-
Notifications
You must be signed in to change notification settings - Fork 0
fix: handle CSP 'none' keyword correctly in buildCspHeader() #10
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| --- | ||
| "@enalmada/start-secure": patch | ||
| --- | ||
|
|
||
| Fix CSP 'none' keyword handling in buildCspHeader() | ||
|
|
||
| When users added sources to CSP directives that default to 'none' (frame-src, object-src, child-src, frame-ancestors), the 'none' keyword was incorrectly retained alongside the new sources, creating invalid CSP directives like `frame-src 'none' https://youtube.com`. | ||
|
|
||
| According to the CSP specification, 'none' must be the only value in a directive. This fix adds comprehensive 'none' keyword handling: | ||
|
|
||
| - Removes 'none' when other sources are added to a directive | ||
| - Clears directive and sets only 'none' when user explicitly sets it alone | ||
| - Filters 'none' from user values if mixed with other sources | ||
|
|
||
| Browser console warnings like "Ignoring unknown option 'none'" are now eliminated, and CSP headers are fully spec-compliant. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,343 @@ | ||
| import { describe, expect, test } from "vitest"; | ||
| import { buildCspHeader } from "../src"; | ||
| import type { CspRule } from "../src/internal/types"; | ||
|
|
||
| describe("buildCspHeader - 'none' keyword handling", () => { | ||
| test("removes 'none' from frame-src when adding YouTube URLs (bug scenario)", () => { | ||
| // This is the exact scenario that caused the browser warning | ||
| const rules: CspRule[] = [ | ||
| { | ||
| description: "frame-sources", | ||
| "frame-src": ["https://*.youtube.com", "https://www.youtube-nocookie.com", "https://*.gell.com"], | ||
| }, | ||
| ]; | ||
|
|
||
| const cspHeader = buildCspHeader(rules, "test-nonce", true); | ||
|
|
||
| // Extract frame-src directive | ||
| const frameSrcMatch = cspHeader.match(/frame-src ([^;]+)/); | ||
| expect(frameSrcMatch).toBeTruthy(); | ||
|
|
||
| const frameSrcValue = frameSrcMatch?.[1]; | ||
|
|
||
| // Should contain the YouTube URLs | ||
| expect(frameSrcValue).toContain("https://*.youtube.com"); | ||
| expect(frameSrcValue).toContain("https://www.youtube-nocookie.com"); | ||
| expect(frameSrcValue).toContain("https://*.gell.com"); | ||
|
|
||
| // Should NOT contain 'none' (this was the bug!) | ||
| expect(frameSrcValue).not.toContain("'none'"); | ||
| }); | ||
|
|
||
| test("removes 'none' from frame-src when adding single URL", () => { | ||
| const rules: CspRule[] = [ | ||
| { | ||
| description: "youtube", | ||
| "frame-src": "https://www.youtube.com", | ||
| }, | ||
| ]; | ||
|
|
||
| const cspHeader = buildCspHeader(rules, "test-nonce", false); | ||
| const frameSrcMatch = cspHeader.match(/frame-src ([^;]+)/); | ||
|
|
||
| expect(frameSrcMatch?.[1]).toContain("https://www.youtube.com"); | ||
| expect(frameSrcMatch?.[1]).not.toContain("'none'"); | ||
| }); | ||
|
|
||
| test("removes 'none' from object-src when adding source", () => { | ||
| const rules: CspRule[] = [ | ||
| { | ||
| description: "pdf-viewer", | ||
| "object-src": "https://cdn.example.com", | ||
| }, | ||
| ]; | ||
|
|
||
| const cspHeader = buildCspHeader(rules, "test-nonce", false); | ||
| const objectSrcMatch = cspHeader.match(/object-src ([^;]+)/); | ||
|
|
||
| expect(objectSrcMatch?.[1]).toContain("https://cdn.example.com"); | ||
| expect(objectSrcMatch?.[1]).not.toContain("'none'"); | ||
| }); | ||
|
|
||
| test("removes 'none' from child-src when adding source", () => { | ||
| const rules: CspRule[] = [ | ||
| { | ||
| description: "worker", | ||
| "child-src": "blob:", | ||
| }, | ||
| ]; | ||
|
|
||
| const cspHeader = buildCspHeader(rules, "test-nonce", false); | ||
| const childSrcMatch = cspHeader.match(/child-src ([^;]+)/); | ||
|
|
||
| expect(childSrcMatch?.[1]).toContain("blob:"); | ||
| expect(childSrcMatch?.[1]).not.toContain("'none'"); | ||
| }); | ||
|
|
||
| test("keeps 'none' when no other values are added", () => { | ||
| // Don't add any frame-src rules, should keep default 'none' | ||
| const rules: CspRule[] = []; | ||
|
|
||
| const cspHeader = buildCspHeader(rules, "test-nonce", false); | ||
| const frameSrcMatch = cspHeader.match(/frame-src ([^;]+)/); | ||
|
|
||
| expect(frameSrcMatch?.[1]).toBe("'none'"); | ||
| }); | ||
|
|
||
| test("handles 'none' correctly when it's the only value in rule", () => { | ||
| // User explicitly sets a directive to 'none' | ||
| const rules: CspRule[] = [ | ||
| { | ||
| description: "test", | ||
| "media-src": "'none'", | ||
| }, | ||
| ]; | ||
|
|
||
| const cspHeader = buildCspHeader(rules, "test-nonce", false); | ||
| const mediaSrcMatch = cspHeader.match(/media-src ([^;]+)/); | ||
|
|
||
| // Should only have 'none', not the default 'self' | ||
| expect(mediaSrcMatch?.[1]).toBe("'none'"); | ||
| expect(mediaSrcMatch?.[1]).not.toContain("'self'"); | ||
| }); | ||
|
|
||
| test("removes 'none' when user provides mixed values in single string", () => { | ||
| // User tries to add 'none' with other values (invalid, should filter 'none') | ||
| const rules: CspRule[] = [ | ||
| { | ||
| description: "test", | ||
| "frame-src": "'none' https://example.com", | ||
| }, | ||
| ]; | ||
|
|
||
| const cspHeader = buildCspHeader(rules, "test-nonce", false); | ||
| const frameSrcMatch = cspHeader.match(/frame-src ([^;]+)/); | ||
|
|
||
| // Should have example.com but NOT 'none' | ||
| expect(frameSrcMatch?.[1]).toContain("https://example.com"); | ||
| expect(frameSrcMatch?.[1]).not.toContain("'none'"); | ||
| }); | ||
|
|
||
| test("removes 'none' when adding multiple values via array", () => { | ||
| const rules: CspRule[] = [ | ||
| { | ||
| description: "test", | ||
| "frame-src": ["https://example1.com", "https://example2.com"], | ||
| }, | ||
| ]; | ||
|
|
||
| const cspHeader = buildCspHeader(rules, "test-nonce", false); | ||
| const frameSrcMatch = cspHeader.match(/frame-src ([^;]+)/); | ||
|
|
||
| expect(frameSrcMatch?.[1]).toContain("https://example1.com"); | ||
| expect(frameSrcMatch?.[1]).toContain("https://example2.com"); | ||
| expect(frameSrcMatch?.[1]).not.toContain("'none'"); | ||
| }); | ||
|
|
||
| test("handles multiple rules modifying same directive with 'none'", () => { | ||
| const rules: CspRule[] = [ | ||
| { | ||
| description: "rule1", | ||
| "frame-src": "https://example1.com", | ||
| }, | ||
| { | ||
| description: "rule2", | ||
| "frame-src": "https://example2.com", | ||
| }, | ||
| ]; | ||
|
|
||
| const cspHeader = buildCspHeader(rules, "test-nonce", false); | ||
| const frameSrcMatch = cspHeader.match(/frame-src ([^;]+)/); | ||
|
|
||
| // Should have both URLs, no 'none', and no duplicates | ||
| expect(frameSrcMatch?.[1]).toContain("https://example1.com"); | ||
| expect(frameSrcMatch?.[1]).toContain("https://example2.com"); | ||
| expect(frameSrcMatch?.[1]).not.toContain("'none'"); | ||
| }); | ||
|
|
||
| test("preserves default 'none' directives when not modified", () => { | ||
| // Add a frame-src rule but don't touch object-src | ||
| const rules: CspRule[] = [ | ||
| { | ||
| description: "youtube", | ||
| "frame-src": "https://www.youtube.com", | ||
| }, | ||
| ]; | ||
|
|
||
| const cspHeader = buildCspHeader(rules, "test-nonce", false); | ||
|
|
||
| // frame-src should not have 'none' | ||
| const frameSrcMatch = cspHeader.match(/frame-src ([^;]+)/); | ||
| expect(frameSrcMatch?.[1]).not.toContain("'none'"); | ||
|
|
||
| // object-src should still have 'none' (not modified) | ||
| const objectSrcMatch = cspHeader.match(/object-src ([^;]+)/); | ||
| expect(objectSrcMatch?.[1]).toBe("'none'"); | ||
|
|
||
| // child-src should still have 'none' (not modified) | ||
| const childSrcMatch = cspHeader.match(/child-src ([^;]+)/); | ||
| expect(childSrcMatch?.[1]).toBe("'none'"); | ||
| }); | ||
| }); | ||
|
|
||
| describe("buildCspHeader - basic functionality", () => { | ||
| test("generates valid CSP header with nonce", () => { | ||
| const rules: CspRule[] = []; | ||
| const cspHeader = buildCspHeader(rules, "abc123", false); | ||
|
|
||
| expect(cspHeader).toContain("default-src 'self'"); | ||
| expect(cspHeader).toContain("'nonce-abc123'"); | ||
| expect(cspHeader).toContain("'strict-dynamic'"); | ||
| }); | ||
|
|
||
| test("includes unsafe-eval in dev mode", () => { | ||
| const rules: CspRule[] = []; | ||
| const cspHeader = buildCspHeader(rules, "abc123", true); | ||
|
|
||
| expect(cspHeader).toContain("'unsafe-eval'"); | ||
| }); | ||
|
|
||
| test("excludes unsafe-eval in production mode", () => { | ||
| const rules: CspRule[] = []; | ||
| const cspHeader = buildCspHeader(rules, "abc123", false); | ||
|
|
||
| expect(cspHeader).not.toContain("'unsafe-eval'"); | ||
| }); | ||
|
|
||
| test("merges custom rules correctly", () => { | ||
| const rules: CspRule[] = [ | ||
| { | ||
| description: "custom-api", | ||
| "connect-src": "https://api.example.com", | ||
| }, | ||
| ]; | ||
|
|
||
| const cspHeader = buildCspHeader(rules, "abc123", false); | ||
| expect(cspHeader).toContain("https://api.example.com"); | ||
| }); | ||
|
|
||
| test("includes WebSocket sources in dev mode", () => { | ||
| const rules: CspRule[] = []; | ||
| const cspHeader = buildCspHeader(rules, "abc123", true); | ||
|
|
||
| expect(cspHeader).toContain("ws://localhost:*"); | ||
| expect(cspHeader).toContain("wss://localhost:*"); | ||
| }); | ||
|
|
||
| test("excludes WebSocket sources in production mode", () => { | ||
| const rules: CspRule[] = []; | ||
| const cspHeader = buildCspHeader(rules, "abc123", false); | ||
|
|
||
| expect(cspHeader).not.toContain("ws://localhost:*"); | ||
| expect(cspHeader).not.toContain("wss://localhost:*"); | ||
| }); | ||
|
|
||
| test("does not duplicate values when merging rules", () => { | ||
| const rules: CspRule[] = [ | ||
| { | ||
| description: "rule1", | ||
| "connect-src": "https://api.example.com", | ||
| }, | ||
| { | ||
| description: "rule2", | ||
| "connect-src": "https://api.example.com", // Same URL | ||
| }, | ||
| ]; | ||
|
|
||
| const cspHeader = buildCspHeader(rules, "abc123", false); | ||
| const connectSrcMatch = cspHeader.match(/connect-src ([^;]+)/); | ||
|
|
||
| // Count how many times the URL appears | ||
| const connectSrcValue = connectSrcMatch?.[1]; | ||
| const urlCount = (connectSrcValue.match(/https:\/\/api\.example\.com/g) || []).length; | ||
|
|
||
| expect(urlCount).toBe(1); // Should only appear once | ||
| }); | ||
|
|
||
| test("formats CSP header with semicolons between directives", () => { | ||
| const rules: CspRule[] = []; | ||
| const cspHeader = buildCspHeader(rules, "abc123", false); | ||
|
|
||
| // Should have semicolon-separated directives | ||
| expect(cspHeader).toMatch(/default-src [^;]+; base-uri [^;]+;/); | ||
| }); | ||
|
|
||
| test("handles array values in rules", () => { | ||
| const rules: CspRule[] = [ | ||
| { | ||
| description: "multiple-sources", | ||
| "img-src": ["https://cdn1.example.com", "https://cdn2.example.com"], | ||
| }, | ||
| ]; | ||
|
|
||
| const cspHeader = buildCspHeader(rules, "abc123", false); | ||
|
|
||
| expect(cspHeader).toContain("https://cdn1.example.com"); | ||
| expect(cspHeader).toContain("https://cdn2.example.com"); | ||
| }); | ||
|
|
||
| test("handles string values with multiple space-separated sources", () => { | ||
| const rules: CspRule[] = [ | ||
| { | ||
| description: "multiple-sources", | ||
| "font-src": "https://fonts.gstatic.com https://fonts.googleapis.com", | ||
| }, | ||
| ]; | ||
|
|
||
| const cspHeader = buildCspHeader(rules, "abc123", false); | ||
|
|
||
| expect(cspHeader).toContain("https://fonts.gstatic.com"); | ||
| expect(cspHeader).toContain("https://fonts.googleapis.com"); | ||
| }); | ||
|
|
||
| test("'none' is case-sensitive (should not treat 'None' as 'none')", () => { | ||
| // CSP keywords are case-sensitive - 'None' and 'NONE' are NOT valid | ||
| const rules: CspRule[] = [ | ||
| { | ||
| description: "case-test", | ||
| "frame-src": "'None' https://example.com", | ||
| }, | ||
| ]; | ||
|
|
||
| const cspHeader = buildCspHeader(rules, "abc123", false); | ||
| const frameSrcMatch = cspHeader.match(/frame-src ([^;]+)/); | ||
|
|
||
| // 'None' should be treated as a regular value (invalid but not filtered) | ||
| // The default 'none' should still be present since we're not adding "'none'" (lowercase) | ||
| expect(frameSrcMatch?.[1]).toContain("'None'"); | ||
| expect(frameSrcMatch?.[1]).toContain("https://example.com"); | ||
| }); | ||
|
|
||
| test("removes 'none' from frame-ancestors when adding source", () => { | ||
| // frame-ancestors also defaults to 'none' | ||
| const rules: CspRule[] = [ | ||
| { | ||
| description: "iframe-parent", | ||
| "frame-ancestors": "https://trusted-parent.com", | ||
| }, | ||
| ]; | ||
|
|
||
| const cspHeader = buildCspHeader(rules, "abc123", false); | ||
| const frameAncestorsMatch = cspHeader.match(/frame-ancestors ([^;]+)/); | ||
|
|
||
| expect(frameAncestorsMatch?.[1]).toContain("https://trusted-parent.com"); | ||
| expect(frameAncestorsMatch?.[1]).not.toContain("'none'"); | ||
| }); | ||
|
|
||
| test("handles whitespace around 'none' value", () => { | ||
| // Test that whitespace is properly handled | ||
| const rules: CspRule[] = [ | ||
| { | ||
| description: "whitespace-test", | ||
| "frame-src": " 'none' https://example.com ", | ||
| }, | ||
| ]; | ||
|
|
||
| const cspHeader = buildCspHeader(rules, "abc123", false); | ||
| const frameSrcMatch = cspHeader.match(/frame-src ([^;]+)/); | ||
|
|
||
| // Should have example.com but NOT 'none' (trimmed and filtered) | ||
| expect(frameSrcMatch?.[1]).toContain("https://example.com"); | ||
| expect(frameSrcMatch?.[1]).not.toContain("'none'"); | ||
| }); | ||
| }); | ||
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.