Verified Commit 8bb1809f authored by Kourser's avatar Kourser
Browse files

Sécurité : une adresse fournie par une organisation ne vise plus l'interne

Deux fonctions laissent une organisation désigner une adresse que le serveur
ira chercher : les sources de veille et la passerelle de diffusion générique.
C'est délibéré — c'est ce qui évite qu'ajouter un opérateur devienne une
version du produit à attendre pendant une crise.

Mais rien ne vérifiait où ces adresses pointaient. Le serveur atteignait donc
la base, le cache et le stockage objet que la composition publie sur la boucle
locale, ainsi que le point de métadonnées d'un hébergeur, qui rend des
identifiants. La passerelle générique autorisait même explicitement
« http://127.0.0.1 », et remontait les deux cents premiers octets de la
réponse dans son message d'erreur : la lecture était donc offerte avec
l'accès. L'inscription étant en libre-service, quiconque pouvait créer un
compte devenait propriétaire d'une organisation, donc capable des deux.

Les destinations passent maintenant par un chemin unique, qui exige HTTPS,
résout le nom et refuse boucle locale, plages privées, lien-local et CGNAT —
en IPv4 comme en IPv6, IPv4 encapsulé compris. Les redirections sont suivies à
la main pour que chaque saut soit revérifié, et le corps est borné : un flux
sans fin est une panne de mémoire à distance.

Ce que cela ne ferme pas est dit dans le fichier : le nom est résolu ici puis
résolu à nouveau par la pile réseau à la connexion, et épingler la connexion
sur l'adresse vérifiée demanderait une dépendance de plus.

Les passerelles dont l'hôte est écrit dans le code — OVH, Brevo — n'en
relèvent pas : leur adresse n'est pas fournie, elle est connue.

Co-Authored-By: Claude (RCA)
Signed-off-by: default avatarJordan Grossemy <jordan.grossemy@rca.fr>
parent a28393bc
Loading
Loading
Loading
Loading
+214 −0
Original line number Diff line number Diff line
import { lookup } from "node:dns/promises";
import { isIP } from "node:net";

/**
 * Appels sortants vers une adresse fournie par une organisation.
 *
 * Deux fonctions du produit laissent une organisation désigner une adresse que
 * *le serveur* ira chercher : les sources de veille (§6.13) et la passerelle de
 * diffusion générique (EF-1105). C'est délibéré — c'est ce qui évite qu'ajouter
 * un opérateur devienne une version du produit à attendre pendant une crise.
 *
 * Mais une adresse fournie par un tiers est une requête émise depuis l'intérieur
 * du réseau. Sans contrôle, elle atteint ce que le serveur atteint et rien de
 * plus n'est nécessaire : la base, le cache, le stockage objet — que la
 * composition publie sur la boucle locale de l'hôte — ou le point de métadonnées
 * d'un hébergeur, qui rend des identifiants. Et l'inscription étant en
 * libre-service, quiconque peut créer un compte devient propriétaire d'une
 * organisation, donc capable de configurer ces deux fonctions.
 *
 * D'où ce module, unique chemin de sortie pour ces adresses-là. Les passerelles
 * dont l'hôte est écrit dans le code — OVH, Brevo — n'en relèvent pas : leur
 * adresse n'est pas fournie, elle est connue.
 *
 * ┌─ Ce que ce module ne ferme pas ──────────────────────────────────────────┐
 * │ Le nom est résolu ici, puis résolu à nouveau par la pile réseau au       │
 * │ moment de la connexion. Un serveur de noms hostile qui répond            │
 * │ différemment aux deux résolutions passe donc au travers. Fermer cette    │
 * │ fenêtre demande d'épingler la connexion sur l'adresse vérifiée, ce que   │
 * │ « fetch » ne permet pas sans dépendance supplémentaire. C'est dit ici    │
 * │ plutôt que laissé croire : le contrôle porte sur le nom, pas sur la      │
 * │ connexion.                                                              │
 * └─────────────────────────────────────────────────────────────────────────┘
 */

const DELAI_MS = 15_000;
const MAX_OCTETS = 2 * 1024 * 1024;
const MAX_REDIRECTIONS = 3;

export class DestinationRefusee extends Error {
  constructor(message: string) {
    super(message);
    this.name = "DestinationRefusee";
  }
}

/**
 * Le réseau local est-il une destination acceptable ?
 *
 * Faux en production, et c'est le seul réglage qui compte. Vrai en
 * développement et sous la suite de vérification, qui éprouve la passerelle
 * générique contre un vrai serveur sur 127.0.0.1 — sans quoi le contrat du
 * fournisseur ne serait vérifié que contre un bouchon.
 */
