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

Sécurité : la GED ne sert plus de script, et l'instance porte ses en-têtes

Trois défauts trouvés en revue, dont le premier était exploitable en un clic
par n'importe quel membre — le rôle « observateur » compris.

Le type MIME d'un document est déclaré par le déposant : c'est une chaîne
libre, elle ne prouve rien du contenu. Il était renvoyé tel quel avec
« inline », donc un fichier text/html ou image/svg+xml déposé dans une crise
s'exécutait sur l'origine de l'application, dans la session de qui l'ouvrait —
et l'interface y menait en un clic. « nosniff » n'y changeait rien : il
interdit de dévier du type déclaré, et le type déclaré était text/html. Seuls
les types qu'on accepte d'ouvrir en ligne le sont désormais ; le reste part en
pièce jointe, en flux d'octets anonyme, avec sa propre politique de contenu.

L'application ne posait aucun en-tête de sécurité. Elle en porte maintenant
une poignée, écrite à la main plutôt qu'empruntée à un greffon : leur contenu
dépend de ce que cette application fait, et une politique générique serait
soit trop large pour servir, soit trop étroite pour laisser la
visioconférence s'ouvrir. « no-referrer » compte autant que le reste : les
jetons de partage voyagent dans le chemin.

Enfin « trustProxy » était vrai sans condition, alors que l'installation
recommandée expose l'application directement. N'importe qui pouvait donc
choisir l'adresse qu'on limiterait en débit — et celle qu'on inscrirait au
journal, où elle a valeur de preuve. Le défaut est désormais de ne croire
personne ; KASTELL_PROXY_DE_CONFIANCE dit à qui se fier.

Co-Authored-By: Claude (RCA)
Signed-off-by: default avatarJordan Grossemy <jordan.grossemy@rca.fr>
parent 106c2d6d
Loading
Loading
Loading
Loading
+15 −0
Original line number Diff line number Diff line
@@ -54,6 +54,21 @@ const schema = z.object({
  CLAMAV_HOTE: z.string().optional(),
  CLAMAV_PORT: z.coerce.number().int().positive().default(3310),
  SECRET_SESSION: z.string().min(32),

  /**
   * À qui l'on fait confiance pour dire l'adresse d'origine d'un appel.
   *
   * L'installation recommandée expose l'application directement, sans reverse
   * proxy. Dans ce cas « X-Forwarded-For » est écrit par l'appelant : le croire
   * revient à le laisser choisir l'adresse qu'on limitera en débit — et celle
   * qu'on inscrira au journal, où elle a valeur de preuve. Le défaut est donc
   * de ne croire personne.
   *
   * Derrière un proxy, renseignez son adresse : « 10.0.0.1 », « 172.18.0.0/16 »,
   * « loopback », ou plusieurs séparées par des virgules. « true » fait
   * confiance à tout le monde : à ne poser que si rien d'autre n'atteint le port.
   */
  KASTELL_PROXY_DE_CONFIANCE: z.string().optional(),
});

export type Env = z.infer<typeof schema>;
+22 −0
Original line number Diff line number Diff line
@@ -17,6 +17,8 @@ import { antivirus, antivirusClamav, definirAntivirus } from "./adaptateurs/anti
import { definirStockage, stockageS3 } from "./adaptateurs/stockage.js";
import { definirMedia, media, mediaConfigure, mediaLiveKit } from "./adaptateurs/media.js";
import { definirPoussee, pousseeRelais } from "./adaptateurs/poussee.js";
import { transportActuel } from "./adaptateurs/notify.js";
import { autoriserReseauLocal } from "./adaptateurs/sortant.js";
import { demarrerEntretien } from "./entretien.js";
import { construireServeur } from "./serveur.js";
import { installerMessagesFrancais } from "@kastell/shared";
@@ -27,6 +29,12 @@ installerMessagesFrancais();
const env = chargerEnv();
const sql = ouvrirBase(env.DATABASE_URL);

// Les sources de veille et la passerelle générique sont des adresses saisies
// par l'organisation : le serveur va les chercher lui-même. Hors production,
// une instance locale est une cible légitime — on développe contre elle. En
// production, elle ne l'est jamais. Voir adaptateurs/sortant.ts.
autoriserReseauLocal(env.KASTELL_ENV !== "production");

console.log("Migrations :");
const faites = await migrer(sql);
if (faites.length === 0) console.log("  (aucune, base à jour)");
@@ -61,6 +69,20 @@ if (env.LIVEKIT_URL && env.LIVEKIT_CLE && env.LIVEKIT_SECRET) {
  }));
}

