Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/dashboard-batches-behind-tls.md
Original file line number Diff line number Diff line change
@@ -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.
24 changes: 24 additions & 0 deletions e2e/tests/dashboard-read-batches.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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) =>
Expand Down
12 changes: 10 additions & 2 deletions packages/dashboard-start/src/implementation/batch-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,10 +78,18 @@ const indexTargets = <Id extends string, Groups extends HttpApiGroup.Constraint>

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

/**
Expand Down