From 764eef4ad4f62e523d6f3c606440afbb40ec7ea8 Mon Sep 17 00:00:00 2001 From: Ben Davis <45952064+bmdavis419@users.noreply.github.com> Date: Fri, 9 Oct 2026 20:28:32 -0700 Subject: [PATCH] fix(dashboard): accept read batches behind an HTTPS proxy The dashboard sends reads that start together as one POST /api/dashboard/batch, and the batch route admitted it only when the page's Origin equalled the URL rebuilt from Host and X-Forwarded-Proto. Behind a proxy that ends TLS, such as the self-host image behind Caddy, Cloudflare Tunnel or Railway, that URL is http:// while the page is https://, so every batch got an empty 403 and the dashboard showed "Could not reach the server" on whichever panels it batched. The browser's Sec-Fetch-Site now decides when it is present: only same-origin passes, and any other value is refused even when Origin matches. Without the header, the exact Origin check still applies. Fixes #2199 Co-Authored-By: Claude Opus 5.5 --- .changeset/dashboard-batches-behind-tls.md | 9 +++++++ e2e/tests/dashboard-read-batches.spec.ts | 24 +++++++++++++++++++ .../src/implementation/batch-host.ts | 12 ++++++++-- 3 files changed, 43 insertions(+), 2 deletions(-) create mode 100644 .changeset/dashboard-batches-behind-tls.md diff --git a/.changeset/dashboard-batches-behind-tls.md b/.changeset/dashboard-batches-behind-tls.md new file mode 100644 index 0000000000..ff0ed2a201 --- /dev/null +++ b/.changeset/dashboard-batches-behind-tls.md @@ -0,0 +1,9 @@ +--- +"@executor-js/dashboard-start": patch +--- + +A self-hosted dashboard served through a proxy that ends TLS, such as Caddy, Cloudflare Tunnel or +Railway, loads its reads again instead of showing "Could not reach the server". A batch of reads is +accepted when the browser marks it `Sec-Fetch-Site: same-origin` and refused for any other value. A +request without that header must still name the URL the server sees as its `Origin`, so a browser +that sends no `Sec-Fetch-Site` is still refused behind such a proxy. diff --git a/e2e/tests/dashboard-read-batches.spec.ts b/e2e/tests/dashboard-read-batches.spec.ts index 1abc006502..6d9477be99 100644 --- a/e2e/tests/dashboard-read-batches.spec.ts +++ b/e2e/tests/dashboard-read-batches.spec.ts @@ -14,6 +14,7 @@ import { appsManifest } from "../support/apps-release.ts"; import { batchedReads, batchPath } from "../support/read-batches.ts"; import { Evidence } from "../support/evidence.ts"; import { Target } from "../support/platform.ts"; +import { targetHosts } from "../support/role-hosts.ts"; import { serverControl } from "../support/server-control.ts"; import { scenarios } from "../test-plan.ts"; @@ -232,6 +233,29 @@ layer(HostedLive, { excludeTestServices: true })("Dashboard read batches", (it) ); expect(crossSite.status).toBe(403); + // Behind a proxy that ends TLS, a server that gets no `X-Forwarded-Proto`, as on self-host, + // sees `http://`, and an `https://` page's `Origin` never matches it. The browser's + // `Sec-Fetch-Site` admits the page's own batch, and any other value is refused even when + // the request names this origin. + const own = new URL(targetHosts(yield* Target).browser); + const attested = (site: string, origin: string) => + api.request( + actors.member, + "POST", + batchPath, + { reads: [read("viewer", "get")] }, + { origin, "sec-fetch-site": site }, + ); + const proxied = yield* attested("same-origin", `https://${own.host}`); + expect(proxied.status).toBe(200); + expect(proxied.body).toMatchObject({ + id: 0, + status: 200, + text: expect.stringContaining(member.userId), + }); + for (const site of ["same-site", "cross-site", "none"]) + expect((yield* attested(site, own.origin)).status, site).toBe(403); + // A slow read does not hold back the others: each answer streams as it finishes. yield* browser.login(actors.owner); yield* browser.use("Open the organization's apps", (page) => diff --git a/packages/dashboard-start/src/implementation/batch-host.ts b/packages/dashboard-start/src/implementation/batch-host.ts index 74916b898e..7cba0b2310 100644 --- a/packages/dashboard-start/src/implementation/batch-host.ts +++ b/packages/dashboard-start/src/implementation/batch-host.ts @@ -78,10 +78,18 @@ const indexTargets = /** * Only this dashboard's own pages may send a batch. The body is JSON, which another site cannot - * send without a preflight, and the browser names the page's origin on every `POST`. + * send without a preflight. The browser compares the page's origin with the URL it sends the batch + * to and reports the result in `Sec-Fetch-Site`, which no page can set, so when it is present it + * decides. The URL this server sees is not enough on its own: behind a proxy that ends TLS it is + * `http://` while the page is `https://`, unless `X-Forwarded-Proto` reaches this server, which the + * self-host image's host never forwards. Browsers send no `Sec-Fetch-Site` to plain `http://` hosts + * other than loopback, and older browsers never send it; then the page's origin, which the browser + * names on every `POST`, must be the URL's. */ const sameOrigin = (request: HttpServerRequest.HttpServerRequest, url: URL) => - request.headers.origin === url.origin && + (request.headers["sec-fetch-site"] === undefined + ? request.headers.origin === url.origin + : request.headers["sec-fetch-site"] === "same-origin") && request.headers["content-type"]?.split(";")[0]?.trim() === "application/json"; /**