diff --git a/src/browser/CdpSafeMetadata.ts b/src/browser/CdpSafeMetadata.ts index cb0381b24..da9793aee 100644 --- a/src/browser/CdpSafeMetadata.ts +++ b/src/browser/CdpSafeMetadata.ts @@ -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; } }; diff --git a/tests/boundary/browser/cdpSafeMetadata.test.ts b/tests/boundary/browser/cdpSafeMetadata.test.ts index 3c9da4c77..f07771fa0 100644 --- a/tests/boundary/browser/cdpSafeMetadata.test.ts +++ b/tests/boundary/browser/cdpSafeMetadata.test.ts @@ -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 }, + ]); + }); +});