// Le transport « console » écrit le corps des courriels dans les traces. C'est
// ce qui permet de dérouler tout le parcours sans serveur de messagerie — et
// c'est aussi, en production, un lien de connexion valide offert à quiconque
// lit « docker logs », plus les coordonnées personnelles que promènent les
// campagnes de vérification. On refuse de démarrer plutôt que de le découvrir
// dans un journal.
if (env.KASTELL_ENV === "production" && transportActuel().nom === "console") {
  throw new Error(
    "Aucun transport de courriel n'est configuré : le transport « console » écrirait "
    + "les liens de connexion en clair dans les traces. Configurez un transport avant "
    + "d'exposer cette instance.",
  );
}

const app = await construireServeur(env, sql);
app.log.info({
  diffusion: diffusion().nom, antivirus: antivirus().nom,
+42 −6
Original line number Diff line number Diff line
@@ -18,6 +18,31 @@ import { REDACTEURS, requerirAccesCrise, requerirMembre } from "./garde.js";

const TAILLE_DIRECTE_MAX = 32 * 1024 * 1024;

/**
 * Ce qui peut être ouvert *dans* l'application, et rien d'autre.
 *
 * Le type MIME est déclaré par le déposant : c'est une chaîne libre, elle ne
 * prouve rien du contenu. Le renvoyer tel quel avec « inline » revient à
 * laisser n'importe quel membre — le rôle « observateur » compris — faire
 * exécuter du script sur l'origine de l'application, dans la session de qui
 * ouvre la pièce. « nosniff » n'y change rien : il interdit de dévier du type
 * déclaré, et le type déclaré serait précisément text/html.
 *
 * Tout ce qui n'est pas dans cette liste part en pièce jointe, en flux d'octets
 * anonyme. Le document reste consultable — il se télécharge et s'ouvre dans
 * l'outil qui le comprend — mais il ne s'exécute plus chez nous.
 *
 * image/svg+xml en est absent à dessein : un SVG est un document qui porte du
 * script, pas une image.
 */
const MIME_INLINE_SUR = new Set([
  "application/pdf",
  "text/plain",
  "image/png", "image/jpeg", "image/gif", "image/webp", "image/avif",
  "audio/mpeg", "audio/ogg", "audio/wav", "audio/webm",
  "video/mp4", "video/webm", "video/ogg",
]);

export function enregistrerRoutesDocuments(app: FastifyInstance, sql: Sql): void {
  // Les fichiers arrivent en flux binaire, jamais encodés dans du JSON.
  app.addContentTypeParser("application/octet-stream", { parseAs: "buffer" },
@@ -47,14 +72,24 @@ export function enregistrerRoutesDocuments(app: FastifyInstance, sql: Sql): void
    }
    const taille = Number(v.taille);
    const plage = String(req.headers.range ?? "").match(/bytes=(\d+)-(\d*)/);
    const disposition = enPieceJointe ? "attachment" : "inline";
    const nom = encodeURIComponent(v.nom_fichier);

    reply.header("content-type", v.type_mime);
    reply.header("content-disposition", `${disposition}; filename*=UTF-8''${nom}`);
    // Le type déclaré au dépôt ne vaut que s'il figure parmi ceux qu'on accepte
    // d'ouvrir en ligne. Sinon : pièce jointe, et flux d'octets anonyme — le
    // navigateur n'a alors plus rien à interpréter.
    const type = v.type_mime.split(";")[0]!.trim().toLowerCase();
    const enLigne = !enPieceJointe && MIME_INLINE_SUR.has(type);

    reply.header("content-type", enLigne ? v.type_mime : "application/octet-stream");
    reply.header("content-disposition",
      `${enLigne ? "inline" : "attachment"}; filename*=UTF-8''${nom}`);
    reply.header("accept-ranges", "bytes");
    // Le contenu ne doit pas être interprété comme du script s'il est servi en ligne.
    reply.header("x-content-type-options", "nosniff");
    // Ceinture et bretelles : même pour un type de la liste, la pièce n'a aucun
    // besoin de charger quoi que ce soit ni de scripter. Cette politique est
    // plus étroite que celle de l'application, et remplace ici la sienne.
    reply.header("content-security-policy", "default-src 'none'; sandbox");

    if (plage) {
      const debut = Number(plage[1]);
@@ -92,7 +127,7 @@ export function enregistrerRoutesDocuments(app: FastifyInstance, sql: Sql): void
  });

  app.get("/crises/:criseId/depots/:sousId", acces, async (req, reply) => {
    const etat = await documents.etatDepot(sql, sousId(req));
    const etat = await documents.etatDepot(sql, criseId(req), sousId(req));
    if (!etat) return reply.code(404).send({ code: "introuvable", message: "Dépôt inconnu." });
    return reply.send(etat);
  });
@@ -107,11 +142,12 @@ export function enregistrerRoutesDocuments(app: FastifyInstance, sql: Sql): void
        message: "Envoyez la partie en application/octet-stream.",
      });
    }
    return reply.send(await documents.recevoirPartie(sql, sousId(req), numero, corps));
    return reply.send(
      await documents.recevoirPartie(sql, criseId(req), sousId(req), numero, corps));
  });

  app.post("/crises/:criseId/depots/:sousId/achevement", acces, async (req, reply) =>
    reply.send(await documents.acheverDepot(sql, sousId(req), ctx(req))));
    reply.send(await documents.acheverDepot(sql, criseId(req), sousId(req), ctx(req))));

  /** Voie courte pour les petites pièces : métadonnées en requête, octets en corps. */
  app.post("/crises/:criseId/documents", acces, async (req, reply) => {
+88 −2
Original line number Diff line number Diff line
@@ -34,6 +34,36 @@ export interface OptionsServeur {
  silencieux?: boolean;
}

/**
 * Lecture de KASTELL_PROXY_DE_CONFIANCE dans ce que Fastify sait interpréter.
 * Voir env.ts pour ce que la valeur signifie et pourquoi le défaut est « rien ».
 */
function proxiesDeConfiance(valeur: string | undefined): boolean | string {
  const t = (valeur ?? "").trim();
  if (t === "" || t === "false") return false;
  if (t === "true") return true;
  return t;
}

/**
 * Origines que le navigateur doit pouvoir joindre pour l'appel vidéo.
 *
 * Déduites de la configuration plutôt qu'élargies à « tout https » : une
 * instance sans visioconférence n'autorise alors rien de plus qu'elle n'utilise.
 * L'adresse est écrite tantôt en ws(s)://, tantôt en http(s):// selon
 * l'exploitant — les deux formes de la même origine sont donc admises.
 */
function origineMedia(url: string | undefined): string[] {
  if (!url) return [];
  try {
    const u = new URL(url);
    const sur = u.protocol === "wss:" || u.protocol === "https:";
    return sur ? [`wss://${u.host}`, `https://${u.host}`] : [`ws://${u.host}`, `http://${u.host}`];
  } catch {
    return [];
  }
}

export async function construireServeur(
  env: Env, sql: Sql, options: OptionsServeur = {},
): Promise<FastifyInstance> {
@@ -41,7 +71,58 @@ export async function construireServeur(
    logger: options.silencieux
      ? false
      : { level: env.KASTELL_ENV === "production" ? "info" : "debug" },
    trustProxy: true,
    trustProxy: proxiesDeConfiance(env.KASTELL_PROXY_DE_CONFIANCE),
  });

  // ── En-têtes de sécurité ─────────────────────────────────────────────────
  //
  // Écrits ici plutôt qu'empruntés à un greffon : c'est une poignée d'en-têtes
  // qu'on relit, et leur contenu dépend de ce que cette application fait
  // précisément. Une politique générique serait soit trop large pour servir,
  // soit trop étroite pour laisser la visioconférence s'ouvrir.
  //
  // La politique de contenu est la seule barrière qui reste si une injection
  // passe : le terminal n'est pas maîtrisé (P5) et le jeton de session, même
  // inaccessible au script, se laisse chevaucher par une requête émise depuis
  // la page. Les documents servis en ligne posent la leur, bien plus étroite —
  // voir routes/documents.ts.
  const politiqueContenu = [
    "default-src 'self'",
    "script-src 'self'",
    // Les écrans posent leurs marges en attribut (`style={{…}}`), et la feuille
    // de caractères vient de Google Fonts — voir apps/web/index.html.
    "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
    "font-src 'self' https://fonts.gstatic.com data:",
    // Les codes d'enrôlement sont rendus en data:, les images de la toile en blob:.
    "img-src 'self' data: blob:",
    "media-src 'self' blob:",
    // La toile partagée charge ses travailleurs depuis un blob.
    "worker-src 'self' blob:",
    `connect-src ${["'self'", ...origineMedia(env.LIVEKIT_URL)].join(" ")}`,
    "object-src 'none'",
    "base-uri 'self'",
    "form-action 'self'",
    "frame-ancestors 'none'",
  ].join("; ");

  const surTls = env.KASTELL_URL_PUBLIQUE.startsWith("https://");

  app.addHook("onRequest", async (req, reply) => {
    // La montée en WebSocket ne se termine pas par une réponse HTTP ordinaire :
    // ces en-têtes n'y auraient pas de destinataire.
    if (req.headers.upgrade) return;

    reply.header("content-security-policy", politiqueContenu);
    reply.header("x-content-type-options", "nosniff");
    reply.header("x-frame-options", "DENY");
    // Aucun référent : les jetons de partage voyagent dans le chemin
    // (« /partages/<jeton> »), et un référent les livrerait au premier lien
    // sortant cliqué depuis la page.
    reply.header("referrer-policy", "no-referrer");
    reply.header("cross-origin-opener-policy", "same-origin");
    if (surTls) {
      reply.header("strict-transport-security", "max-age=31536000; includeSubDomains");
    }
  });

  // La notification du serveur média arrive signée : son corps doit rester
@@ -62,7 +143,12 @@ export async function construireServeur(
      return { etat: "pret", base: "joignable" };
    } catch (e) {
      reply.code(503);
      return { etat: "indisponible", base: "injoignable", detail: String(e) };
      // Le détail part aux traces, pas dans la réponse : cette sonde est
      // ouverte, et l'erreur brute d'un pilote de base nomme l'hôte,
      // l'utilisateur et parfois la version. L'exploitant qui diagnostique lit
      // les traces ; le passant n'a pas à lire la topologie.
      app.log.error({ err: e }, "sonde de disponibilité : base injoignable");
      return { etat: "indisponible", base: "injoignable" };
    }
  });

