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

Sécurité : un identifiant d'enfant ne suffit plus, la portée est vérifiée

Plusieurs lectures et écritures ne portaient que sur l'identifiant de la
ressource, sans son parent. La garde de route vérifiait bien que l'appelant a
accès à la crise ou à l'organisation nommée dans le chemin — mais c'est
l'appelant qui écrit ce chemin. Présenter sa propre crise et l'identifiant
d'un dépôt d'ailleurs satisfaisait donc la garde.

Ce que cela permettait : lire l'état du dépôt d'une autre organisation, y
injecter des octets, ou l'achever ; lire les noms et adresses des personnes
qui n'ont pas répondu à la campagne de vérification d'une autre ; supprimer
une période d'indisponibilité qui n'est pas la sienne ; déclarer absente une
personne d'un autre annuaire, donc la retirer d'une mobilisation étrangère.

L'imprévisibilité des UUID freinait l'exploitation, elle ne la fermait pas —
et un garde absent ne se remarque pas tant qu'on ne le cherche pas.

Co-Authored-By: Claude (RCA)
Signed-off-by: default avatarJordan Grossemy <jordan.grossemy@rca.fr>
parent 8bb1809f
Loading
Loading
Loading
Loading
+20 −8
Original line number Diff line number Diff line
@@ -137,23 +137,35 @@ export async function ouvrirDepot(
  return { depotId: depot!.id, taillePartie: TAILLE_PARTIE_MIN };
}

