first commit
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
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=()");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user