209 lines
7.9 KiB
TypeScript
209 lines
7.9 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
buildCspDirectives,
|
|
buildCspHeader,
|
|
buildServeJson,
|
|
injectCspMetaIntoHtml,
|
|
serializeCsp,
|
|
toOrigin
|
|
} from "./vite-plugin-csp";
|
|
|
|
describe("toOrigin", () => {
|
|
it("renvoie null pour une entrée vide ou non string", () => {
|
|
expect(toOrigin(null)).toBeNull();
|
|
expect(toOrigin(undefined)).toBeNull();
|
|
expect(toOrigin("")).toBeNull();
|
|
expect(toOrigin(" ")).toBeNull();
|
|
});
|
|
|
|
it("extrait le protocole + host (+ port)", () => {
|
|
expect(toOrigin("https://analytics.example.com/")).toBe("https://analytics.example.com");
|
|
expect(toOrigin("https://analytics.example.com")).toBe("https://analytics.example.com");
|
|
expect(toOrigin("https://analytics.example.com:8443/foo/bar?x=1")).toBe(
|
|
"https://analytics.example.com:8443"
|
|
);
|
|
});
|
|
|
|
it("refuse les protocoles non http(s)", () => {
|
|
expect(toOrigin("file:///etc/passwd")).toBeNull();
|
|
expect(toOrigin("javascript:alert(1)")).toBeNull();
|
|
expect(toOrigin("data:text/plain,foo")).toBeNull();
|
|
});
|
|
|
|
it("renvoie null pour une URL invalide", () => {
|
|
expect(toOrigin("not a url")).toBeNull();
|
|
expect(toOrigin("http://")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("serializeCsp", () => {
|
|
it("joint les directives par `; ` et quote chaque source", () => {
|
|
const out = serializeCsp([
|
|
{ name: "default-src", sources: ["self"] },
|
|
{ name: "script-src", sources: ["self", "https://x.test"] }
|
|
]);
|
|
expect(out).toBe("default-src 'self'; script-src 'self' 'https://x.test'");
|
|
});
|
|
|
|
it("préserve les tokens spéciaux (unsafe-inline, etc.)", () => {
|
|
const out = serializeCsp([{ name: "script-src", sources: ["self", "unsafe-inline", "unsafe-eval"] }]);
|
|
expect(out).toBe("script-src 'self' 'unsafe-inline' 'unsafe-eval'");
|
|
});
|
|
});
|
|
|
|
describe("buildCspDirectives", () => {
|
|
it("en prod sans Matomo : aucune origine externe, pas de unsafe-eval, frame-ancestors none", () => {
|
|
const dirs = buildCspDirectives("production", null);
|
|
const find = (name: string) => dirs.find((d) => d.name === name);
|
|
expect(find("default-src")?.sources).toEqual(["self"]);
|
|
expect(find("script-src")?.sources).toEqual(["self"]);
|
|
// `style-src` inclut `unsafe-inline` même en prod (styles inline Solid).
|
|
expect(find("style-src")?.sources).toEqual(["self", "unsafe-inline"]);
|
|
expect(find("img-src")?.sources).toEqual(["self"]);
|
|
expect(find("font-src")?.sources).toEqual(["self"]);
|
|
expect(find("connect-src")?.sources).toEqual(["self"]);
|
|
expect(find("media-src")?.sources).toEqual(["self"]);
|
|
expect(find("object-src")?.sources).toEqual(["none"]);
|
|
expect(find("base-uri")?.sources).toEqual(["self"]);
|
|
expect(find("form-action")?.sources).toEqual(["self"]);
|
|
expect(find("frame-ancestors")?.sources).toEqual(["none"]);
|
|
expect(find("frame-src")?.sources).toEqual(["none"]);
|
|
});
|
|
|
|
it("en prod avec Matomo : autorise l'origine sur script/connect/img", () => {
|
|
const dirs = buildCspDirectives("production", "https://analytics.example.com");
|
|
expect(dirs.find((d) => d.name === "script-src")?.sources).toEqual([
|
|
"self",
|
|
"https://analytics.example.com"
|
|
]);
|
|
expect(dirs.find((d) => d.name === "connect-src")?.sources).toEqual([
|
|
"self",
|
|
"https://analytics.example.com"
|
|
]);
|
|
expect(dirs.find((d) => d.name === "img-src")?.sources).toEqual([
|
|
"self",
|
|
"https://analytics.example.com"
|
|
]);
|
|
// Pas de Matomo sur font-src / default-src.
|
|
expect(dirs.find((d) => d.name === "font-src")?.sources).toEqual(["self"]);
|
|
});
|
|
|
|
it("en dev : autorise unsafe-eval (HMR), ws:/wss:, et unsafe-inline sur style-src", () => {
|
|
const dirs = buildCspDirectives("development", null);
|
|
expect(dirs.find((d) => d.name === "script-src")?.sources).toEqual([
|
|
"self",
|
|
"unsafe-inline",
|
|
"unsafe-eval"
|
|
]);
|
|
expect(dirs.find((d) => d.name === "style-src")?.sources).toEqual(["self", "unsafe-inline"]);
|
|
expect(dirs.find((d) => d.name === "connect-src")?.sources).toEqual(["self", "ws:", "wss:"]);
|
|
// frame-ancestors reste 'none' en dev aussi.
|
|
expect(dirs.find((d) => d.name === "frame-ancestors")?.sources).toEqual(["none"]);
|
|
});
|
|
|
|
it("en dev avec Matomo : cumule unsafe-* et l'origine Matomo", () => {
|
|
const dirs = buildCspDirectives("development", "https://analytics.example.com");
|
|
expect(dirs.find((d) => d.name === "script-src")?.sources).toEqual([
|
|
"self",
|
|
"unsafe-inline",
|
|
"unsafe-eval",
|
|
"https://analytics.example.com"
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("buildCspHeader", () => {
|
|
it("inclut toutes les directives attendues en prod", () => {
|
|
const csp = buildCspHeader("production", null);
|
|
expect(csp).toContain("default-src 'self'");
|
|
expect(csp).toContain("frame-ancestors 'none'");
|
|
expect(csp).toContain("object-src 'none'");
|
|
// `unsafe-inline` reste sur `style-src` (styles inline Solid) — seul `unsafe-eval` est
|
|
// strictement exclu en prod (HMR Vite ne sert qu'en dev).
|
|
expect(csp).not.toContain("unsafe-eval");
|
|
});
|
|
});
|
|
|
|
describe("injectCspMetaIntoHtml", () => {
|
|
const sample = `<!doctype html>
|
|
<html lang="fr">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<title>Test</title>
|
|
</head>
|
|
<body></body>
|
|
</html>
|
|
`;
|
|
|
|
it("insère la balise CSP juste après le charset", () => {
|
|
const out = injectCspMetaIntoHtml(sample, "default-src 'self'");
|
|
expect(out).toMatch(
|
|
/<meta\s+charset="UTF-8"\s*\/>\s*<meta\s+http-equiv="Content-Security-Policy"\s+content="default-src 'self'">/
|
|
);
|
|
});
|
|
|
|
it("insère après <head> si pas de charset", () => {
|
|
const html = "<html><head><title>X</title></head><body/></html>";
|
|
const out = injectCspMetaIntoHtml(html, "default-src 'self'");
|
|
expect(out).toMatch(/<head>\s*<meta\s+http-equiv="Content-Security-Policy"/);
|
|
});
|
|
|
|
it("quote correctement la valeur CSP", () => {
|
|
const out = injectCspMetaIntoHtml(sample, "default-src 'self'; script-src 'self'");
|
|
expect(out).toContain(`content="default-src 'self'; script-src 'self'"`);
|
|
});
|
|
});
|
|
|
|
describe("buildServeJson", () => {
|
|
it("déclare une règle HTML avec CSP + en-têtes statiques, et une règle catch-all sans CSP", () => {
|
|
const sj = buildServeJson(null);
|
|
expect(sj.headers).toHaveLength(2);
|
|
|
|
const htmlRule = sj.headers.find((r) => r.source === "**/*.html");
|
|
expect(htmlRule).toBeDefined();
|
|
const htmlKeys = htmlRule!.headers.map((h) => h.key);
|
|
expect(htmlKeys).toEqual(
|
|
expect.arrayContaining([
|
|
"Content-Security-Policy",
|
|
"X-Content-Type-Options",
|
|
"Referrer-Policy",
|
|
"X-Frame-Options",
|
|
"Permissions-Policy"
|
|
])
|
|
);
|
|
const cspEntry = htmlRule!.headers.find((h) => h.key === "Content-Security-Policy");
|
|
expect(cspEntry?.value).not.toContain("unsafe-eval");
|
|
expect(cspEntry?.value).toContain("frame-ancestors 'none'");
|
|
|
|
const allRule = sj.headers.find((r) => r.source === "**/*");
|
|
expect(allRule).toBeDefined();
|
|
const allKeys = allRule!.headers.map((h) => h.key);
|
|
expect(allKeys).not.toContain("Content-Security-Policy");
|
|
expect(allKeys).toEqual(
|
|
expect.arrayContaining([
|
|
"X-Content-Type-Options",
|
|
"Referrer-Policy",
|
|
"X-Frame-Options",
|
|
"Permissions-Policy"
|
|
])
|
|
);
|
|
});
|
|
|
|
it("la CSP s'adapte à l'origine Matomo fournie", () => {
|
|
const sj = buildServeJson("https://analytics.example.com");
|
|
const cspEntry = sj.headers
|
|
.find((r) => r.source === "**/*.html")!
|
|
.headers.find((h) => h.key === "Content-Security-Policy");
|
|
expect(cspEntry?.value).toContain("https://analytics.example.com");
|
|
});
|
|
|
|
it("la Permissions-Policy désactive les capacités sensibles", () => {
|
|
const sj = buildServeJson(null);
|
|
const allHeaders = sj.headers.flatMap((r) => r.headers);
|
|
const pp = allHeaders.find((h) => h.key === "Permissions-Policy");
|
|
expect(pp?.value).toContain("camera=()");
|
|
expect(pp?.value).toContain("microphone=()");
|
|
expect(pp?.value).toContain("geolocation=()");
|
|
});
|
|
});
|