import fs from "node:fs/promises"; import path from "node:path"; import type { IncomingMessage, ServerResponse } from "node:http"; import type { Connect } from "vite"; import type { Plugin, ResolvedConfig } from "vite"; /** Mode effectif de la construction Vite (sert à choisir la politique CSP). */ export type CspMode = "development" | "production"; /** Options publiques du plugin. */ export type CspPluginOptions = Readonly<{ /** * Origine Matomo à autoriser (sans slash final). `null` = aucune origine externe * (Matomo non configuré via `VITE_MATOMO_URL`). */ readonly matomoOrigin: string | null; }>; /** Une règle CSP (directive + sources). */ export type CspDirective = Readonly<{ readonly name: string; readonly sources: readonly string[]; }>; /** Helpers exportés (testés unitairement) : construction de la chaîne CSP. */ /** * Source `'self'` et tokens spéciaux. Les sources `none`, `self`, `unsafe-inline`, * `unsafe-eval`, `strict-dynamic` etc. doivent être quotés **uniquement** quand * ils sont entre quotes, ce qui est notre cas. */ const sourceNeedsQuoting = true; const quote = (s: string): string => (sourceNeedsQuoting ? `'${s}'` : s); /** Joint deux sources CSP avec un espace. */ const joinSources = (sources: readonly string[]): string => sources.map(quote).join(" "); /** * Sérialise une liste de directives CSP en une chaîne `Content-Security-Policy`. * Chaque directive devient `name source1 source2 …` séparée par `; `. */ export const serializeCsp = (directives: readonly CspDirective[]): string => directives.map((d) => `${d.name} ${joinSources(d.sources)}`).join("; "); /** Enlève un slash final d'une origine. */ const stripTrailingSlash = (s: string): string => s.replace(/\/+$/, ""); /** Normalise une URL en origine (`https://host[:port]`). Renvoie `null` si invalide. */ export const toOrigin = (raw: string | null | undefined): string | null => { if (typeof raw !== "string") return null; const trimmed = raw.trim(); if (!trimmed) return null; try { const u = new URL(trimmed); if (u.protocol !== "http:" && u.protocol !== "https:") return null; return stripTrailingSlash(`${u.protocol}//${u.host}`); } catch { return null; } }; /** * Construit la liste de directives CSP pour le mode donné. * Sources autorisées : * - `'self'` partout * - Origine Matomo (si fournie) sur `script-src`, `connect-src`, `img-src` * - Dev uniquement : `'unsafe-inline'`, `'unsafe-eval'` (script & style), `ws:` * / `wss:` (connect-src pour HMR Vite) */ export const buildCspDirectives = ( mode: CspMode, matomoOrigin: string | null ): readonly CspDirective[] => { const matomo = matomoOrigin ? [matomoOrigin] : []; const scriptSources: string[] = ["self"]; const styleSources: string[] = ["self"]; const connectSources: string[] = ["self"]; if (mode === "development") { scriptSources.push("unsafe-inline", "unsafe-eval"); connectSources.push("ws:", "wss:"); } // `'unsafe-inline'` sur `style-src` est **toujours** requis : Solid pose des styles // inline (`style={{...}}`) sur des nœuds (cf. `apps/web/src/ui/SpriteTile.tsx`). // L'attribut `style="..."` est restreint par `style-src-attr` (fallback `style-src`). styleSources.push("unsafe-inline"); if (matomo.length) { scriptSources.push(...matomo); connectSources.push(...matomo); } return [ { name: "default-src", sources: ["self"] }, { name: "script-src", sources: scriptSources }, { name: "style-src", sources: styleSources }, { name: "img-src", sources: ["self", ...matomo] }, { name: "font-src", sources: ["self"] }, { name: "connect-src", sources: connectSources }, { name: "media-src", sources: ["self"] }, { name: "object-src", sources: ["none"] }, { name: "base-uri", sources: ["self"] }, { name: "form-action", sources: ["self"] }, { name: "frame-ancestors", sources: ["none"] }, { name: "frame-src", sources: ["none"] } ]; }; /** Construit la chaîne `Content-Security-Policy` pour le mode donné. */ export const buildCspHeader = (mode: CspMode, matomoOrigin: string | null): string => serializeCsp(buildCspDirectives(mode, matomoOrigin)); /** Format d'en-têtes pour `serve.json` (cf. https://github.com/vercel/serve#configuration). */ export type ServeJsonHeader = Readonly<{ key: string; value: string }>; export type ServeJsonRule = Readonly<{ source: string; headers: readonly ServeJsonHeader[] }>; export type ServeJson = Readonly<{ headers: readonly ServeJsonRule[] }>; /** En-têtes statiques (indépendants de la CSP). */ const STATIC_SECURITY_HEADERS: ReadonlyArray<{ key: string; value: string }> = [ { key: "X-Content-Type-Options", value: "nosniff" }, { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, { key: "X-Frame-Options", value: "DENY" }, { key: "Permissions-Policy", // Aucune fonctionnalité avancée nécessaire sur la V1 (app statique, pas de WebRTC, pas de paiement). value: "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()" } ]; /** * Construit l'objet `serve.json` qui sera écrit dans `dist/` (lu par `serve` au runtime). * Les en-têtes sont scopés sur le glob HTML pour la CSP, et sur un glob catch-all pour le * reste (ne pas « fuir » la CSP sur des assets qui n'en ont pas besoin ; l'en-tête reste * valide sur tout contenu textuel, mais limiter la surface évite les bugs). */ export const buildServeJson = (matomoOrigin: string | null): ServeJson => ({ headers: [ { source: "**/*.html", headers: [ { key: "Content-Security-Policy", value: buildCspHeader("production", matomoOrigin) }, ...STATIC_SECURITY_HEADERS ] }, { source: "**/*", headers: STATIC_SECURITY_HEADERS } ] }); /** Injecte la balise `` dans un HTML. */ export const injectCspMetaIntoHtml = (html: string, csp: string): string => { const metaTag = ``; // Stratégie : insérer juste après la première balise d'en-tête de charset. // Si absente, on insère juste après ``. const charsetMatch = //i.exec(html); if (charsetMatch) { const insertAt = charsetMatch.index + charsetMatch[0].length; return `${html.slice(0, insertAt)}\n ${metaTag}${html.slice(insertAt)}`; } const headMatch = /]*>/i.exec(html); if (headMatch) { const insertAt = headMatch.index + headMatch[0].length; return `${html.slice(0, insertAt)}\n ${metaTag}${html.slice(insertAt)}`; } return ` ${metaTag}\n${html}`; }; /** Vrai si la requête vise un document HTML. */ const isHtmlRequest = (req: IncomingMessage): boolean => { const accept = String(req.headers.accept ?? ""); // `fetch` côté JS : pas un document ; `curl` sans Accept : on autorise pour le smoke test. return accept.includes("text/html") || accept.includes("*/*") || accept === ""; }; /** Vrai si la requête vise une page recette (HTML pré-rendu `/r/...`). */ const isRecipeSharePath = (url: string | undefined): boolean => { if (!url) return false; try { const pathname = new URL(url, "http://localhost").pathname.replace(/\/$/, ""); return /^\/r\/[^/]+\/[^/]+$/.test(pathname); } catch { return false; } }; /** Middleware Vite : pose les en-têtes de sécurité sur les réponses HTML du dev server. */ const makeSecurityHeadersMiddleware = ( cspValue: string ): Connect.NextHandleFunction => { return function securityHeadersMiddleware( req: IncomingMessage, res: ServerResponse, next: Connect.NextFunction ): void { if (!isHtmlRequest(req) && !isRecipeSharePath(req.url)) { next(); return; } res.setHeader("Content-Security-Policy", cspValue); for (const h of STATIC_SECURITY_HEADERS) { res.setHeader(h.key, h.value); } next(); }; }; /** * Plugin Vite qui injecte la CSP (en meta) dans le HTML de l'app * et émet les en-têtes de sécurité en dev + un `dist/serve.json` en prod. */ export function cspPlugin(options: CspPluginOptions): Plugin { let config: ResolvedConfig; const matomoOrigin = options.matomoOrigin; return { name: "ben-to-csp", enforce: "pre", configResolved(resolved) { config = resolved; }, transformIndexHtml(html) { const mode: CspMode = config.command === "build" ? "production" : "development"; const csp = buildCspHeader(mode, matomoOrigin); return injectCspMetaIntoHtml(html, csp); }, configureServer(server) { const csp = buildCspHeader("development", matomoOrigin); // Place ce middleware **avant** celui de `recipeSeoPlugin` (enregistré après) pour // que `res.setHeader` soit effectif avant que `recipeSeoPlugin` n'écrive la réponse. server.middlewares.use(makeSecurityHeadersMiddleware(csp)); }, async closeBundle() { if (config.command !== "build") return; const outDir = path.resolve(config.root, config.build.outDir); const serveJsonPath = path.join(outDir, "serve.json"); const payload = buildServeJson(matomoOrigin); await fs.writeFile(serveJsonPath, JSON.stringify(payload, null, 2) + "\n", "utf8"); config.logger.info( `csp: dist/serve.json écrit (${payload.headers.length} règle(s), matomo=${matomoOrigin ?? "désactivé" })` ); } }; }