fix: replace frame-ancestors instead of merging it

Merging the configured value into the default turned a deliberate
CSP_FRAME_ANCESTORS='none' into `frame-ancestors 'self' 'none'`, which
browsers resolve back to 'self'. Also bail out if the serialized policy
somehow lacks the nonce slot rather than emitting a header the shell
cannot match.
This commit is contained in:
Danny Avila 2026-07-26 23:07:29 -04:00
parent 05f3a85191
commit 4d5f577e83
2 changed files with 25 additions and 9 deletions

View file

@ -93,6 +93,13 @@ describe('policy directives', () => {
expect(header).toContain("frame-ancestors 'self' https://portal.example.com");
});
it("replaces rather than merges frame-ancestors, so 'none' is not diluted by 'self'", () => {
const header = headerFor({ CSP_FRAME_ANCESTORS: "'none'" });
expect(header).toContain("frame-ancestors 'none'");
expect(header).not.toContain("frame-ancestors 'self'");
});
it('appends additional directives and skips malformed ones', () => {
const header = serializeCspDirectives(
buildCspDirectives({

View file

@ -76,7 +76,7 @@ function scriptSources(scriptExtras: string[]): string[] {
* `'unsafe-inline'`, which would block every `<style>` element injected at runtime by
* the app shell and by third-party components that cannot know our nonce.
*/
function defaultDirectives(scriptExtras: string[]): CspDirective[] {
function defaultDirectives(scriptExtras: string[], frameAncestors: string[]): CspDirective[] {
return [
['default-src', ["'self'"]],
['base-uri', ["'self'"]],
@ -92,7 +92,9 @@ function defaultDirectives(scriptExtras: string[]): CspDirective[] {
['worker-src', ["'self'", 'blob:']],
['manifest-src', ["'self'"]],
['form-action', ["'self'", 'https:']],
['frame-ancestors', ["'self'"]],
/* Replaced wholesale, not appended: merging would turn a deliberate
* `'none'` into `'self' 'none'`, which browsers resolve back to `'self'`. */
['frame-ancestors', frameAncestors],
];
}
@ -144,7 +146,11 @@ function mergeDirectives(directives: CspDirective[]): CspDirective[] {
export function buildCspDirectives(env: NodeJS.ProcessEnv = process.env): CspDirective[] {
const scriptExtras = splitSourceList(env.CSP_SCRIPT_SRC_EXTRA);
const directives = defaultDirectives(scriptExtras);
const frameAncestors = splitSourceList(env.CSP_FRAME_ANCESTORS);
const directives = defaultDirectives(
scriptExtras,
frameAncestors.length > 0 ? frameAncestors : ["'self'"],
);
for (const [directive, envName] of Object.entries(SOURCE_EXTRA_ENV)) {
const extraSources = splitSourceList(env[envName]);
@ -153,11 +159,6 @@ export function buildCspDirectives(env: NodeJS.ProcessEnv = process.env): CspDir
}
}
const frameAncestors = splitSourceList(env.CSP_FRAME_ANCESTORS);
if (frameAncestors.length > 0) {
directives.push(['frame-ancestors', frameAncestors]);
}
const reportUri = env.CSP_REPORT_URI?.trim();
if (reportUri) {
directives.push(['report-uri', [reportUri]]);
@ -184,7 +185,15 @@ export function createCspPolicy(env: NodeJS.ProcessEnv = process.env): CspPolicy
return null;
}
const [prefix, suffix] = serializeCspDirectives(buildCspDirectives(env)).split(NONCE_SLOT);
const serialized = serializeCspDirectives(buildCspDirectives(env));
const [prefix, suffix] = serialized.split(NONCE_SLOT);
if (suffix == null) {
logger.error(
'[CSP] Policy has no nonce slot; refusing to send a policy the shell cannot match.',
);
return null;
}
const reportOnly = isReportOnly(env);
logger.info(
`[CSP] Content Security Policy enabled in ${reportOnly ? 'report-only' : 'enforcing'} mode.`,