Skip to content

Commit ccd171f

Browse files
committed
fix(csp): use 'unsafe-inline' for style-src (match cloud-portal) to stop Safari refusing stylesheets
Strict nonce-based style-src made Safari refuse the <style>/<link> stylesheets that CSS-in-JS libraries (sonner toasts, Radix UI) inject without a nonce ("Refused to apply a stylesheet because its hash, its nonce, or 'unsafe-inline' does not appear in the style-src directive"), breaking styling. Inline style attributes and injected stylesheets cannot carry a nonce, and per CSP3 a nonce in the directive disables 'unsafe-inline' — so the nonce is dropped from style-src and 'unsafe-inline' is used in dev and prod. Mirrors datum cloud-portal (app/server/entry.ts: strict script-src nonce + "Allow inline styles for third-party widgets"). script-src stays strict (NONCE + 'strict-dynamic'), which is the real XSS boundary; style-based injection is low-risk. Supersedes the earlier revert (3e4c6a3): that kept the strict policy assuming the violations were cosmetic, but Safari refusing stylesheets is a real visual break. Tests assert the new style-src policy and the script-src nonce invariant.
1 parent 728d246 commit ccd171f

3 files changed

Lines changed: 21 additions & 8 deletions

File tree

app/server/edge/__tests__/edge-home.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ describe('server/edge home (request-bound Hono code)', () => {
55
it('exposes cspDirectives from the new edge path', async () => {
66
const mod = await import('@/server/edge/secure-headers');
77
expect(typeof mod.cspDirectives).toBe('function');
8-
expect(mod.cspDirectives(false).styleSrc).toContain(NONCE); // CSP nonce invariant survives the move
8+
expect(mod.cspDirectives(false).scriptSrc).toContain(NONCE); // CSP nonce invariant survives the move (script-src; style-src uses 'unsafe-inline')
99
});
1010
it('the old middleware path still resolves (shim)', async () => {
1111
const mod = await import('@/server/middleware/secure-headers');

app/server/edge/secure-headers.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,9 +54,15 @@ export function cspDirectives(isDev: boolean, frameAncestors: readonly string[]
5454
scriptSrc: isDev
5555
? ["'self'", "'unsafe-inline'", "'unsafe-eval'"]
5656
: ["'self'", NONCE, "'strict-dynamic'"],
57-
// Production drops 'unsafe-inline' and uses the per-request nonce
58-
// (mirrors scriptSrc); dev keeps 'unsafe-inline' for HMR-injected styles.
59-
styleSrc: isDev ? ["'self'", "'unsafe-inline'"] : ["'self'", NONCE],
57+
// style-src uses 'unsafe-inline' in dev AND prod (no nonce). CSS-in-JS libs
58+
// (sonner toasts, Radix UI) inject <style>/<link> stylesheets and element.style
59+
// attributes that cannot carry a nonce — Safari *refuses the stylesheet outright*
60+
// ("Refused to apply a stylesheet... nonce... or 'unsafe-inline'..."), so styling
61+
// breaks. Per CSP3 a nonce in the directive disables 'unsafe-inline', so the nonce
62+
// is intentionally dropped here. Mirrors datum cloud-portal (app/server/entry.ts:
63+
// "Allow inline styles for third-party widgets"). script-src stays strict
64+
// (NONCE + 'strict-dynamic') — that is the real XSS boundary; style injection is low-risk.
65+
styleSrc: ["'self'", "'unsafe-inline'"],
6066
imgSrc: ["'self'", 'data:', 'https:'],
6167
// Fathom analytics beacons POST to https://cdn.usefathom.com — without this
6268
// connect-src entry the browser blocks them. script-src needs no change:

app/server/middleware/__tests__/secure-headers.test.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,18 @@ describe('cspDirectives', () => {
2828
});
2929
});
3030

31-
describe('CSP style-src hardening', () => {
32-
it('production style-src uses the nonce and drops unsafe-inline', () => {
31+
describe('CSP style-src policy', () => {
32+
// style-src intentionally uses 'unsafe-inline' (no nonce) in dev AND prod so the
33+
// <style>/<link> stylesheets and element.style writes that CSS-in-JS libs (sonner,
34+
// Radix) emit are not refused by the browser. Per CSP3 a nonce would disable
35+
// 'unsafe-inline', so it must be absent. Mirrors datum cloud-portal. The real XSS
36+
// defense — script-src nonce — is unaffected.
37+
it('production style-src uses unsafe-inline and drops the nonce', () => {
3338
const prod = cspDirectives(false);
34-
expect(prod.styleSrc).toContain(NONCE);
35-
expect(prod.styleSrc).not.toContain("'unsafe-inline'");
39+
expect(prod.styleSrc).toContain("'unsafe-inline'");
40+
expect(prod.styleSrc).not.toContain(NONCE);
41+
// script-src stays strict — the load-bearing invariant
42+
expect(prod.scriptSrc).toContain(NONCE);
3643
});
3744

3845
it('dev style-src keeps unsafe-inline for HMR', () => {

0 commit comments

Comments
 (0)