82 lines
4.9 KiB
JavaScript
82 lines
4.9 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import crypto from "node:crypto";
|
|
import fs from "node:fs";
|
|
import http from "node:http";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { createDeploymentHttpServer, listenDeploymentHttpServer, loadDeploymentContract } from "./deployment-http-server.mjs";
|
|
|
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
|
const reportPath = path.join(root, "tests/golden/M13-05A/csp-report.json");
|
|
const manifestPath = path.join(root, "tests/golden/M13-05A/manifest.json");
|
|
const hashFile = async (file) => crypto.createHash("sha256").update(await fs.promises.readFile(file)).digest("hex");
|
|
const contract = loadDeploymentContract();
|
|
const policy = contract.responseHeaders.allResponses["Content-Security-Policy"];
|
|
assert.equal(typeof policy, "string");
|
|
const directives = new Map(policy.split(";").map((directive) => {
|
|
const tokens = directive.trim().split(/\s+/u);
|
|
return [tokens.shift(), tokens];
|
|
}));
|
|
const sourceTokens = [...directives.values()].flat();
|
|
for (const forbidden of ["'unsafe-inline'", "'unsafe-eval'", "data:", "*", "blob:"]) {
|
|
assert.equal(sourceTokens.includes(forbidden), false, `CSP contains forbidden token ${forbidden}`);
|
|
}
|
|
for (const required of ["default-src 'self'", "script-src 'self'", "worker-src 'self'", "connect-src 'self'", "object-src 'none'", "base-uri 'none'", "frame-ancestors 'none'"]) assert.ok(policy.includes(required), `CSP is missing ${required}`);
|
|
const index = fs.readFileSync(path.join(root, "web/app/index.html"), "utf8");
|
|
assert.doesNotMatch(index, /<script\b[^>]*(?:\b(?:src\s*=\s*["']data:|type\s*=\s*["']text\/javascript["']))/iu);
|
|
assert.doesNotMatch(index, /\bon[a-z]+\s*=/iu);
|
|
const productionSources = [];
|
|
function walk(directory) {
|
|
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
const file = path.join(directory, entry.name);
|
|
if (entry.isDirectory()) walk(file);
|
|
else if (/\.(?:ts|tsx|js|mjs|html)$/.test(entry.name)) productionSources.push(file);
|
|
}
|
|
}
|
|
walk(path.join(root, "web/app/src"));
|
|
for (const file of productionSources) {
|
|
const source = fs.readFileSync(file, "utf8");
|
|
assert.doesNotMatch(source, /\beval\s*\(|\bnew\s+Function\s*\(/u, `dynamic code in ${file}`);
|
|
assert.doesNotMatch(source, /(?:worker|script|src)\s*[:=]\s*["'`]data:/iu, `data script/worker in ${file}`);
|
|
}
|
|
const distRoot = path.join(root, "web/dist");
|
|
let buildChecked = false;
|
|
if (fs.existsSync(distRoot)) {
|
|
const builtFiles = [];
|
|
const walkBuilt = (directory) => {
|
|
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
const file = path.join(directory, entry.name);
|
|
if (entry.isDirectory()) walkBuilt(file);
|
|
else if (/\.(?:js|html)$/.test(entry.name)) builtFiles.push(file);
|
|
}
|
|
};
|
|
walkBuilt(distRoot);
|
|
for (const file of builtFiles) {
|
|
const source = fs.readFileSync(file, "utf8");
|
|
assert.doesNotMatch(source, /\beval\s*\(|\bnew\s+Function\s*\(/u, `dynamic code in built file ${file}`);
|
|
assert.doesNotMatch(source, /<script[^>]+\bsrc\s*=\s*["']data:/iu, `data script in built file ${file}`);
|
|
}
|
|
buildChecked = true;
|
|
}
|
|
const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "m13-05a-csp-"));
|
|
fs.writeFileSync(path.join(fixtureRoot, "index.html"), index);
|
|
const server = createDeploymentHttpServer({ root: fixtureRoot, contract });
|
|
const origin = await listenDeploymentHttpServer(server);
|
|
try {
|
|
for (const pathname of ["/", "/missing", "/index.html"]) {
|
|
const response = await fetch(`${origin}${pathname}`);
|
|
assert.equal(response.headers.get("content-security-policy"), policy);
|
|
}
|
|
const report = { schemaVersion: 1, task: "M13-05A", operation: "CSP_POLICY", policy, sourceChecks: { inlineScript: "DENY", inlineHandler: "DENY", eval: "DENY", dataScript: "DENY", undeclaredConnect: "DENY" }, responseCount: 3, buildChecked, execution: "DISABLED", nextTask: "M13-05B" };
|
|
if (process.env.UPDATE_M13_05A_REPORT === "1") { await fs.promises.mkdir(path.dirname(reportPath), { recursive: true }); await fs.promises.writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`); }
|
|
assert.deepEqual(JSON.parse(await fs.promises.readFile(reportPath, "utf8")), report);
|
|
const manifest = JSON.parse(await fs.promises.readFile(manifestPath, "utf8"));
|
|
assert.deepEqual({ schemaVersion: manifest.schemaVersion, task: manifest.task, parentTask: manifest.parentTask, nextTask: manifest.nextTask }, { schemaVersion: 1, task: "M13-05A", parentTask: "M13-04J", nextTask: "M13-05B" });
|
|
for (const artifact of Object.values(manifest.artifacts)) assert.equal(await hashFile(path.join(root, artifact.path)), artifact.sha256, `artifact hash mismatch ${artifact.path}`);
|
|
process.stdout.write("csp-policy-ok inline=DENY eval=DENY dataScript=DENY undeclaredConnect=DENY responses=3 execution=DISABLED next=M13-05B\n");
|
|
} finally {
|
|
await new Promise((resolve) => server.close(resolve));
|
|
fs.rmSync(fixtureRoot, { recursive: true, force: true });
|
|
}
|