Skip to content

Commit cf73d29

Browse files
bartlomiejuclaude
andauthored
fix: CSP user directives now override defaults instead of duplicating (#3724)
Fixes #3552 Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 4edfe2f commit cf73d29

2 files changed

Lines changed: 47 additions & 11 deletions

File tree

packages/fresh/src/middlewares/csp.ts

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -82,19 +82,26 @@ export function csp<State>(options: CSPOptions = {}): Middleware<State> {
8282
"upgrade-insecure-requests",
8383
];
8484

85-
const cspDirectives = [...defaultCsp, ...csp];
85+
// User-provided directives override defaults with the same name
86+
const userDirectiveNames = new Set(
87+
csp.map((d) => d.split(" ")[0]),
88+
);
89+
const merged = defaultCsp.filter((d) =>
90+
!userDirectiveNames.has(d.split(" ")[0])
91+
);
92+
merged.push(...csp);
93+
8694
if (reportTo) {
87-
cspDirectives.push(`report-to csp-endpoint`);
88-
cspDirectives.push(`report-uri ${reportTo}`); // deprecated but some browsers still use it
95+
merged.push(`report-to csp-endpoint`);
96+
merged.push(`report-uri ${reportTo}`); // deprecated but some browsers still use it
8997
}
90-
9198
const headerName = reportOnly
9299
? "Content-Security-Policy-Report-Only"
93100
: "Content-Security-Policy";
94101

95102
if (!useNonce) {
96103
// Static CSP — no per-request nonce
97-
const cspString = cspDirectives.join("; ");
104+
const cspString = merged.join("; ");
98105
return async (ctx) => {
99106
const res = await ctx.next();
100107
res.headers.set(headerName, cspString);
@@ -113,7 +120,7 @@ export function csp<State>(options: CSPOptions = {}): Middleware<State> {
113120

114121
let directives: string[];
115122
if (nonce) {
116-
directives = cspDirectives.map((d) => {
123+
directives = merged.map((d) => {
117124
const spaceIdx = d.indexOf(" ");
118125
const name = spaceIdx === -1 ? d : d.slice(0, spaceIdx);
119126
if (INLINE_DIRECTIVES.has(name) && d.includes("'unsafe-inline'")) {
@@ -122,7 +129,7 @@ export function csp<State>(options: CSPOptions = {}): Middleware<State> {
122129
return d;
123130
});
124131
} else {
125-
directives = cspDirectives;
132+
directives = merged;
126133
}
127134

128135
res.headers.set(headerName, directives.join("; "));

packages/fresh/src/middlewares/csp_test.tsx

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,17 +30,46 @@ Deno.test("CSP - GET with override options", async () => {
3030
.handler();
3131

3232
const res = await handler(new Request("https://localhost/"));
33+
const header = res.headers.get("Content-Security-Policy")!;
3334

3435
expect(res.status).toBe(200);
35-
expect(res.headers.get("Content-Security-Policy")).toContain(
36-
"font-src 'self' 'https://fonts.gstatic.com'; style-src 'self' 'https://fonts.googleapis.com'",
36+
expect(header).toContain(
37+
"font-src 'self' 'https://fonts.gstatic.com'",
3738
);
38-
expect(res.headers.get("Content-Security-Policy")).toContain(
39-
"report-uri /api/csp-reports",
39+
expect(header).toContain(
40+
"style-src 'self' 'https://fonts.googleapis.com'",
4041
);
42+
expect(header).toContain("report-uri /api/csp-reports");
4143
expect(res.headers.get("Reporting-Endpoints")).toBe(
4244
'csp-endpoint="/api/csp-reports"',
4345
);
46+
47+
// Overrides should replace defaults, not duplicate them
48+
const fontSrcCount = header.split("font-src").length - 1;
49+
expect(fontSrcCount).toBe(1);
50+
const styleSrcCount = header.split("style-src").length - 1;
51+
expect(styleSrcCount).toBe(1);
52+
});
53+
54+
Deno.test("CSP - user directives override defaults", async () => {
55+
const handler = new App()
56+
.use(csp({
57+
csp: [
58+
"img-src 'self' https://example.com data:",
59+
],
60+
}))
61+
.get("/", () => new Response("ok"))
62+
.handler();
63+
64+
const res = await handler(new Request("https://localhost/"));
65+
const header = res.headers.get("Content-Security-Policy")!;
66+
67+
// Should contain the user's img-src, not the default
68+
expect(header).toContain("img-src 'self' https://example.com data:");
69+
70+
// Should not duplicate img-src
71+
const imgSrcCount = header.split("img-src").length - 1;
72+
expect(imgSrcCount).toBe(1);
4473
});
4574

4675
Deno.test("CSP - GET report only", async () => {

0 commit comments

Comments
 (0)