From 1cf576ab5582a6091eb281645bc3a9bb3473e0e7 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Sun, 6 Sep 2026 13:26:12 +1000 Subject: [PATCH] fix(desktop): keep server CORS headers so preflights cache (#47560) --- .../desktop/src/main/windows/headers.test.ts | 41 +++++++++++++++++++ packages/desktop/src/main/windows/headers.ts | 29 +++++++++++++ packages/desktop/src/main/windows/protocol.ts | 14 +------ packages/desktop/src/main/windows/security.ts | 9 ++-- 4 files changed, 74 insertions(+), 19 deletions(-) create mode 100644 packages/desktop/src/main/windows/headers.test.ts create mode 100644 packages/desktop/src/main/windows/headers.ts diff --git a/packages/desktop/src/main/windows/headers.test.ts b/packages/desktop/src/main/windows/headers.test.ts new file mode 100644 index 00000000000..b3289ce9201 --- /dev/null +++ b/packages/desktop/src/main/windows/headers.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, test } from "bun:test" +import { addRendererHeaders, documentPolicyHeader, jsCallStacksDocumentPolicy, upsertHeader } from "./headers" + +describe("renderer response headers", () => { + test("keeps the server's exact allow-headers list so Chromium can reuse the cached preflight", () => { + const headers = { + "access-control-allow-origin": ["oc://renderer"], + "access-control-allow-headers": ["authorization"], + "access-control-max-age": ["86400"], + } + addRendererHeaders(headers, { document: false }) + expect(headers).toEqual({ + "access-control-allow-origin": ["*"], + "access-control-allow-headers": ["authorization"], + "access-control-max-age": ["86400"], + }) + }) + + test("fills CORS headers for servers that send none, naming authorization explicitly", () => { + const headers: Record = { "content-type": ["application/json"] } + addRendererHeaders(headers, { document: false }) + expect(headers["Access-Control-Allow-Origin"]).toEqual(["*"]) + expect(headers["Access-Control-Allow-Headers"]).toEqual(["*, authorization"]) + expect(headers["Access-Control-Max-Age"]).toEqual(["7200"]) + }) + + test("adds the crash-report document policy only to renderer documents", () => { + const document = {} + addRendererHeaders(document, { document: true }) + expect(Reflect.get(document, documentPolicyHeader)).toEqual([jsCallStacksDocumentPolicy]) + const asset = {} + addRendererHeaders(asset, { document: false }) + expect(Object.keys(asset)).not.toContain(documentPolicyHeader) + }) + + test("upsert replaces a header regardless of key casing", () => { + const headers = { "X-Test": ["a"] } + upsertHeader(headers, "x-test", ["b"]) + expect(headers).toEqual({ "X-Test": ["b"] }) + }) +}) diff --git a/packages/desktop/src/main/windows/headers.ts b/packages/desktop/src/main/windows/headers.ts new file mode 100644 index 00000000000..8ef360121a0 --- /dev/null +++ b/packages/desktop/src/main/windows/headers.ts @@ -0,0 +1,29 @@ +export const documentPolicyHeader = "Document-Policy" +export const jsCallStacksDocumentPolicy = "include-js-call-stacks-in-crash-reports" + +// The renderer lives at oc://renderer, so every server it talks to is cross-origin. Servers that +// send no CORS headers get permissive ones here so the renderer can still reach them. +// +// A server's own `Access-Control-Allow-Headers` is kept as-is. Chromium reuses a cached preflight +// only when that header names `authorization` explicitly (`*` never covers it), so overwriting the +// server's exact list with `*` forced a fresh OPTIONS round trip in front of every API call. +export function addRendererHeaders(headers: object, options: { document: boolean }) { + upsertHeader(headers, "Access-Control-Allow-Origin", ["*"]) + if (!hasHeader(headers, "Access-Control-Allow-Headers")) { + upsertHeader(headers, "Access-Control-Allow-Headers", ["*, authorization"]) + } + if (!hasHeader(headers, "Access-Control-Max-Age")) { + // Chromium caps preflight cache lifetime at two hours; without the header it caches for 5s. + upsertHeader(headers, "Access-Control-Max-Age", ["7200"]) + } + if (options.document) upsertHeader(headers, documentPolicyHeader, [jsCallStacksDocumentPolicy]) +} + +export function hasHeader(headers: object, key: string) { + return Object.keys(headers).some((header) => header.toLowerCase() === key.toLowerCase()) +} + +export function upsertHeader(headers: object, key: string, value: string | string[]) { + const current = Object.keys(headers).find((header) => header.toLowerCase() === key.toLowerCase()) + Reflect.set(headers, current ?? key, value) +} diff --git a/packages/desktop/src/main/windows/protocol.ts b/packages/desktop/src/main/windows/protocol.ts index ec94d836964..4c965aead4e 100644 --- a/packages/desktop/src/main/windows/protocol.ts +++ b/packages/desktop/src/main/windows/protocol.ts @@ -4,11 +4,10 @@ import { pathToFileURL } from "node:url" import { Effect, Path } from "effect" import { scoped } from "../native/logging" import { DesktopPaths } from "../paths" +import { documentPolicyHeader, jsCallStacksDocumentPolicy } from "./headers" const rendererProtocol = "oc" const rendererHost = "renderer" -const documentPolicyHeader = "Document-Policy" -const jsCallStacksDocumentPolicy = "include-js-call-stacks-in-crash-reports" protocol.registerSchemesAsPrivileged([ { @@ -85,17 +84,6 @@ export function isRendererUrl(value?: string, html = false) { return url.origin === new URL(devUrl).origin } -export function addRendererHeaders(value: string, headers: object) { - upsertHeader(headers, "Access-Control-Allow-Origin", ["*"]) - upsertHeader(headers, "Access-Control-Allow-Headers", ["*"]) - if (isRendererUrl(value, true)) upsertHeader(headers, documentPolicyHeader, [jsCallStacksDocumentPolicy]) -} - -export function upsertHeader(headers: object, key: string, value: string | string[]) { - const current = Object.keys(headers).find((header) => header.toLowerCase() === key.toLowerCase()) - Reflect.set(headers, current ?? key, value) -} - function addDocumentPolicy(response: Response, file: string) { if (!file.toLowerCase().endsWith(".html")) return response const headers = new Headers(response.headers) diff --git a/packages/desktop/src/main/windows/security.ts b/packages/desktop/src/main/windows/security.ts index 52ba46a7e96..97b5e1cba39 100644 --- a/packages/desktop/src/main/windows/security.ts +++ b/packages/desktop/src/main/windows/security.ts @@ -1,5 +1,6 @@ import type { BrowserWindow } from "electron" -import { addRendererHeaders, isRendererUrl, upsertHeader } from "./protocol" +import { addRendererHeaders } from "./headers" +import { isRendererUrl } from "./protocol" const rendererPermissions = new Set(["clipboard-sanitized-write", "notifications"]) @@ -30,13 +31,9 @@ export function wireNavigationPolicy(win: BrowserWindow, openExternalURL: (url: } export function wireRendererHeaders(win: BrowserWindow) { - win.webContents.session.webRequest.onBeforeSendHeaders((details, callback) => { - upsertHeader(details.requestHeaders, "Access-Control-Allow-Origin", ["*"]) - callback({ requestHeaders: details.requestHeaders }) - }) win.webContents.session.webRequest.onHeadersReceived((details, callback) => { const responseHeaders = details.responseHeaders ?? {} - addRendererHeaders(details.url, responseHeaders) + addRendererHeaders(responseHeaders, { document: isRendererUrl(details.url, true) }) callback({ responseHeaders }) }) }