fix: skip upgrade-insecure-requests CSP for external HTTP backends (#7714)
Signed-off-by: fresh3nough <fresh3nough@users.noreply.github.com> Signed-off-by: fre <anonwurcod@proton.me>
This commit is contained in:
+5
-41
@@ -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),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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'");
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user