Files
ben-to/apps/web/vite-plugin-csp.ts
2026-07-21 16:50:58 +02:00

250 lines
9.3 KiB
TypeScript

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 `<meta http-equiv="Content-Security-Policy">` dans un HTML. */
export const injectCspMetaIntoHtml = (html: string, csp: string): string => {
const metaTag = `<meta http-equiv="Content-Security-Policy" content="${csp}">`;
// 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 `<head>`.
const charsetMatch = /<meta\s+charset="[^"]*"\s*\/?>/i.exec(html);
if (charsetMatch) {
const insertAt = charsetMatch.index + charsetMatch[0].length;
return `${html.slice(0, insertAt)}\n ${metaTag}${html.slice(insertAt)}`;
}
const headMatch = /<head[^>]*>/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é"
})`
);
}
};
}