+60 −0
Original line number Diff line number Diff line
@@ -27,6 +27,66 @@ rend illisibles — c'est voulu, et il faut le sauvegarder ailleurs que dans la
base qu'il protège.
:::

### Derrière un reverse proxy

| Variable | Rôle |
|---|---|
| `KASTELL_PROXY_DE_CONFIANCE` | Qui a le droit de dire l'adresse d'origine d'un appel. Vide par défaut. |

L'adresse d'origine sert à deux choses : limiter le débit des tentatives de
connexion, et s'inscrire au journal, où elle a valeur de preuve. Elle est lue
dans l'en-tête `X-Forwarded-For` — que **l'appelant peut écrire lui-même**.

Tant que cette variable est vide, l'en-tête est ignoré et c'est l'adresse de la
connexion qui compte. C'est le bon réglage si l'application est exposée
directement.

Si vous avez placé un proxy devant, renseignez son adresse — sinon toutes les
tentatives paraîtront venir de lui, et une limite atteinte par un seul
utilisateur bloquerait tout le monde :

```
KASTELL_PROXY_DE_CONFIANCE=172.18.0.0/16
```

Sont acceptés : une ou plusieurs adresses ou plages séparées par des virgules,
et le mot `loopback`. `true` fait confiance à tout le monde : à ne poser que si
rien d'autre que le proxy n'atteint le port.

