250 lines
9.3 KiB
TypeScript
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é"
|
|
})`
|
|
);
|
|
}
|
|
};
|
|
}
|