From 45a461a1a42525c7e3da4aa8f265ade8dafece99 Mon Sep 17 00:00:00 2001 From: Sebastian Fuchs Date: Tue, 6 Oct 2026 13:58:19 +0200 Subject: [PATCH] feat: Add `cspReport` utility to generate CSP reports and recommendations --- .changeset/csp-report.md | 7 + ...directives_if_both_variants_are_defined.md | 22 ++ ...missing_directives_using_their_fallback.md | 20 ++ .../recommends_adding_a_nonce_source.md | 20 ++ .../recommends_adding_missing_sources.md | 26 ++ ...ends_all_directives_for_an_empty_header.md | 21 ++ ...commends_fixing_weak_directive_variants.md | 31 ++ .../recommends_nothing_for_a_strict_policy.md | 20 ++ ...s_nothing_for_strict_directive_variants.md | 24 ++ .../recommends_removing_extra_sources.md | 23 ++ ...rectives_if_only_one_variant_is_defined.md | 21 ++ docs/.vitepress/config.ts | 4 + docs/src/security/csp-report.md | 52 +++ package.json | 1 + pnpm-lock.yaml | 3 + src/index.ts | 2 + src/security/csp-report.ts | 296 ++++++++++++++++++ tests/csp-report.spec.ts | 100 ++++++ 18 files changed, 693 insertions(+) create mode 100644 .changeset/csp-report.md create mode 100644 data/test/validation/csp-report/does_not_require_base_directives_if_both_variants_are_defined.md create mode 100644 data/test/validation/csp-report/evaluates_missing_directives_using_their_fallback.md create mode 100644 data/test/validation/csp-report/recommends_adding_a_nonce_source.md create mode 100644 data/test/validation/csp-report/recommends_adding_missing_sources.md create mode 100644 data/test/validation/csp-report/recommends_all_directives_for_an_empty_header.md create mode 100644 data/test/validation/csp-report/recommends_fixing_weak_directive_variants.md create mode 100644 data/test/validation/csp-report/recommends_nothing_for_a_strict_policy.md create mode 100644 data/test/validation/csp-report/recommends_nothing_for_strict_directive_variants.md create mode 100644 data/test/validation/csp-report/recommends_removing_extra_sources.md create mode 100644 data/test/validation/csp-report/requires_base_directives_if_only_one_variant_is_defined.md create mode 100644 docs/src/security/csp-report.md create mode 100644 src/security/csp-report.ts create mode 100644 tests/csp-report.spec.ts diff --git a/.changeset/csp-report.md b/.changeset/csp-report.md new file mode 100644 index 0000000..2d996a8 --- /dev/null +++ b/.changeset/csp-report.md @@ -0,0 +1,7 @@ +--- +"@cronn/playwright-utils": minor +--- + +Add `cspReport` to evaluate a `Content-Security-Policy` header against recommended directives and render the result as a Markdown report for file snapshots. + +Add `getCspHeader` to extract the `Content-Security-Policy` header from a page response, falling back to an empty string. diff --git a/data/test/validation/csp-report/does_not_require_base_directives_if_both_variants_are_defined.md b/data/test/validation/csp-report/does_not_require_base_directives_if_both_variants_are_defined.md new file mode 100644 index 0000000..b9c60c1 --- /dev/null +++ b/data/test/validation/csp-report/does_not_require_base_directives_if_both_variants_are_defined.md @@ -0,0 +1,22 @@ +# CSP Report + +## Detected Directives + +| Directive | Value | +| --------------------------- | ------------------- | +| `upgrade-insecure-requests` | _none_ | +| `trusted-types` | `angular` | +| `require-trusted-types-for` | `'script'` | +| `default-src` | `'self'` | +| `script-src-elem` | `'nonce-[NONCE_0]'` | +| `script-src-attr` | `'none'` | +| `style-src-elem` | `'nonce-[NONCE_1]'` | +| `style-src-attr` | `'none'` | +| `object-src` | `'none'` | +| `base-uri` | `'none'` | +| `form-action` | `'self'` | +| `frame-ancestors` | `'none'` | + +## Recommendations + +None diff --git a/data/test/validation/csp-report/evaluates_missing_directives_using_their_fallback.md b/data/test/validation/csp-report/evaluates_missing_directives_using_their_fallback.md new file mode 100644 index 0000000..7927b56 --- /dev/null +++ b/data/test/validation/csp-report/evaluates_missing_directives_using_their_fallback.md @@ -0,0 +1,20 @@ +# CSP Report + +## Detected Directives + +| Directive | Value | +| --------------------------- | ------------------- | +| `upgrade-insecure-requests` | _none_ | +| `trusted-types` | `angular` | +| `require-trusted-types-for` | `'script'` | +| `default-src` | `'self'` | +| `script-src` | `'nonce-[NONCE_0]'` | +| `object-src` | `'none'` | +| `base-uri` | `'none'` | +| `form-action` | `'self'` | +| `frame-ancestors` | `'none'` | + +## Recommendations + +- `style-src`: Add a nonce source +- `style-src`: Remove `'self'` (inherited from `default-src`) diff --git a/data/test/validation/csp-report/recommends_adding_a_nonce_source.md b/data/test/validation/csp-report/recommends_adding_a_nonce_source.md new file mode 100644 index 0000000..a61bbef --- /dev/null +++ b/data/test/validation/csp-report/recommends_adding_a_nonce_source.md @@ -0,0 +1,20 @@ +# CSP Report + +## Detected Directives + +| Directive | Value | +| --------------------------- | ------------------- | +| `upgrade-insecure-requests` | _none_ | +| `trusted-types` | `angular` | +| `require-trusted-types-for` | `'script'` | +| `default-src` | `'self'` | +| `script-src` | `'strict-dynamic'` | +| `style-src` | `'nonce-[NONCE_0]'` | +| `object-src` | `'none'` | +| `base-uri` | `'none'` | +| `form-action` | `'self'` | +| `frame-ancestors` | `'none'` | + +## Recommendations + +- `script-src`: Add a nonce source diff --git a/data/test/validation/csp-report/recommends_adding_missing_sources.md b/data/test/validation/csp-report/recommends_adding_missing_sources.md new file mode 100644 index 0000000..dac8945 --- /dev/null +++ b/data/test/validation/csp-report/recommends_adding_missing_sources.md @@ -0,0 +1,26 @@ +# CSP Report + +## Detected Directives + +| Directive | Value | +| --------------------------- | ---------------------------- | +| `upgrade-insecure-requests` | _none_ | +| `trusted-types` | `angular` | +| `require-trusted-types-for` | `'script'` | +| `default-src` | `'self'` | +| `script-src` | `'nonce-[NONCE_0]'` | +| `style-src` | `'nonce-[NONCE_1]'` | +| `img-src` | `https://images.example.com` | +| `object-src` | `https://cdn.example.com` | +| `base-uri` | `'none'` | +| `form-action` | `https://api.example.com` | +| `frame-ancestors` | `'none'` | + +## Recommendations + +- `img-src`: Add `'self' data:` +- `img-src`: Remove `https://images.example.com` +- `object-src`: Add `'none'` +- `object-src`: Remove `https://cdn.example.com` +- `form-action`: Add `'self'` +- `form-action`: Remove `https://api.example.com` diff --git a/data/test/validation/csp-report/recommends_all_directives_for_an_empty_header.md b/data/test/validation/csp-report/recommends_all_directives_for_an_empty_header.md new file mode 100644 index 0000000..3f2fe74 --- /dev/null +++ b/data/test/validation/csp-report/recommends_all_directives_for_an_empty_header.md @@ -0,0 +1,21 @@ +# CSP Report + +## Detected Directives + +None + +## Recommendations + +- `upgrade-insecure-requests`: Missing, add `upgrade-insecure-requests` +- `trusted-types`: Missing, add `trusted-types` +- `require-trusted-types-for`: Missing, add `require-trusted-types-for 'script'` +- `default-src`: Missing, add `default-src 'self'` +- `script-src`: Missing, add `script-src 'nonce-[RANDOM]'` +- `style-src`: Missing, add `style-src 'nonce-[RANDOM]'` +- `img-src`: Missing, add `img-src 'self' data:` +- `font-src`: Missing, add `font-src 'self'` +- `connect-src`: Missing, add `connect-src 'self'` +- `object-src`: Missing, add `object-src 'none'` +- `base-uri`: Missing, add `base-uri 'self'` +- `form-action`: Missing, add `form-action 'self'` +- `frame-ancestors`: Missing, add `frame-ancestors 'none'` diff --git a/data/test/validation/csp-report/recommends_fixing_weak_directive_variants.md b/data/test/validation/csp-report/recommends_fixing_weak_directive_variants.md new file mode 100644 index 0000000..2aa28fd --- /dev/null +++ b/data/test/validation/csp-report/recommends_fixing_weak_directive_variants.md @@ -0,0 +1,31 @@ +# CSP Report + +## Detected Directives + +| Directive | Value | +| --------------------------- | ----------------------------------------- | +| `upgrade-insecure-requests` | _none_ | +| `trusted-types` | `angular` | +| `require-trusted-types-for` | `'script'` | +| `default-src` | `'self'` | +| `script-src` | `'nonce-[NONCE_0]'` | +| `script-src-elem` | `'unsafe-inline' https://cdn.example.com` | +| `script-src-attr` | `'unsafe-inline'` | +| `style-src` | `'nonce-[NONCE_1]'` | +| `style-src-elem` | `'nonce-[NONCE_1]' 'unsafe-inline'` | +| `style-src-attr` | `'unsafe-hashes'` | +| `object-src` | `'none'` | +| `base-uri` | `'none'` | +| `form-action` | `'self'` | +| `frame-ancestors` | `'none'` | + +## Recommendations + +- `script-src-elem`: Add a nonce source +- `script-src-elem`: Remove `'unsafe-inline'` +- `script-src-elem`: Remove `https://cdn.example.com` +- `script-src-attr`: Add `'none'` +- `script-src-attr`: Remove `'unsafe-inline'` +- `style-src-elem`: Remove `'unsafe-inline'` +- `style-src-attr`: Add `'none'` +- `style-src-attr`: Remove `'unsafe-hashes'` diff --git a/data/test/validation/csp-report/recommends_nothing_for_a_strict_policy.md b/data/test/validation/csp-report/recommends_nothing_for_a_strict_policy.md new file mode 100644 index 0000000..8dd419c --- /dev/null +++ b/data/test/validation/csp-report/recommends_nothing_for_a_strict_policy.md @@ -0,0 +1,20 @@ +# CSP Report + +## Detected Directives + +| Directive | Value | +| --------------------------- | ------------------- | +| `upgrade-insecure-requests` | _none_ | +| `trusted-types` | `angular` | +| `require-trusted-types-for` | `'script'` | +| `default-src` | `'self'` | +| `script-src` | `'nonce-[NONCE_0]'` | +| `style-src` | `'nonce-[NONCE_1]'` | +| `object-src` | `'none'` | +| `base-uri` | `'none'` | +| `form-action` | `'self'` | +| `frame-ancestors` | `'none'` | + +## Recommendations + +None diff --git a/data/test/validation/csp-report/recommends_nothing_for_strict_directive_variants.md b/data/test/validation/csp-report/recommends_nothing_for_strict_directive_variants.md new file mode 100644 index 0000000..1806a35 --- /dev/null +++ b/data/test/validation/csp-report/recommends_nothing_for_strict_directive_variants.md @@ -0,0 +1,24 @@ +# CSP Report + +## Detected Directives + +| Directive | Value | +| --------------------------- | ------------------- | +| `upgrade-insecure-requests` | _none_ | +| `trusted-types` | `angular` | +| `require-trusted-types-for` | `'script'` | +| `default-src` | `'self'` | +| `script-src` | `'nonce-[NONCE_0]'` | +| `script-src-elem` | `'nonce-[NONCE_0]'` | +| `script-src-attr` | `'none'` | +| `style-src` | `'nonce-[NONCE_1]'` | +| `style-src-elem` | `'nonce-[NONCE_1]'` | +| `style-src-attr` | `'none'` | +| `object-src` | `'none'` | +| `base-uri` | `'none'` | +| `form-action` | `'self'` | +| `frame-ancestors` | `'none'` | + +## Recommendations + +None diff --git a/data/test/validation/csp-report/recommends_removing_extra_sources.md b/data/test/validation/csp-report/recommends_removing_extra_sources.md new file mode 100644 index 0000000..1136630 --- /dev/null +++ b/data/test/validation/csp-report/recommends_removing_extra_sources.md @@ -0,0 +1,23 @@ +# CSP Report + +## Detected Directives + +| Directive | Value | +| --------------------------- | ------------------------------------------------------------ | +| `upgrade-insecure-requests` | _none_ | +| `trusted-types` | `angular` | +| `require-trusted-types-for` | `'script'` | +| `default-src` | `'self'` | +| `script-src` | `'nonce-[NONCE_0]' 'strict-dynamic' https://cdn.example.com` | +| `style-src` | `'nonce-[NONCE_1]' 'unsafe-inline'` | +| `img-src` | `'self' data: https://images.example.com` | +| `object-src` | `'none'` | +| `base-uri` | `'none'` | +| `form-action` | `'self'` | +| `frame-ancestors` | `'none'` | + +## Recommendations + +- `script-src`: Remove `https://cdn.example.com` +- `style-src`: Remove `'unsafe-inline'` +- `img-src`: Remove `https://images.example.com` diff --git a/data/test/validation/csp-report/requires_base_directives_if_only_one_variant_is_defined.md b/data/test/validation/csp-report/requires_base_directives_if_only_one_variant_is_defined.md new file mode 100644 index 0000000..4915e5e --- /dev/null +++ b/data/test/validation/csp-report/requires_base_directives_if_only_one_variant_is_defined.md @@ -0,0 +1,21 @@ +# CSP Report + +## Detected Directives + +| Directive | Value | +| --------------------------- | ------------------- | +| `upgrade-insecure-requests` | _none_ | +| `trusted-types` | `angular` | +| `require-trusted-types-for` | `'script'` | +| `default-src` | `'self'` | +| `script-src-elem` | `'nonce-[NONCE_0]'` | +| `style-src` | `'nonce-[NONCE_1]'` | +| `object-src` | `'none'` | +| `base-uri` | `'none'` | +| `form-action` | `'self'` | +| `frame-ancestors` | `'none'` | + +## Recommendations + +- `script-src`: Add a nonce source +- `script-src`: Remove `'self'` (inherited from `default-src`) diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 4871297..9dc82eb 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -63,6 +63,10 @@ export default defineConfig({ { text: "CSP Violations", link: "/capturing/csp" }, ], }, + { + text: "Security", + items: [{ text: "CSP Report", link: "/security/csp-report" }], + }, { text: "Snapshot Testing", items: [{ text: "Normalizers", link: "/snapshots/normalizers" }], diff --git a/docs/src/security/csp-report.md b/docs/src/security/csp-report.md new file mode 100644 index 0000000..e7517c1 --- /dev/null +++ b/docs/src/security/csp-report.md @@ -0,0 +1,52 @@ +# CSP Report + +A Content Security Policy is only effective if it is strict enough, and a weakened policy usually goes unnoticed because the application keeps working. `cspReport` evaluates a `Content-Security-Policy` header against a set of recommended directives and returns a Markdown report, which can be stored as a file snapshot to detect any change to the policy. + +## Usage + +```ts +import { cspReport, getCspHeader } from "@cronn/playwright-utils"; +import { expect, test } from "@playwright/test"; + +test("has a strict Content Security Policy", async ({ page }) => { + const response = await page.goto("/"); + const cspHeader = await getCspHeader(response); + + expect(cspReport(cspHeader)).toMatchTextFile({ + fileExtension: "md", + }); +}); +``` + +`getCspHeader` extracts the `Content-Security-Policy` header from a response. It accepts the nullable result of `page.goto()` and returns an empty string if the response or the header is missing, which `cspReport` reports as a policy without any directives. + +The report lists all directives of the header, followed by the recommendations for the policy: + +```md +# CSP Report + +## Directives + +| Directive | Value | +| --------------------------- | ------------------- | +| `upgrade-insecure-requests` | _none_ | +| `trusted-types` | `angular` | +| `require-trusted-types-for` | `'script'` | +| `default-src` | `'self'` | +| `script-src` | `'strict-dynamic'` | +| `style-src` | `'nonce-[NONCE_0]'` | +| `object-src` | `'none'` | +| `base-uri` | `'none'` | +| `form-action` | `'self'` | +| `frame-ancestors` | `'none'` | + +## Recommendations + +- `script-src`: Add a nonce source +``` + +Nonces are generated per response, so they are masked as `[NONCE_0]`, `[NONCE_1]`, … to keep the report deterministic. The same nonce used by several directives is masked with the same index. + +A directive is considered compliant if it only contains recommended values or is set to `'none'`, so a stricter policy does not produce a recommendation. Extra sources which are not recommended are reported individually. If a directive contains none of the recommended values, the expected value is reported instead. + +If one of the fetch directives `script-src`, `style-src`, `img-src`, `font-src` or `connect-src` is missing, it is evaluated using `default-src`. Recommendations resulting from the fallback are marked as `(inherited from default-src)`. diff --git a/package.json b/package.json index 6f9f716..918767c 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "version": "pnpm changeset version && pnpm turbo run format:fix" }, "dependencies": { + "markdown-table": "^3.0.4", "package-directory": "^8.2.0" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d784597..b033927 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -177,6 +177,9 @@ importers: .: dependencies: + markdown-table: + specifier: ^3.0.4 + version: 3.0.4 package-directory: specifier: ^8.2.0 version: 8.2.0 diff --git a/src/index.ts b/src/index.ts index ded9955..d214879 100644 --- a/src/index.ts +++ b/src/index.ts @@ -34,6 +34,8 @@ export { type ResponseHandler, } from "./api/route-interceptor/response-handler"; +export { cspReport, getCspHeader } from "./security/csp-report"; + export { resolveFromPackageRoot } from "./file"; export { formFiller, type FormFields } from "./page-object-model/form-filler"; diff --git a/src/security/csp-report.ts b/src/security/csp-report.ts new file mode 100644 index 0000000..620f609 --- /dev/null +++ b/src/security/csp-report.ts @@ -0,0 +1,296 @@ +import type { Response } from "@playwright/test"; +import { markdownTable } from "markdown-table"; + +import { maskedValueWithIndex } from "../normalizers/masked-value"; + +interface CspDirective { + name: string; + values: Array; +} + +interface CspRecommendation { + directive: string; + message: string; +} + +interface CspEvaluation { + directives: Array; + recommendations: Array; +} + +interface RecommendedDirective { + name: string; + values?: Array; + requireNonce?: boolean; + tolerated?: Array; + fallback?: string; + /** + * `true` if the directive is always optional, or the directives which make + * it optional if all of them are defined. Optional directives are only + * evaluated if they are defined. + */ + optional?: true | Array; +} + +const RECOMMENDED_DIRECTIVES: Array = [ + { name: "upgrade-insecure-requests" }, + { name: "trusted-types" }, + { name: "require-trusted-types-for", values: ["'script'"] }, + { name: "default-src", values: ["'self'"] }, + { + name: "script-src", + requireNonce: true, + tolerated: ["'strict-dynamic'"], + fallback: "default-src", + optional: ["script-src-elem", "script-src-attr"], + }, + { + name: "script-src-elem", + requireNonce: true, + tolerated: ["'strict-dynamic'"], + optional: true, + }, + { name: "script-src-attr", values: ["'none'"], optional: true }, + { + name: "style-src", + requireNonce: true, + fallback: "default-src", + optional: ["style-src-elem", "style-src-attr"], + }, + { name: "style-src-elem", requireNonce: true, optional: true }, + { name: "style-src-attr", values: ["'none'"], optional: true }, + { name: "img-src", values: ["'self'", "data:"], fallback: "default-src" }, + { name: "font-src", values: ["'self'"], fallback: "default-src" }, + { name: "connect-src", values: ["'self'"], fallback: "default-src" }, + { name: "object-src", values: ["'none'"] }, + { name: "base-uri", values: ["'self'"] }, + { name: "form-action", values: ["'self'"] }, + { name: "frame-ancestors", values: ["'none'"] }, +]; + +const NONCE_PLACEHOLDER = "'nonce-[RANDOM]'"; + +const MASKED_NONCE = maskedValueWithIndex("NONCE"); + +/** + * Returns the `Content-Security-Policy` header of a response, or an empty + * string if the response or the header is missing. + */ +export async function getCspHeader(response: Response | null): Promise { + return (await response?.headerValue("content-security-policy")) ?? ""; +} + +/** + * Evaluates a `Content-Security-Policy` header against a set of recommended + * directives and returns the result as a Markdown report. + * + * The report lists all directives of the header, followed by recommendations + * for missing or weak directives. Missing fetch directives are evaluated using + * `default-src`, just like the browser falls back to it. Nonces are masked as + * `[NONCE_0]`, `[NONCE_1]`, … to keep the report deterministic for file snapshots. + * + * @example + * ```ts + * const response = await page.goto("/"); + * const cspHeader = await getCspHeader(response); + * + * expect(cspReport(cspHeader)).toMatchTextFile({ + * fileExtension: "md", + * }); + * ``` + */ +export function cspReport(header: string): string { + const { directives, recommendations } = evaluateCsp(parseCsp(header)); + + return [ + "# CSP Report", + "## Detected Directives", + directives.length === 0 + ? "None" + : markdownTable([ + ["Directive", "Value"], + ...maskNonces(directives).map((directive) => [ + code(directive.name), + formatValues(directive.values), + ]), + ]), + "## Recommendations", + recommendations.length === 0 + ? "None" + : recommendations + .map( + (recommendation) => + `- ${code(recommendation.directive)}: ${recommendation.message}`, + ) + .join("\n"), + ] + .join("\n\n") + .concat("\n"); +} + +function parseCsp(header: string): Array { + return header + .split(";") + .map((directive) => directive.trim()) + .filter((directive) => directive !== "") + .map((directive) => { + const [name = "", ...values] = directive.split(/\s+/); + return { name: name.toLowerCase(), values }; + }); +} + +function evaluateCsp(directives: Array): CspEvaluation { + const directivesByName = new Map(); + for (const directive of directives) { + if (!directivesByName.has(directive.name)) { + directivesByName.set(directive.name, directive); + } + } + + const recommendations = RECOMMENDED_DIRECTIVES.flatMap((recommended) => + evaluateDirective(recommended, directivesByName).map((message) => ({ + directive: recommended.name, + message, + })), + ); + + return { directives, recommendations }; +} + +function evaluateDirective( + recommended: RecommendedDirective, + directivesByName: Map, +): Array { + const directive = directivesByName.get(recommended.name); + if (directive === undefined && isOptional(recommended, directivesByName)) { + return []; + } + + const fallback = + recommended.fallback !== undefined + ? directivesByName.get(recommended.fallback) + : undefined; + const actual = directive ?? fallback; + + if (actual === undefined) { + return [ + `Missing, add ${code([recommended.name, ...formatRecommendedValues(recommended)].join(" "))}`, + ]; + } + + const inherited = + directive === undefined ? ` (inherited from ${code(actual.name)})` : ""; + const messages: Array = []; + const recommendedValues = recommended.values; + const extraValues = findExtraValues(recommended, actual.values); + const hasRecommendedValue = extraValues.length < actual.values.length; + + if ( + recommendedValues !== undefined && + extraValues.length > 0 && + !hasRecommendedValue + ) { + messages.push(`Add ${formatValues(recommendedValues)}`); + } + + if (recommended.requireNonce === true && !actual.values.some(isNonce)) { + messages.push(`Add a nonce source`); + } + + for (const value of extraValues) { + messages.push(`Remove ${code(value)}${inherited}`); + } + + return messages; +} + +function isOptional( + recommended: RecommendedDirective, + directivesByName: Map, +): boolean { + if (Array.isArray(recommended.optional)) { + return recommended.optional.every((name) => directivesByName.has(name)); + } + + return recommended.optional === true; +} + +/** + * Returns all sources which are neither recommended nor tolerated. Directives + * without recommended sources accept any value, and `'none'` is always + * accepted as it is stricter than any recommendation. + */ +function findExtraValues( + recommended: RecommendedDirective, + values: Array, +): Array { + if ( + (recommended.values === undefined && recommended.requireNonce !== true) || + isNone(values) + ) { + return []; + } + + const acceptedValues = [ + ...(recommended.values ?? []), + ...(recommended.tolerated ?? []), + ]; + + return values.filter( + (value) => + !acceptedValues.includes(value) && + !(recommended.requireNonce === true && isNonce(value)), + ); +} + +function formatRecommendedValues( + recommended: RecommendedDirective, +): Array { + return [ + ...(recommended.requireNonce === true ? [NONCE_PLACEHOLDER] : []), + ...(recommended.values ?? []), + ]; +} + +function maskNonces(directives: Array): Array { + const nonceIndices = new Map(); + + function maskNonce(value: string): string { + if (!isNonce(value)) { + return value; + } + + let index = nonceIndices.get(value); + if (index === undefined) { + index = nonceIndices.size; + nonceIndices.set(value, index); + } + + return `'nonce-${MASKED_NONCE(index)}'`; + } + + return directives.map((directive) => ({ + ...directive, + values: directive.values.map(maskNonce), + })); +} + +function isNone(values: Array): boolean { + return values.length === 1 && values[0] === "'none'"; +} + +function isNonce(value: string): boolean { + return value.startsWith("'nonce-"); +} + +function formatValues(values: Array): string { + if (values.length === 0) { + return "_none_"; + } + + return code(values.join(" ")); +} + +function code(text: string): string { + return `\`${text.replaceAll("|", "\\|")}\``; +} diff --git a/tests/csp-report.spec.ts b/tests/csp-report.spec.ts new file mode 100644 index 0000000..657ccf0 --- /dev/null +++ b/tests/csp-report.spec.ts @@ -0,0 +1,100 @@ +import { test } from "@playwright/test"; + +import { cspReport, getCspHeader } from "../src/security/csp-report"; +import { expect } from "../src/test/fixtures"; + +const SERVER_URL = "https://csp-report.test"; + +async function testCspReport(cspHeader: string): Promise { + await expect.soft(cspReport(cspHeader)).toMatchTextFile({ + fileExtension: "md", + }); +} + +test("recommends nothing for a strict policy", async () => { + await testCspReport( + "upgrade-insecure-requests; trusted-types angular; require-trusted-types-for 'script'; default-src 'self'; script-src 'nonce-abc'; style-src 'nonce-def'; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'", + ); +}); + +test("recommends all directives for an empty header", async () => { + await testCspReport(""); +}); + +test("recommends adding a nonce source", async () => { + await testCspReport( + "upgrade-insecure-requests; trusted-types angular; require-trusted-types-for 'script'; default-src 'self'; script-src 'strict-dynamic'; style-src 'nonce-def'; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'", + ); +}); + +test("recommends adding missing sources", async () => { + await testCspReport( + "upgrade-insecure-requests; trusted-types angular; require-trusted-types-for 'script'; default-src 'self'; script-src 'nonce-abc'; style-src 'nonce-def'; img-src https://images.example.com; object-src https://cdn.example.com; base-uri 'none'; form-action https://api.example.com; frame-ancestors 'none'", + ); +}); + +test("recommends removing extra sources", async () => { + await testCspReport( + "upgrade-insecure-requests; trusted-types angular; require-trusted-types-for 'script'; default-src 'self'; script-src 'nonce-abc' 'strict-dynamic' https://cdn.example.com; style-src 'nonce-def' 'unsafe-inline'; img-src 'self' data: https://images.example.com; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'", + ); +}); + +test("evaluates missing directives using their fallback", async () => { + await testCspReport( + "upgrade-insecure-requests; trusted-types angular; require-trusted-types-for 'script'; default-src 'self'; script-src 'nonce-abc'; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'", + ); +}); + +test("recommends nothing for strict directive variants", async () => { + await testCspReport( + "upgrade-insecure-requests; trusted-types angular; require-trusted-types-for 'script'; default-src 'self'; script-src 'nonce-abc'; script-src-elem 'nonce-abc'; script-src-attr 'none'; style-src 'nonce-def'; style-src-elem 'nonce-def'; style-src-attr 'none'; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'", + ); +}); + +test("recommends fixing weak directive variants", async () => { + await testCspReport( + "upgrade-insecure-requests; trusted-types angular; require-trusted-types-for 'script'; default-src 'self'; script-src 'nonce-abc'; script-src-elem 'unsafe-inline' https://cdn.example.com; script-src-attr 'unsafe-inline'; style-src 'nonce-def'; style-src-elem 'nonce-def' 'unsafe-inline'; style-src-attr 'unsafe-hashes'; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'", + ); +}); + +test("does not require base directives if both variants are defined", async () => { + await testCspReport( + "upgrade-insecure-requests; trusted-types angular; require-trusted-types-for 'script'; default-src 'self'; script-src-elem 'nonce-abc'; script-src-attr 'none'; style-src-elem 'nonce-def'; style-src-attr 'none'; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'", + ); +}); + +test("requires base directives if only one variant is defined", async () => { + await testCspReport( + "upgrade-insecure-requests; trusted-types angular; require-trusted-types-for 'script'; default-src 'self'; script-src-elem 'nonce-abc'; style-src 'nonce-def'; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'", + ); +}); + +test("extracts the CSP header from a response", async ({ page }) => { + await page.route(`${SERVER_URL}/**`, (route) => + route.fulfill({ + contentType: "text/html", + headers: { "content-security-policy": "default-src 'self'" }, + body: "", + }), + ); + + const response = await page.goto(SERVER_URL); + + expect(await getCspHeader(response)).toBe("default-src 'self'"); +}); + +test("falls back to an empty string if the CSP header is missing", async ({ + page, +}) => { + await page.route(`${SERVER_URL}/**`, (route) => + route.fulfill({ contentType: "text/html", body: "" }), + ); + + const response = await page.goto(SERVER_URL); + + expect(await getCspHeader(response)).toBe(""); +}); + +test("falls back to an empty string if the response is missing", async () => { + expect(await getCspHeader(null)).toBe(""); +});