let reseauLocalAutorise = false;

export function autoriserReseauLocal(oui: boolean): void {
  reseauLocalAutorise = oui;
}

/** Plages IPv4 qui ne désignent jamais un tiers sur l'internet public. */
function v4Interdite(ip: string): boolean {
  const o = ip.split(".").map(Number);
  if (o.length !== 4 || o.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) {
    return true; // illisible : on refuse plutôt que de deviner
  }
  const [a, b] = o as [number, number, number, number];
  if (a === 0) return true;                            // 0.0.0.0/8
  if (a === 10) return true;                           // privé
  if (a === 127) return true;                          // boucle locale
  if (a === 169 && b === 254) return true;             // lien-local, dont 169.254.169.254
  if (a === 172 && b >= 16 && b <= 31) return true;    // privé
  if (a === 192 && b === 168) return true;             // privé
  if (a === 100 && b >= 64 && b <= 127) return true;   // partagé entre abonnés (CGNAT)
  if (a === 192 && b === 0 && o[2] === 0) return true; // affectations de protocole
  if (a === 198 && (b === 18 || b === 19)) return true; // bancs d'essai
  if (a >= 224) return true;                           // multicast, réservé, diffusion
  return false;
}

/** Idem pour IPv6, en tenant compte de l'IPv4 encapsulé. */
function v6Interdite(ip: string): boolean {
  const bas = ip.toLowerCase().split("%")[0]!; // l'identifiant de zone ne nous concerne pas

  // Une adresse IPv4 habillée en IPv6 reste une adresse IPv4 : c'est elle qui
  // décide. Écrite en hexadécimal, on ne cherche pas à la relire — on refuse.
  if (bas.startsWith("::ffff:")) {
    const v4 = bas.slice(7);
    return /^\d+\.\d+\.\d+\.\d+$/.test(v4) ? v4Interdite(v4) : true;
  }

  if (bas === "::1" || bas === "::") return true;
  if (/^f[cd]/.test(bas)) return true;          // fc00::/7, adresses locales uniques
  if (/^fe[89ab]/.test(bas)) return true;       // fe80::/10, lien-local
  if (/^ff/.test(bas)) return true;             // multicast
  if (bas.startsWith("64:ff9b:")) return true;  // NAT64
  if (bas.startsWith("100:")) return true;      // 100::/64, trou noir
  if (bas.startsWith("2002:")) return true;     // 6to4 : encapsule une adresse v4
  return false;
}

/**
 * Vérifie une destination avant de s'y connecter. Lève `DestinationRefusee`.
 *
 * Le message nomme l'hôte, jamais l'adresse résolue : dire « ce nom pointe vers
 * le réseau interne » suffit à corriger sa saisie, et ne dessine pas la
 * topologie pour qui cherchait à la lire.
 */
