-
Notifications
You must be signed in to change notification settings - Fork 1
security: enforce renderer CSP and externalize theme bootstrap #172
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -3,23 +3,13 @@ | |||||
| <head> | ||||||
| <meta charset="UTF-8" /> | ||||||
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | ||||||
| <meta | ||||||
| http-equiv="Content-Security-Policy" | ||||||
| content="default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data: assets:; font-src 'self' data:; connect-src 'self' https: http:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'" | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The
Suggested change
|
||||||
| /> | ||||||
|
Comment on lines
+6
to
+9
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 2. Frame-ancestors ignored in meta csp The CSP specification explicitly prohibits frame-ancestors inside a `<meta http-equiv='Content-Security-Policy'>` tag — browsers and Electron's Chromium engine are required to silently ignore it. Since Electron loads the renderer via loadFile() (file:// protocol) with no HTTP response headers and no onHeadersReceived hook, the intended clickjacking protection is completely non-operational. Agent Prompt
|
||||||
| <title>AI Code Fusion</title> | ||||||
| <link rel="stylesheet" href="../../../dist/renderer/output.css" /> | ||||||
| <script> | ||||||
| // Apply dark mode immediately to prevent flash of light theme | ||||||
| const appWindow = globalThis; | ||||||
| const savedMode = localStorage.getItem('darkMode'); | ||||||
| const prefersDark = | ||||||
| typeof appWindow.matchMedia === 'function' && | ||||||
| appWindow.matchMedia('(prefers-color-scheme: dark)').matches; | ||||||
| const shouldEnableDarkMode = savedMode === 'true' || (savedMode === null && prefersDark); | ||||||
|
|
||||||
| if (shouldEnableDarkMode) { | ||||||
| document.documentElement.classList.add('dark'); | ||||||
| } else { | ||||||
| document.documentElement.classList.remove('dark'); | ||||||
| } | ||||||
| </script> | ||||||
| <script src="./theme-bootstrap.js"></script> | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 1. qa:screenshot not run for ui change The PR modifies startup UI rendering behavior by replacing the inline dark-mode bootstrap script with an external theme-bootstrap.js file, which qualifies as a UI behavior change. The PR description's validation section omits npm run qa:screenshot, violating the requirement to run screenshot QA whenever UI behavior or layout changes are introduced. Agent Prompt
|
||||||
| </head> | ||||||
| <body | ||||||
| class="h-screen overflow-hidden bg-gray-100 dark:bg-gray-900 transition-colors duration-200" | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| (function applyInitialTheme() { | ||
| try { | ||
| // Apply dark mode before React mounts to avoid a light-theme flash. | ||
| const appWindow = globalThis; | ||
| const savedMode = localStorage.getItem('darkMode'); | ||
| const prefersDark = | ||
| typeof appWindow.matchMedia === 'function' && | ||
| appWindow.matchMedia('(prefers-color-scheme: dark)').matches; | ||
| const shouldEnableDarkMode = savedMode === 'true' || (savedMode === null && prefersDark); | ||
|
|
||
| if (shouldEnableDarkMode) { | ||
| document.documentElement.classList.add('dark'); | ||
| return; | ||
| } | ||
|
|
||
| document.documentElement.classList.remove('dark'); | ||
| } catch (error) { | ||
| // Fall back to light mode if storage/media APIs are unavailable. | ||
| console.warn('Theme bootstrap failed', error); | ||
| document.documentElement.classList.remove('dark'); | ||
| } | ||
| })(); |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,35 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import path from 'node:path'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const realFs = jest.requireActual('node:fs') as typeof import('node:fs'); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const rendererIndexPath = path.resolve(__dirname, '../../../src/renderer/public/index.html'); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const readRendererIndex = () => realFs.readFileSync(rendererIndexPath, 'utf8'); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| describe('renderer CSP policy', () => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| it('defines a strict CSP policy without unsafe script/style exceptions', () => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const indexHtml = readRendererIndex(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const cspMatch = indexHtml.match( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| /<meta\s+http-equiv="Content-Security-Policy"\s+content="([^"]+)"/ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| expect(cspMatch).not.toBeNull(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const cspValue = cspMatch?.[1] ?? ''; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| expect(cspValue).toContain("default-src 'self'"); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| expect(cspValue).toContain("script-src 'self'"); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| expect(cspValue).toContain("style-src 'self'"); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| expect(cspValue).toContain("object-src 'none'"); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| expect(cspValue).toContain("base-uri 'none'"); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| expect(cspValue).not.toContain("'unsafe-inline'"); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| expect(cspValue).not.toContain("'unsafe-eval'"); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+9
to
+25
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This test is a great start for ensuring the CSP is enforced. To make it more robust and prevent accidental weakening of the policy in the future, consider using a snapshot test for the CSP value. This will ensure the entire policy is tracked and any changes must be explicitly reviewed and approved.
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| it('loads theme bootstrap from an external script instead of inline script tags', () => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const indexHtml = readRendererIndex(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| expect(indexHtml).toContain('<script src="./theme-bootstrap.js"></script>'); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const inlineScriptTags = [...indexHtml.matchAll(/<script(?![^>]*\bsrc=)[^>]*>/g)]; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| expect(inlineScriptTags).toHaveLength(0); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,69 @@ | ||
| const loadThemeBootstrapScript = () => { | ||
| jest.isolateModules(() => { | ||
| require('../../../src/renderer/public/theme-bootstrap.js'); | ||
| }); | ||
| }; | ||
|
|
||
| const createMatchMediaMock = (matches) => | ||
| jest.fn().mockImplementation(() => ({ | ||
| matches, | ||
| media: '(prefers-color-scheme: dark)', | ||
| onchange: null, | ||
| addEventListener: jest.fn(), | ||
| removeEventListener: jest.fn(), | ||
| addListener: jest.fn(), | ||
| removeListener: jest.fn(), | ||
| dispatchEvent: jest.fn(), | ||
| })); | ||
|
|
||
| describe('theme-bootstrap script', () => { | ||
| let addSpy; | ||
| let removeSpy; | ||
| let matchMediaSpy; | ||
|
|
||
| beforeEach(() => { | ||
| jest.resetModules(); | ||
| addSpy = jest.spyOn(document.documentElement.classList, 'add'); | ||
| removeSpy = jest.spyOn(document.documentElement.classList, 'remove'); | ||
| matchMediaSpy = jest.spyOn(window, 'matchMedia').mockImplementation(createMatchMediaMock(false)); | ||
| window.localStorage.removeItem('darkMode'); | ||
| }); | ||
|
|
||
| afterEach(() => { | ||
| addSpy.mockRestore(); | ||
| removeSpy.mockRestore(); | ||
| matchMediaSpy.mockRestore(); | ||
| jest.restoreAllMocks(); | ||
| document.documentElement.classList.remove('dark'); | ||
| }); | ||
|
|
||
| it('enables dark class when persisted setting is true', () => { | ||
| window.localStorage.setItem('darkMode', 'true'); | ||
|
|
||
| loadThemeBootstrapScript(); | ||
|
|
||
| expect(addSpy).toHaveBeenCalledWith('dark'); | ||
| expect(removeSpy).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('falls back to system preference when persisted value is absent', () => { | ||
| matchMediaSpy.mockImplementation(createMatchMediaMock(false)); | ||
|
|
||
| loadThemeBootstrapScript(); | ||
|
|
||
| expect(addSpy).not.toHaveBeenCalled(); | ||
| expect(removeSpy).toHaveBeenCalledWith('dark'); | ||
| }); | ||
|
|
||
| it('keeps light mode when storage access throws', () => { | ||
| const warnSpy = jest.spyOn(console, 'warn'); | ||
| jest.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { | ||
| throw new Error('storage unavailable'); | ||
| }); | ||
|
|
||
| loadThemeBootstrapScript(); | ||
|
|
||
| expect(removeSpy).toHaveBeenCalledWith('dark'); | ||
| expect(warnSpy).toHaveBeenCalled(); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
connect-srcdirective in the Content Security Policy is overly permissive, allowing connections overhttps:and, more critically,http:. This constitutes a medium-severity vulnerability (Insecure Communication) as allowinghttp:undermines HTTPS security, making the application vulnerable to man-in-the-middle attacks and data exfiltration. It's strongly recommended to restrict this directive to'self'and only specific, trusted domains, prioritizinghttps:for all external connections.