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"; /**