fix(desktop): keep server CORS headers so preflights cache (#47560)

This commit is contained in:
Luke Parker
2026-09-06 13:26:12 +10:00
committed by GitHub
parent f7d0f69e6c
commit 1cf576ab55
4 changed files with 74 additions and 19 deletions
@@ -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<string, string[]> = { "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"] })
})
})
@@ -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)
}
+1 -13
View File
@@ -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)
@@ -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 })
})
}