fix(browser): classify CSP host sources by their own origin (#707)

* fix(browser): classify CSP host sources by their own origin

A Content-Security-Policy host-source without a scheme (for example
`cdn.example.com` or `*.trusted.test`) was resolved with
`new URL(token, baseUrl)`, so it became a page-relative path and was
reported as the observed page's own origin. Third-party script, style, and
connect sources were therefore misreported as approved origins, and a bare
`*` wildcard collapsed to the page origin as well.

Resolve host-sources per CSP3 \u00a72.3.1: a scheme-less source inherits the
protected resource's scheme, wildcard host/port sources stay classified as a
set of origins, and non-host tokens remain `other`. Keyword and scheme
sources are unchanged.

Adds boundary coverage for scheme-less host sources, wildcards, and scheme
inheritance against the approved-origin set.

* fix(browser): validate CSP host source syntax

---------

Co-authored-by: morluto <[email protected]>
This commit is contained in:
Dery Ferdika
2026-10-06 22:26:57 +08:00
committed by GitHub
co-authored by morluto
parent 739039092f
commit 1d15bcb9ec
2 changed files with 100 additions and 6 deletions
+52 -6
View File
@@ -125,15 +125,61 @@ const cspSource = (
if (/^'[a-z0-9-]+'$/u.test(lower)) return { kind: "keyword", value: lower };
if (/^[a-z][a-z0-9+.-]*:$/u.test(lower))
return { kind: "scheme", value: lower };
const hostOrigin = cspHostSourceOrigin(token, baseUrl);
if (hostOrigin === undefined) return { kind: "other", value: null };
return hostOrigin !== null && allowedOrigins.has(hostOrigin)
? { kind: "approved_origin", value: hostOrigin }
: { kind: "external_origin", value: null };
};
/**
* Resolve a CSP host-source (CSP3 §2.3.1) to the single origin it names. A
* scheme-less source inherits the protected resource's scheme instead of being
* resolved as a page-relative path. Returns `null` for a recognized wildcard
* host or port, which denotes a set of origins rather than one origin, and
* `undefined` when the token is not a host-source at all.
*/
const cspHostSourceOrigin = (
token: string,
baseUrl: string,
): string | null | undefined => {
const match = /^(?:([a-z][a-z0-9+.-]*):\/\/)?([^/?#]+)(?:\/[^?#]*)?$/iu.exec(
token,
);
const authority = match?.[2];
if (authority === undefined || authority.length === 0) return undefined;
const scheme = (match?.[1] ?? schemeOf(baseUrl))?.toLowerCase();
if (scheme === undefined || !/^[a-z][a-z0-9+.-]*$/u.test(scheme))
return undefined;
if (
!/^(?:\*|(?:\*\.)?[a-z0-9-]+(?:\.[a-z0-9-]+)*\.?)(?::(?:\*|\d+))?$/iu.test(
authority,
)
)
return undefined;
if (authority.includes("*")) return null;
try {
const parsed = new URL(token, baseUrl);
return allowedOrigins.has(parsed.origin)
? { kind: "approved_origin", value: parsed.origin }
: { kind: "external_origin", value: null };
const parsed = new URL(`${scheme}://${authority}`);
return parsed.username === "" &&
parsed.password === "" &&
parsed.pathname === "/" &&
parsed.origin !== "null"
? parsed.origin
: undefined;
} catch (cause: unknown) {
// Non-URL tokens are classified as other.
// Authorities that are not a bare host[:port] are classified as other.
void cause;
return { kind: "other", value: null };
return undefined;
}
};
const schemeOf = (baseUrl: string): string | undefined => {
try {
return new URL(baseUrl).protocol.replace(/:$/u, "");
} catch (cause: unknown) {
// An unparseable protected-resource URL cannot lend a scheme.
void cause;
return undefined;
}
};
@@ -142,3 +142,51 @@ describe("safe CDP response metadata", () => {
);
});
});
describe("safe CDP response metadata CSP sources", () => {
it("classifies scheme-less CSP host sources by their own host, not the page origin", () => {
const captured = safeResponseMetadata(
"request-csp",
`${origin}/api`,
{
headers: {
"Content-Security-Policy":
"script-src cdn.example.test *.trusted.test * 'self' https://api.other.test",
},
},
new Set([origin]),
);
expect(captured.response.csp.directives).toEqual([
{
name: "script-src",
sources: [
{ kind: "external_origin", value: null },
{ kind: "external_origin", value: null },
{ kind: "external_origin", value: null },
{ kind: "keyword", value: "'self'" },
{ kind: "external_origin", value: null },
],
},
]);
});
it("inherits the protected resource scheme for a scheme-less host source", () => {
const captured = safeResponseMetadata(
"request-csp-scheme",
`${origin}/api`,
{
headers: {
"Content-Security-Policy":
"script-src cdn.example.test app.example.test",
},
},
new Set([origin, "https://cdn.example.test"]),
);
expect(captured.response.csp.directives[0]?.sources).toEqual([
{ kind: "approved_origin", value: "https://cdn.example.test" },
{ kind: "approved_origin", value: origin },
]);
});
});