Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 19 additions & 3 deletions server/securityHeaders.js
Original file line number Diff line number Diff line change
Expand Up @@ -133,9 +133,25 @@ export const EMBEDDABLE_PATHS = ['/stats/embed'];
// violations are logged as `csp.violation` events (server/cspReport.js)
// without breaking the site.
// - 'enforce': the hash-based policy becomes the enforcing header.
export const CSP_MODE = ['report-only', 'enforce'].includes(process.env.CSP_MODE)
? process.env.CSP_MODE
: 'report-only';
export const CSP_MODES = ['report-only', 'enforce'];

// Resolve the rollout switch without silently accepting a typo: cloudbuild.yaml
// and deploy-with-secret.sh already reject unknown values, but an out-of-band
// env edit (gcloud run services update --update-env-vars CSP_MODE=enforc) used to
// land here and quietly keep the legacy 'unsafe-inline' policy enforcing with the
// strict one stuck in Report-Only. Warn instead of downgrading in silence.
export function resolveCspMode(raw, warn = (...args) => console.warn(...args)) {
if (raw === undefined || raw === null || raw === '') return 'report-only';
if (CSP_MODES.includes(raw)) return raw;
warn(
`csp.mode.invalid: CSP_MODE=${JSON.stringify(raw)} is not one of ${CSP_MODES.join('|')}; ` +
"falling back to 'report-only' (legacy 'unsafe-inline' policy keeps enforcing). " +
'Fix the deploy env var to silence this warning.'
);
return 'report-only';
}

export const CSP_MODE = resolveCspMode(process.env.CSP_MODE);

// Extract inline <script> contents and return their CSP source expressions.
// Exported so server.js and tests share one implementation. `sha256` must be a
Expand Down
25 changes: 24 additions & 1 deletion tests/securityHeaders.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import {
CSP_HEADER,
CSP_EMBED_HEADER,
computeInlineScriptSources,
createSecurityHeadersMiddleware
createSecurityHeadersMiddleware,
resolveCspMode
} from '../server/securityHeaders.js';
import { createFastifyCompatApp } from '../server/fastifyCompat.js';

Expand Down Expand Up @@ -315,3 +316,25 @@ test('index.html: every inline script is hashed and nothing needs an inline even
.flatMap(el => [...el.attributes].filter(a => /^on/i.test(a.name)).map(a => `<${el.tagName.toLowerCase()} ${a.name}>`));
assert.deepEqual(handlers, [], 'inline event handlers cannot be allowed by a hash-based CSP');
});

test('resolveCspMode: unset or empty CSP_MODE keeps the documented default', () => {
const warnings = [];
assert.equal(resolveCspMode(undefined, m => warnings.push(m)), 'report-only');
assert.equal(resolveCspMode('', m => warnings.push(m)), 'report-only');
assert.deepEqual(warnings, []);
});

test('resolveCspMode: known modes pass through without a warning', () => {
const warnings = [];
assert.equal(resolveCspMode('report-only', m => warnings.push(m)), 'report-only');
assert.equal(resolveCspMode('enforce', m => warnings.push(m)), 'enforce');
assert.deepEqual(warnings, []);
});

test('resolveCspMode: a typo warns instead of silently downgrading (issue #185)', () => {
const warnings = [];
assert.equal(resolveCspMode('enforc', m => warnings.push(m)), 'report-only');
assert.equal(warnings.length, 1);
assert.match(warnings[0], /csp\.mode\.invalid/);
assert.match(warnings[0], /enforc/);
});