Skip to content

Commit f94425f

Browse files
committed
SSR origin honors X-Forwarded-Proto/-Host behind a TLS-terminating proxy
#991 seeds the API client + connect-card MCP URL from the document request origin (request.url). Behind a proxy that terminates TLS and forwards plain HTTP upstream — tailscale serve for the dev-share workflow, or an nginx/Caddy front — request.url is http:// while the browser is on https://, so the HTTPS page got an http:// API base and blocked it as mixed content. Derive the origin from X-Forwarded-Proto/ -Host when present (extracted to a pure, unit-tested request-origin module). No-op on Cloudflare, where request.url already carries the external https origin.
1 parent d73368a commit f94425f

3 files changed

Lines changed: 87 additions & 1 deletion

File tree

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { describe, expect, it } from "@effect/vitest";
2+
3+
import { browserOriginFromRequest } from "./request-origin";
4+
5+
const req = (url: string, headers: Record<string, string> = {}): Request =>
6+
new Request(url, { headers });
7+
8+
describe("browserOriginFromRequest", () => {
9+
it("uses the request URL origin when no proxy headers are present (Cloudflare)", () => {
10+
expect(browserOriginFromRequest(req("https://executor.sh/acme/policies"))).toBe(
11+
"https://executor.sh",
12+
);
13+
});
14+
15+
it("honors X-Forwarded-Proto to recover https behind a TLS-terminating proxy", () => {
16+
// tailscale serve / nginx: browser on https, upstream request is http.
17+
expect(
18+
browserOriginFromRequest(
19+
req("http://mac-mini.tail5665af.ts.net:47130/acme/policies", {
20+
"x-forwarded-proto": "https",
21+
}),
22+
),
23+
).toBe("https://mac-mini.tail5665af.ts.net:47130");
24+
});
25+
26+
it("honors X-Forwarded-Host when the proxy rewrites the host", () => {
27+
expect(
28+
browserOriginFromRequest(
29+
req("http://127.0.0.1:8080/", {
30+
"x-forwarded-proto": "https",
31+
"x-forwarded-host": "executor.sh",
32+
}),
33+
),
34+
).toBe("https://executor.sh");
35+
});
36+
37+
it("takes the first value of a comma-listed forwarded chain", () => {
38+
expect(
39+
browserOriginFromRequest(
40+
req("http://internal:8080/", {
41+
"x-forwarded-proto": "https, http",
42+
"x-forwarded-host": "executor.sh, internal",
43+
}),
44+
),
45+
).toBe("https://executor.sh");
46+
});
47+
48+
it("falls back to the request scheme/host for the half-set header", () => {
49+
// Only proto forwarded: keep the request's own host.
50+
expect(
51+
browserOriginFromRequest(req("http://localhost:43130/", { "x-forwarded-proto": "https" })),
52+
).toBe("https://localhost:43130");
53+
// Only host forwarded: keep the request's own scheme.
54+
expect(
55+
browserOriginFromRequest(
56+
req("http://localhost:43130/", { "x-forwarded-host": "executor.sh" }),
57+
),
58+
).toBe("http://executor.sh");
59+
});
60+
});
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
// The origin the BROWSER sees for a document request — what origin-derived UI
2+
// (the connect card's MCP URL, the API client's base) must be built from.
3+
//
4+
// On Cloudflare `request.url` already carries the external `https://` origin,
5+
// so this is a no-op there. Behind a TLS-terminating reverse proxy that
6+
// forwards plain HTTP upstream — `tailscale serve` for the dev-share workflow,
7+
// or an nginx/Caddy front — `request.url` is `http://` while the browser is on
8+
// `https://`; honoring the proxy's `X-Forwarded-Proto`/`-Host` recovers the
9+
// real origin (otherwise the HTTPS page would seed an `http://` API base and
10+
// the browser blocks it as mixed content).
11+
//
12+
// Trusting these headers is safe: the result only shapes the origin rendered
13+
// back to that same requester, never a cross-tenant decision (org access is
14+
// enforced server-side regardless of the URL the client was handed).
15+
//
16+
// Pure (no `cloudflare:workers`) so it unit-tests without the worker runtime.
17+
export const browserOriginFromRequest = (request: Request): string => {
18+
const url = new URL(request.url);
19+
const proto = request.headers.get("x-forwarded-proto")?.split(",")[0]?.trim();
20+
const host = request.headers.get("x-forwarded-host")?.split(",")[0]?.trim();
21+
if (!proto && !host) return url.origin;
22+
return `${proto || url.protocol.replace(":", "")}://${host || url.host}`;
23+
};

apps/cloud/src/auth/ssr-gate.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import { makeDbLayer } from "../db/db";
3636
import { makeUserStoreLayer, UserStoreService } from "./context";
3737
import { parseCookie } from "./cookies";
3838
import { sealedSessionDisplayName } from "./middleware";
39+
import { browserOriginFromRequest } from "./request-origin";
3940
import { loginPath, safeReturnTo } from "./return-to";
4041
import { ONBOARDING_PATHS, PUBLIC_PATHS } from "./route-paths";
4142
import { WorkOSClient } from "./workos";
@@ -239,7 +240,9 @@ export const authGateMiddleware = createMiddleware({ type: "request" }).server(
239240
// `http://127.0.0.1:4000` default — which would otherwise flash until
240241
// hydration corrected it. Set-cookie writes ride on the rendered response.
241242
const { hint, mint } = await resolveAuthHint(session, cookieHeader);
242-
const result = await next({ context: { authHint: hint, origin: url.origin } });
243+
const result = await next({
244+
context: { authHint: hint, origin: browserOriginFromRequest(request) },
245+
});
243246
if (!mint && !session.refreshedSession) return result;
244247

245248
const response = new Response(result.response.body, result.response);

0 commit comments

Comments
 (0)