mirror of
https://github.com/morluto/rea.git
synced 2026-10-11 21:39:55 +00:00
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:
@@ -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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user