From b475ecfc097f344ac1271cd77e9842b1a25b64b2 Mon Sep 17 00:00:00 2001 From: fre$h Date: Fri, 13 Mar 2026 07:30:35 +0000 Subject: [PATCH] fix: skip upgrade-insecure-requests CSP for external HTTP backends (#7714) Signed-off-by: fresh3nough Signed-off-by: fre --- ui/desktop/src/main.ts | 46 +------ ui/desktop/src/utils/__tests__/csp.test.ts | 135 +++++++++++++++++++++ ui/desktop/src/utils/csp.ts | 75 ++++++++++++ 3 files changed, 215 insertions(+), 41 deletions(-) create mode 100644 ui/desktop/src/utils/__tests__/csp.test.ts create mode 100644 ui/desktop/src/utils/csp.ts diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index 28c2ad67..359b3ba5 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -51,6 +51,7 @@ import { Client } from './api/client'; import { GooseApp } from './api'; import installExtension, { REACT_DEVELOPER_TOOLS } from 'electron-devtools-installer'; import { BLOCKED_PROTOCOLS, WEB_PROTOCOLS } from './utils/urlSecurity'; +import { buildCSP } from './utils/csp'; function shouldSetupUpdater(): boolean { // Setup updater if either the flag is enabled OR dev updates are enabled @@ -1778,51 +1779,14 @@ async function appMain() { } }); - const buildConnectSrc = (): string => { - const sources = [ - "'self'", - 'http://127.0.0.1:*', - 'https://127.0.0.1:*', - 'http://localhost:*', - 'https://localhost:*', - 'https://api.github.com', - 'https://github.com', - 'https://objects.githubusercontent.com', - ]; - - const settings = getSettings(); - if (settings.externalGoosed?.enabled && settings.externalGoosed.url) { - try { - const externalUrl = new URL(settings.externalGoosed.url); - sources.push(externalUrl.origin); - } catch { - console.warn('Invalid external goosed URL in settings, skipping CSP entry'); - } - } - - return sources.join(' '); - }; - - // Add CSP headers to all sessions + // Add CSP headers to all sessions — recomputed on every response so that + // changes to externalGoosed settings take effect without restarting the app. session.defaultSession.webRequest.onHeadersReceived((details, callback) => { + const currentSettings = getSettings(); callback({ responseHeaders: { ...details.responseHeaders, - 'Content-Security-Policy': - "default-src 'self';" + - "style-src 'self' 'unsafe-inline';" + - "script-src 'self' 'unsafe-inline';" + - "img-src 'self' data: https:;" + - `connect-src ${buildConnectSrc()};` + - "object-src 'none';" + - "frame-src 'self' https: http:;" + - "font-src 'self' data: https:;" + - "media-src 'self' mediastream:;" + - "form-action 'none';" + - "base-uri 'self';" + - "manifest-src 'self';" + - "worker-src 'self';" + - 'upgrade-insecure-requests;', + 'Content-Security-Policy': buildCSP(currentSettings.externalGoosed), }, }); }); diff --git a/ui/desktop/src/utils/__tests__/csp.test.ts b/ui/desktop/src/utils/__tests__/csp.test.ts new file mode 100644 index 00000000..a117845b --- /dev/null +++ b/ui/desktop/src/utils/__tests__/csp.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect } from 'vitest'; +import { buildConnectSrc, shouldUpgradeInsecureRequests, buildCSP } from '../csp'; +import type { ExternalGoosedConfig } from '../settings'; + +describe('buildConnectSrc', () => { + it('includes default sources when no external backend is configured', () => { + const result = buildConnectSrc(undefined); + expect(result).toContain("'self'"); + expect(result).toContain('http://127.0.0.1:*'); + }); + + it('includes external backend origin when enabled', () => { + const config: ExternalGoosedConfig = { + enabled: true, + url: 'http://dev.company.net:12604', + secret: 'test', + }; + const result = buildConnectSrc(config); + expect(result).toContain('http://dev.company.net:12604'); + }); + + it('does not include external origin when disabled', () => { + const config: ExternalGoosedConfig = { + enabled: false, + url: 'http://dev.company.net:12604', + secret: 'test', + }; + const result = buildConnectSrc(config); + expect(result).not.toContain('dev.company.net'); + }); + + it('handles invalid URLs gracefully', () => { + const config: ExternalGoosedConfig = { + enabled: true, + url: 'not-a-valid-url', + secret: 'test', + }; + const result = buildConnectSrc(config); + expect(result).toContain("'self'"); + expect(result).not.toContain('not-a-valid-url'); + }); +}); + +describe('shouldUpgradeInsecureRequests', () => { + it('returns true when no external backend is configured', () => { + expect(shouldUpgradeInsecureRequests(undefined)).toBe(true); + }); + + it('returns true when external backend is disabled', () => { + const config: ExternalGoosedConfig = { + enabled: false, + url: 'http://dev.company.net:12604', + secret: 'test', + }; + expect(shouldUpgradeInsecureRequests(config)).toBe(true); + }); + + it('returns false when external backend uses HTTP', () => { + const config: ExternalGoosedConfig = { + enabled: true, + url: 'http://dev.company.net:12604', + secret: 'test', + }; + expect(shouldUpgradeInsecureRequests(config)).toBe(false); + }); + + it('returns true when external backend uses HTTPS', () => { + const config: ExternalGoosedConfig = { + enabled: true, + url: 'https://dev.company.net:12604', + secret: 'test', + }; + expect(shouldUpgradeInsecureRequests(config)).toBe(true); + }); + + it('returns true for invalid URLs', () => { + const config: ExternalGoosedConfig = { + enabled: true, + url: 'not-a-url', + secret: 'test', + }; + expect(shouldUpgradeInsecureRequests(config)).toBe(true); + }); + + it('returns true when URL is empty', () => { + const config: ExternalGoosedConfig = { + enabled: true, + url: '', + secret: 'test', + }; + expect(shouldUpgradeInsecureRequests(config)).toBe(true); + }); +}); + +describe('buildCSP', () => { + it('includes upgrade-insecure-requests with no external backend', () => { + const csp = buildCSP(undefined); + expect(csp).toContain('upgrade-insecure-requests'); + }); + + it('includes upgrade-insecure-requests with HTTPS external backend', () => { + const config: ExternalGoosedConfig = { + enabled: true, + url: 'https://secure.company.net:12604', + secret: 'test', + }; + const csp = buildCSP(config); + expect(csp).toContain('upgrade-insecure-requests'); + expect(csp).toContain('https://secure.company.net:12604'); + }); + + it('excludes upgrade-insecure-requests with HTTP external backend', () => { + const config: ExternalGoosedConfig = { + enabled: true, + url: 'http://dev.company.net:12604', + secret: 'test', + }; + const csp = buildCSP(config); + expect(csp).not.toContain('upgrade-insecure-requests'); + expect(csp).toContain('http://dev.company.net:12604'); + }); + + it('always includes core directives', () => { + const config: ExternalGoosedConfig = { + enabled: true, + url: 'http://dev.company.net:12604', + secret: 'test', + }; + const csp = buildCSP(config); + expect(csp).toContain("default-src 'self'"); + expect(csp).toContain("script-src 'self' 'unsafe-inline'"); + expect(csp).toContain('connect-src'); + expect(csp).toContain("object-src 'none'"); + }); +}); diff --git a/ui/desktop/src/utils/csp.ts b/ui/desktop/src/utils/csp.ts new file mode 100644 index 00000000..69144dae --- /dev/null +++ b/ui/desktop/src/utils/csp.ts @@ -0,0 +1,75 @@ +import type { ExternalGoosedConfig } from './settings'; + +const DEFAULT_CONNECT_SOURCES = [ + "'self'", + 'http://127.0.0.1:*', + 'https://127.0.0.1:*', + 'http://localhost:*', + 'https://localhost:*', + 'https://api.github.com', + 'https://github.com', + 'https://objects.githubusercontent.com', +]; + +export function buildConnectSrc(externalGoosed?: ExternalGoosedConfig): string { + const sources = [...DEFAULT_CONNECT_SOURCES]; + + if (externalGoosed?.enabled && externalGoosed.url) { + try { + const externalUrl = new URL(externalGoosed.url); + sources.push(externalUrl.origin); + } catch { + console.warn('Invalid external goosed URL in settings, skipping CSP entry'); + } + } + + return sources.join(' '); +} + +/** + * Returns true when upgrade-insecure-requests should be included in the CSP. + * + * The directive is omitted when the user has configured an external backend + * that uses plain HTTP, because Chromium would silently rewrite those + * requests to HTTPS. The remote server typically does not speak TLS, so the + * upgraded requests fail with "Failed to fetch". + * + * Loopback addresses (127.0.0.1 / localhost) are exempt from the upgrade + * per the CSP spec, which is why the built-in local backend is unaffected. + */ +export function shouldUpgradeInsecureRequests(externalGoosed?: ExternalGoosedConfig): boolean { + if (!externalGoosed?.enabled || !externalGoosed.url) { + return true; + } + + try { + const parsed = new URL(externalGoosed.url); + return parsed.protocol !== 'http:'; + } catch { + return true; + } +} + +export function buildCSP(externalGoosed?: ExternalGoosedConfig): string { + const connectSrc = buildConnectSrc(externalGoosed); + const upgradeDirective = shouldUpgradeInsecureRequests(externalGoosed) + ? 'upgrade-insecure-requests;' + : ''; + + return ( + "default-src 'self';" + + "style-src 'self' 'unsafe-inline';" + + "script-src 'self' 'unsafe-inline';" + + "img-src 'self' data: https:;" + + `connect-src ${connectSrc};` + + "object-src 'none';" + + "frame-src 'self' https: http:;" + + "font-src 'self' data: https:;" + + "media-src 'self' mediastream:;" + + "form-action 'none';" + + "base-uri 'self';" + + "manifest-src 'self';" + + "worker-src 'self';" + + upgradeDirective + ); +}