### Images de la composition

Toutes les images sont épinglées par **version explicite et empreinte** :

```
image: postgres:17.11-alpine3.24@sha256:18cfe3ef…
```

Une étiquette comme `:latest` ou `17-alpine` bouge : deux démarrages à un mois
d'écart ne partent pas de la même image, et rien ne dit laquelle tourne. Sur un
outil de crise, savoir exactement ce qui est déployé fait partie du produit.

Les empreintes sont celles des **manifestes multi-architecture** : une empreinte
propre à une plateforme casserait l'autre, et la composition doit tourner sur
amd64 comme sur arm64.

Pour monter de version : relever la nouvelle empreinte, puis remplacer version
et empreinte ensemble.

```bash
docker buildx imagetools inspect postgres:17-alpine
```

### Courriel

Aucun transport de courriel n'est encore livré. En `KASTELL_ENV=production`,
l'instance **refuse de démarrer** plutôt que d'utiliser le transport de
développement, qui écrit les messages dans les traces — donc les liens de
connexion en clair, à la portée de qui lit les journaux.

Les parcours concernés sont la connexion par lien, la vérification d'adresse et
les campagnes de vérification de l'annuaire. Le reste de l'instance n'en dépend
pas.

### Offre de code source — AGPL, section 13

| Variable | Rôle |