export async function etatDepot(sql: Sql, depotId: string): Promise<EtatDepot | null> {
/**
 * Un dépôt se lit toujours par le couple crise + dépôt, jamais par le seul
 * identifiant de dépôt.
 *
 * La garde de route vérifie que l'appelant a accès à la crise nommée dans le
 * chemin — mais c'est l'appelant qui écrit ce chemin. Sans la crise dans la
 * clause « where », un membre pouvait présenter sa propre crise et l'identifiant
 * du dépôt d'une autre organisation : la garde était satisfaite, et la requête
 * servait le dépôt d'autrui.
 */
export async function etatDepot(
  sql: Sql, criseId: string, depotId: string,
): Promise<EtatDepot | null> {
  const [d] = await sql<(Omit<EtatDepot, "prochaine_partie">)[]>`
    select id, octets_recus::text, taille_declaree::text, parties, acheve_at, expire_at
      from depot where id = ${depotId}`;
      from depot where id = ${depotId} and crise_id = ${criseId}`;
  if (!d) return null;
  return { ...d, prochaine_partie: d.parties.length + 1 };
}

export async function recevoirPartie(
  sql: Sql, depotId: string, numero: number, corps: Buffer,
  sql: Sql, criseId: string, depotId: string, numero: number, corps: Buffer,
): Promise<EtatDepot> {
  const [d] = await sql<{ cle_objet: string; televersement_id: string; parties: Partie[];
    octets_recus: string; taille_declaree: string; acheve_at: Date | null;
    expire_at: Date }[]>`
    select cle_objet, televersement_id, parties, octets_recus::text, taille_declaree::text,
           acheve_at, expire_at
      from depot where id = ${depotId}`;
      from depot where id = ${depotId} and crise_id = ${criseId}`;
  if (!d) throw new Error("Dépôt inconnu.");
  if (d.acheve_at) throw new Error("Ce dépôt est déjà achevé.");
  if (d.expire_at < new Date()) throw new Error("Ce dépôt a expiré.");
@@ -161,7 +173,7 @@ export async function recevoirPartie(
  // Réémission d'une partie déjà reçue : le réseau a coupé au mauvais moment,
  // ce n'est pas une erreur.
  const deja = d.parties.find((p) => p.numero === numero);
  if (deja) return (await etatDepot(sql, depotId))!;
  if (deja) return (await etatDepot(sql, criseId, depotId))!;

  if (numero !== d.parties.length + 1) {
    throw new Error(`Partie ${d.parties.length + 1} attendue, ${numero} reçue.`);
@@ -176,7 +188,7 @@ export async function recevoirPartie(
    update depot set parties = ${sql.json(parties as never)},
                     octets_recus = octets_recus + ${corps.length}
     where id = ${depotId}`;
  return (await etatDepot(sql, depotId))!;
  return (await etatDepot(sql, criseId, depotId))!;
}

const NUL = String.fromCharCode(0);
@@ -194,7 +206,7 @@ function extraireTexte(typeMime: string, contenu: Buffer): string | null {
 * du contenu réellement stocké — pas celle qu'un client aurait annoncée.
 */
export async function acheverDepot(
  sql: Sql, depotId: string, ctx: Contexte,
  sql: Sql, criseId: string, depotId: string, ctx: Contexte,
): Promise<{ documentId: string; version: number; empreinte: string; verdict: Verdict }> {
  const [d] = await sql<{ crise_id: string; cellule_id: string | null; document_id: string;
    dossier: string; titre: string; nom_fichier: string; type_mime: string;
@@ -204,7 +216,7 @@ export async function acheverDepot(
    select crise_id, cellule_id, document_id, dossier, titre, nom_fichier, type_mime,
           cle_objet, televersement_id, parties, octets_recus::text, taille_declaree::text,
           acheve_at, message_id
      from depot where id = ${depotId}`;
      from depot where id = ${depotId} and crise_id = ${criseId}`;
  if (!d) throw new Error("Dépôt inconnu.");
  if (d.acheve_at) throw new Error("Ce dépôt est déjà achevé.");
  if (d.octets_recus !== d.taille_declaree) {
+23 −6
Original line number Diff line number Diff line
@@ -94,18 +94,35 @@ export async function periodes(sql: Sql, organisationId: string): Promise<Indisp
     order by i.du`;
}

/**
 * Une période se pose sur une personne de *son* organisation.
 *
 * L'identifiant de la personne vient du corps de la requête : sans ce contrôle,
 * un rédacteur pouvait déclarer absent quelqu'un d'une autre organisation — donc
 * le retirer des personnes joignables d'une mobilisation qui n'est pas la sienne.
 */
export async function ajouterPeriode(
  sql: Sql, personneId: string, genre: "astreinte" | "absence",
  sql: Sql, organisationId: string, personneId: string, genre: "astreinte" | "absence",
  du: Date, au: Date, motif?: string,
): Promise<string> {
): Promise<string | null> {
  const [r] = await sql<{ id: string }[]>`
    insert into indisponibilite (personne_id, genre, du, au, motif)
    values (${personneId}, ${genre}, ${du}, ${au}, ${motif ?? null}) returning id`;
  return r!.id;
    select ${personneId}, ${genre}, ${du}, ${au}, ${motif ?? null}
     where exists (select 1 from personne_courante
                    where id = ${personneId} and organisation_id = ${organisationId})
    returning id`;
  return r?.id ?? null;
}

export async function retirerPeriode(sql: Sql, id: string): Promise<boolean> {
  const r = await sql`delete from indisponibilite where id = ${id}`;
/** Même raison : on ne supprime que dans son propre annuaire. */
export async function retirerPeriode(
  sql: Sql, organisationId: string, id: string,
): Promise<boolean> {
  const r = await sql`
    delete from indisponibilite
     where id = ${id}
       and personne_id in (select id from personne_courante
                            where organisation_id = ${organisationId})`;
  return r.count > 0;
}

+15 −3
Original line number Diff line number Diff line
@@ -163,10 +163,22 @@ export async function repondre(
  return { personneId: demande.personne_id, corrigee };
}

export async function etat(sql: Sql, campagneId: string): Promise<EtatCampagne | null> {
/**
 * L'état d'une campagne se lit par le couple organisation + campagne.
 *
 * Ce que cette fonction rend nomme les personnes qui n'ont pas répondu, avec
 * leur adresse : c'est un extrait d'annuaire. Sans l'organisation dans la clause
 * « where », un membre d'une organisation quelconque pouvait le lire pour une
 * campagne d'une autre — la garde de route n'ayant vérifié que son
 * appartenance à celle qu'il avait lui-même nommée dans le chemin.
 */
export async function etat(
  sql: Sql, organisationId: string, campagneId: string,
): Promise<EtatCampagne | null> {
  const [c] = await sql<Campagne[]>`
    select id, lancee_at, lancee_par, cloturee_at, echeance_at
      from campagne_verification where id = ${campagneId}`;
      from campagne_verification
     where id = ${campagneId} and organisation_id = ${organisationId}`;
  if (!c) return null;

  const demandes = await sql<{ nom: string; email: string; repondu_at: Date | null }[]>`
@@ -188,5 +200,5 @@ export async function derniere(sql: Sql, organisationId: string): Promise<EtatCa
  const [c] = await sql<{ id: string }[]>`
    select id from campagne_verification where organisation_id = ${organisationId}
     order by lancee_at desc limit 1`;
  return c ? etat(sql, c.id) : null;
  return c ? etat(sql, organisationId, c.id) : null;
}
+7 −3
Original line number Diff line number Diff line
@@ -83,13 +83,17 @@ export function enregistrerRoutesPreparation(app: FastifyInstance, env: Env, sql
    if (c.au <= c.du) {
      return reply.code(400).send({ code: "periode_invalide", message: "La fin précède le début." });
    }
    const id = await annuaire.ajouterPeriode(sql, c.personneId, c.genre, c.du, c.au, c.motif);
    const id = await annuaire.ajouterPeriode(sql, org(req), c.personneId, c.genre,
      c.du, c.au, c.motif);
    if (!id) {
      return reply.code(404).send({ code: "introuvable", message: "Personne inconnue." });
    }
    return reply.code(201).send({ id });
  });

  app.delete("/organisations/:id/preparation/indisponibilites/:sousId", ecriture,
    async (req, reply) => {
      const retiree = await annuaire.retirerPeriode(sql, sousId(req));
      const retiree = await annuaire.retirerPeriode(sql, org(req), sousId(req));
      if (!retiree) return reply.code(404).send({ code: "introuvable", message: "Période inconnue." });
      return reply.send({ message: "Période retirée." });
    });
@@ -172,7 +176,7 @@ export function enregistrerRoutesPreparation(app: FastifyInstance, env: Env, sql
  });

  app.get("/organisations/:id/preparation/campagnes/:sousId", lecture, async (req, reply) => {
    const e = await campagne.etat(sql, sousId(req));
    const e = await campagne.etat(sql, org(req), sousId(req));
    if (!e) return reply.code(404).send({ code: "introuvable", message: "Campagne inconnue." });
    return reply.send(e);
  });
+7 −7
Original line number Diff line number Diff line
@@ -63,21 +63,21 @@ export async function verifierDocuments(
  r.verifie("EF-902", "le dépôt annonce la taille de partie attendue",
    depot.taillePartie === documents.TAILLE_PARTIE_MIN);

  await documents.recevoirPartie(sql, depot.depotId, 1, bloc1);
  await documents.recevoirPartie(sql, crise.criseId, depot.depotId, 1, bloc1);
  // Coupure réseau : le client revient et demande où il en était.
  const reprise = await documents.etatDepot(sql, depot.depotId);
  const reprise = await documents.etatDepot(sql, crise.criseId, depot.depotId);
  r.verifie("EF-902", "après coupure, le dépôt dit quelle partie reprendre",
    reprise?.prochaine_partie === 2 && Number(reprise.octets_recus) === bloc1.length);

  const rejeu = await documents.recevoirPartie(sql, depot.depotId, 1, bloc1);
  const rejeu = await documents.recevoirPartie(sql, crise.criseId, depot.depotId, 1, bloc1);
  r.verifie("EF-902", "réémettre une partie déjà reçue n'est pas une erreur",
    Number(rejeu.octets_recus) === bloc1.length);

  await r.refuse("EF-902", "une partie hors séquence est refusée",
    () => documents.recevoirPartie(sql, depot.depotId, 5, bloc2));
    () => documents.recevoirPartie(sql, crise.criseId, depot.depotId, 5, bloc2));

  await documents.recevoirPartie(sql, depot.depotId, 2, bloc2);
  const acheve = await documents.acheverDepot(sql, depot.depotId, ctx);
  await documents.recevoirPartie(sql, crise.criseId, depot.depotId, 2, bloc2);
  const acheve = await documents.acheverDepot(sql, crise.criseId, depot.depotId, ctx);
  r.verifie("EF-902", "le dépôt s'achève et produit la première version",
    acheve.version === 1);
  r.verifie("EB-07", "l'empreinte est celle du contenu réellement stocké",
@@ -94,7 +94,7 @@ export async function verifierDocuments(
    `charge de ${JSON.stringify(evenement?.charge ?? {}).length} octets pour un fichier de ${complet.length}`);

  await r.refuse("EF-902", "un dépôt achevé ne se rejoue pas",
    () => documents.acheverDepot(sql, depot.depotId, ctx));
    () => documents.acheverDepot(sql, crise.criseId, depot.depotId, ctx));

  // ── Versions ─────────────────────────────────────────────────────────────
  r.titre("Versions");