export async function verifierDestination(url: URL): Promise<void> {
  const clairAdmis = url.protocol === "http:" && reseauLocalAutorise;
  if (url.protocol !== "https:" && !clairAdmis) {
    throw new DestinationRefusee(
      "L'adresse doit être en HTTPS : un jeton envoyé en clair est un jeton perdu.");
  }

  if (reseauLocalAutorise) return;

  const hote = url.hostname.replace(/^\[/, "").replace(/\]$/, "");
  let adresses: string[];
  if (isIP(hote)) {
    adresses = [hote];
  } else {
    try {
      adresses = (await lookup(hote, { all: true })).map((a) => a.address);
    } catch {
      throw new DestinationRefusee(`Le nom « ${hote} » ne se résout pas.`);
    }
  }
  if (adresses.length === 0) throw new DestinationRefusee(`Le nom « ${hote} » ne se résout pas.`);

  for (const a of adresses) {
    if (isIP(a) === 6 ? v6Interdite(a) : v4Interdite(a)) {
      throw new DestinationRefusee(
        ${hote} » désigne une adresse du réseau interne. Une source de veille ou une `
        + "passerelle doit être joignable sur l'internet public.");
    }
  }
}

export interface AppelSortant {
  methode?: "GET" | "POST" | undefined;
  entetes?: Record<string, string> | undefined;
  corps?: string | undefined;
  delaiMs?: number | undefined;
  maxOctets?: number | undefined;
}

export interface ReponseSortante {
  statut: number;
  ok: boolean;
  texte: string;
}

/**
 * Appelle une adresse fournie par une organisation, et rend un corps borné.
 *
 * Les redirections sont suivies à la main : sinon la vérification ne porterait
 * que sur le premier saut, et une adresse publique irréprochable pourrait
 * renvoyer vers la boucle locale. Le corps est borné parce qu'un flux sans fin
 * est une panne de mémoire à distance.
 */
export async function appeler(
  adresse: string, options: AppelSortant = {},
): Promise<ReponseSortante> {
  let url: URL;
  try {
    url = new URL(adresse);
  } catch {
    throw new DestinationRefusee("Adresse illisible.");
  }

  let reponse: Response | null = null;
  for (let saut = 0; saut <= MAX_REDIRECTIONS; saut += 1) {
    await verifierDestination(url);
    reponse = await fetch(url, {
      method: options.methode ?? "GET",
      headers: options.entetes ?? {},
      ...(options.corps === undefined ? {} : { body: options.corps }),
      // Chaque saut repasse par la vérification ci-dessus.
      redirect: "manual",
      signal: AbortSignal.timeout(options.delaiMs ?? DELAI_MS),
    });

    const suivante = [301, 302, 303, 307, 308].includes(reponse.status)
      ? reponse.headers.get("location") : null;
    if (!suivante) break;
    if (saut === MAX_REDIRECTIONS) throw new DestinationRefusee("Trop de redirections.");
    url = new URL(suivante, url);
    reponse = null;
  }
  if (!reponse) throw new DestinationRefusee("Trop de redirections.");

  const max = options.maxOctets ?? MAX_OCTETS;
  let texte = "";
  if (reponse.body) {
    const lecteur = reponse.body.getReader();
    const morceaux: Uint8Array[] = [];
    let total = 0;
    for (;;) {
      const { done, value } = await lecteur.read();
      if (done) break;
      if (!value) continue;
      total += value.length;
      if (total > max) {
        await lecteur.cancel();
        throw new DestinationRefusee(
          `La réponse dépasse ${Math.floor(max / 1024)} Kio : source écartée.`);
      }
      morceaux.push(value);
    }
    texte = Buffer.concat(morceaux).toString("utf8");
  }

  return { statut: reponse.status, ok: reponse.ok, texte };
}
+24 −12
Original line number Diff line number Diff line
import { createHash } from "node:crypto";
import { appeler } from "../adaptateurs/sortant.js";

/**
 * Fournisseurs de diffusion, branchés par l'organisation elle-même.
@@ -39,6 +40,14 @@ export interface Definition {

const DELAI_MS = 12_000;

/**
 * Appel direct, réservé aux passerelles dont l'hôte est écrit dans ce fichier —
 * OVH, Brevo. Leur adresse n'est pas fournie par l'organisation : il n'y a donc
 * pas de destination à vérifier, et le corps de leur réponse est un diagnostic
 * utile qu'on peut remonter tel quel.
 *
 * La passerelle générique, dont l'adresse *est* saisie, ne passe pas par ici.
 */
async function poster(url: string, init: RequestInit): Promise<Response> {
  const r = await fetch(url, { ...init, signal: AbortSignal.timeout(DELAI_MS) });
  if (!r.ok) {
@@ -154,14 +163,6 @@ export function generique(
    nom: "generique",
    genre,
    async envoyer(e) {
      // Une clé d'API qui part en clair est une clé publiée. On ne l'accepte
      // qu'en clair vers la machine locale, pour l'essai et le développement.
      const cible = new URL(r.url);
      const locale = ["localhost", "127.0.0.1", "::1"].includes(cible.hostname);
      if (cible.protocol !== "https:" && !locale) {
        throw new Error("L'adresse de l'API doit être en HTTPS : "
          + "un jeton envoyé en clair est un jeton perdu.");
      }
      const remplacer = (t: string) => t
        .replaceAll("{{destinataire}}", e.destinataire)
        .replaceAll("{{texte}}", JSON.stringify(e.texte).slice(1, -1))
@@ -172,11 +173,22 @@ export function generique(
      const valeur = entete.toLowerCase() === "authorization"
        ? `Bearer ${id.jeton}` : id.jeton;

      await poster(r.url, {
        method: "POST",
        headers: { "content-type": "application/json", [entete]: valeur },
        body: remplacer(r.gabarit),
      // Seule passerelle dont l'adresse est saisie par l'organisation : elle
      // passe donc par la vérification de destination, qui exige HTTPS et
      // refuse le réseau interne. Voir adaptateurs/sortant.ts.
      //
      // Et contrairement à `poster`, la réponse n'est pas remontée dans
      // l'erreur : sur une adresse qu'on n'a pas choisie, ce serait offrir la
      // lecture de ce que la vérification empêche d'atteindre.
      const reponse = await appeler(r.url, {
        methode: "POST",
        entetes: { "content-type": "application/json", [entete]: valeur },
        corps: remplacer(r.gabarit),
        delaiMs: DELAI_MS,
      });
      if (!reponse.ok) {
        throw new Error(`La passerelle a répondu ${reponse.statut}.`);
      }
    },
  };
}
+20 −10
Original line number Diff line number Diff line
import { createHash } from "node:crypto";
import { appeler } from "../adaptateurs/sortant.js";

/**
 * Sources de veille externe (§6.13).
@@ -43,13 +44,22 @@ export function empreinte(s: Signal): Buffer {
    .digest();
}

async function lire(url: string, entetes: Record<string, string> = {}): Promise<Response> {
  const r = await fetch(url, {
    headers: { "user-agent": "Kastell/veille", ...entetes },
    signal: AbortSignal.timeout(DELAI_MS),
/**
 * Toute lecture d'une source passe par ici, et donc par la vérification de la
 * destination : l'adresse est saisie par l'organisation, elle vaut ce que vaut
 * une saisie. Voir adaptateurs/sortant.ts.
 *
 * Le corps de la réponse n'est jamais remonté dans une erreur : ce serait
 * rendre lisible ce que la vérification cherche justement à ne pas laisser
 * atteindre.
 */
async function lire(url: string, entetes: Record<string, string> = {}): Promise<string> {
  const r = await appeler(url, {
    entetes: { "user-agent": "Kastell/veille", ...entetes },
    delaiMs: DELAI_MS,
  });
  if (!r.ok) throw new Error(`${r.status} ${r.statusText}`);
  return r;
  if (!r.ok) throw new Error(`La source a répondu ${r.statut}.`);
  return r.texte;
}

// ── RSS et Atom ────────────────────────────────────────────────────────────
@@ -74,7 +84,7 @@ const balise = (bloc: string, nom: string): string | null => {
 * suffisamment mal formés pour qu'aucune bibliothèque ne les prenne tous.
 */
export async function rss(s: Source): Promise<Signal[]> {
  const texte = await (await lire(s.adresse)).text();
  const texte = await lire(s.adresse);
  const blocs = [
    ...texte.matchAll(/<item[\s>][\s\S]*?<\/item>/gi),
    ...texte.matchAll(/<entry[\s>][\s\S]*?<\/entry>/gi),
@@ -109,7 +119,7 @@ export async function bluesky(s: Source, terme: string): Promise<Signal[]> {
  const base = s.adresse || "https://public.api.bsky.app";
  const url = `${base.replace(/\/+$/, "")}/xrpc/app.bsky.feed.searchPosts`
    + `?q=${encodeURIComponent(terme)}&limit=${PLAFOND}&sort=latest`;
  const r = await (await lire(url)).json() as {
  const r = JSON.parse(await lire(url)) as {
    posts?: { uri: string; author?: { handle?: string };
      record?: { text?: string; createdAt?: string } }[];
  };
@@ -131,7 +141,7 @@ export async function mastodon(s: Source, terme: string): Promise<Signal[]> {
    + `&type=statuses&limit=40&resolve=false`;
  const entetes = s.identifiants?.["jeton"]
    ? { authorization: `Bearer ${s.identifiants["jeton"]}` } : {};
  const r = await (await lire(url, entetes)).json() as {
  const r = JSON.parse(await lire(url, entetes)) as {
    statuses?: { content?: string; url?: string; created_at?: string;
      account?: { acct?: string } }[];
  };
@@ -159,7 +169,7 @@ export async function generique(s: Source, terme: string): Promise<Signal[]> {
  const entetes = jeton
    ? { [cle]: cle.toLowerCase() === "authorization" ? `Bearer ${jeton}` : jeton } : {};

  const corps = await (await lire(url, entetes)).json() as Record<string, unknown>;
  const corps = JSON.parse(await lire(url, entetes)) as Record<string, unknown>;
  const chemin = (o: unknown, c: string): unknown =>
    c.split(".").reduce<unknown>((acc, k) => (acc as Record<string, unknown>)?.[k], o);

+7 −0
Original line number Diff line number Diff line
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
import { chargerEnv } from "./env.js";
import { definirStockage, stockageS3 } from "./adaptateurs/stockage.js";
import { definirAntivirus } from "./adaptateurs/antivirus.js";
import { autoriserReseauLocal } from "./adaptateurs/sortant.js";
import { antivirusEssai } from "./verification/antivirus_essai.js";
import { ouvrirBase } from "./db/client.js";
import { migrer } from "./db/migrate.js";
@@ -108,6 +109,12 @@ await sql.unsafe("drop schema public cascade; create schema public;");

interceptet();

// La suite éprouve la passerelle générique contre un vrai serveur sur
// 127.0.0.1 : c'est le seul moyen de vérifier le contrat réellement transmis —
// en-tête d'authentification, gabarit substitué, corps de la requête. Il faut
// donc lever la garde de destination ici, comme en développement.
autoriserReseauLocal(true);

/**
 * Le stockage objet est branché ici, avant toute suite.
 *
+56 −0
Original line number Diff line number Diff line
@@ -10,6 +10,9 @@ import { mainCourante } from "../noyau/rejeu.js";
import { verifierEnBase } from "../noyau/integrite.js";
import * as veille from "../veille/veille.js";
import { rss } from "../veille/sources.js";
import {
  appeler as appelSortant, autoriserReseauLocal, verifierDestination,
} from "../adaptateurs/sortant.js";
import type { Rapport } from "./rapport.js";

const adresse = (p: string) => `${p}+${randomUUID()}@exemple.test`;
@@ -147,5 +150,58 @@ export async function verifierVeille(
  const anomalies = await verifierEnBase(sql, crise.criseId);
  r.verifie("EB-08", "la chaîne reste intacte", anomalies.length === 0);

  // ── Où une source a le droit de pointer ──────────────────────────────────
  //
  // L'adresse d'une source est saisie par l'organisation, et c'est le serveur
  // qui va la chercher : elle vaut donc autant qu'une requête émise depuis
  // l'intérieur du réseau. L'inscription étant en libre-service, quiconque peut
  // créer un compte y a accès. Ces garanties sont éprouvées avec la garde en
  // position de production, que la suite lève par ailleurs pour ses serveurs
  // d'essai locaux.
  r.titre("Destinations d'une source de veille");
  autoriserReseauLocal(false);
  try {
    for (const [quoi, adresseInterdite] of [
      ["le point de métadonnées d'un hébergeur", "http://169.254.169.254/latest/meta-data/"],
      ["la boucle locale du serveur", "http://127.0.0.1:9000/"],
      ["un nom qui résout vers la boucle locale", "https://localhost.localdomain/flux"],
      ["une adresse privée", "https://10.1.2.3/flux"],
      ["une adresse privée en IPv6", "https://[fd00::1]/flux"],
      ["une adresse IPv4 encapsulée en IPv6", "https://[::ffff:127.0.0.1]/flux"],
    ] as const) {
      await r.refuse("EF-1301", `une source ne peut pas viser ${quoi}`,
        () => appelSortant(adresseInterdite));
    }
    await r.refuse("EF-1301", "ni transporter un jeton en clair",
      () => appelSortant("http://presse.exemple.test/flux"));

    // Le contrôle ne doit pas être si large qu'il écarte des sources légitimes :
    // les bornes des plages privées sont vérifiées des deux côtés.
    const limites = ["https://172.15.0.1/f", "https://172.32.0.1/f",
      "https://100.63.0.1/f", "https://100.128.0.1/f", "https://223.255.255.255/f"];
    const refusees: string[] = [];
    for (const a of limites) {
      try { await verifierDestination(new URL(a)); } catch { refusees.push(a); }
    }
    r.verifie("EF-1301", "une adresse publique voisine d'une plage privée reste admise",
      refusees.length === 0, refusees.join(", "));
  } finally {
    autoriserReseauLocal(true);
  }

  // Un flux sans fin est une panne de mémoire à distance.
  const sansFin = createServer((_req, rep) => {
    rep.writeHead(200, { "content-type": "text/plain" });
    const bloc = Buffer.alloc(64 * 1024, 0x61);
    const pousser = () => { if (!rep.writableEnded && rep.write(bloc)) setImmediate(pousser); };
    rep.on("drain", pousser);
    pousser();
  });
  await new Promise<void>((ok) => sansFin.listen(0, "127.0.0.1", ok));
  const portSansFin = (sansFin.address() as AddressInfo).port;
  await r.refuse("EF-1301", "une source qui ne s'arrête jamais est écartée",
    () => appelSortant(`http://127.0.0.1:${portSansFin}/`, { maxOctets: 256 * 1024 }));
  sansFin.close();

  presse.close();
}