From cf89630bd3f005035c8286bb58f9715a1d74a2eb Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Thu, 30 Jul 2026 14:34:25 +0000 Subject: [PATCH] fix(pwa): return the caller's own host in the CLI device-flow URLs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `logicsrc login --device` told users to open https://logicsrc-credentials-production.up.railway.app/cli/device even when they had reached the app on the real domain. /cli/device/code built verification_uri from `config.origin`, which is a single fixed value read from $PUBLIC_ORIGIN, so the response was wrong for every hostname except the one that variable happened to name. Derive the origin from the request instead: whatever host the CLI called is the host it gets sent back to. Express honours X-Forwarded-Proto/Host here because server.mjs sets `trust proxy` behind Railway's TLS terminator. Deliberately scoped to the two device-flow URLs. The WebAuthn expectedOrigin in passkey.mjs stays pinned to config.origin — validating a signature against a host the caller supplied would defeat the check. Note this fixes which URL is *printed*; the host still has to route to this service for the link to load. Co-Authored-By: Claude Opus 5 (1M context) --- apps/pwa/src/lib/origin.mjs | 30 ++++++++++++++++++++++ apps/pwa/src/routes/cli.mjs | 8 ++++-- apps/pwa/test/origin.test.mjs | 47 +++++++++++++++++++++++++++++++++++ 3 files changed, 83 insertions(+), 2 deletions(-) create mode 100644 apps/pwa/src/lib/origin.mjs create mode 100644 apps/pwa/test/origin.test.mjs diff --git a/apps/pwa/src/lib/origin.mjs b/apps/pwa/src/lib/origin.mjs new file mode 100644 index 0000000..5da4c1d --- /dev/null +++ b/apps/pwa/src/lib/origin.mjs @@ -0,0 +1,30 @@ +// Which origin to hand back to a caller. +// +// `config.origin` comes from $PUBLIC_ORIGIN and is a single fixed value, so any +// response that echoes it is wrong the moment the app is reachable on more than +// one hostname — that is how `logicsrc login` ended up printing a generated +// Railway hostname to users on the real domain. For URLs we hand back to the +// caller, derive the origin from the request instead: whatever host the client +// reached us on is the host it should be sent back to. +// +// Express honours X-Forwarded-Proto/X-Forwarded-Host here because server.mjs +// sets `trust proxy` behind Railway's TLS terminator. +// +// NOT for security decisions. The WebAuthn `expectedOrigin` in passkey.mjs must +// stay pinned to config.origin — validating a signature against a host the +// caller supplied would defeat the check. + +/** + * The origin this request arrived on (`https://logicsrc.com`), falling back to + * the configured origin when there is no Host header (HTTP/1.0, direct socket). + * + * @param {{ protocol?: string, get?: (h: string) => string | undefined, headers?: Record }} req + * @param {string} fallback - config.origin + * @returns {string} origin with no trailing slash + */ +export function requestOrigin(req, fallback) { + const host = req?.get?.("host") || req?.headers?.host; + if (!host) return String(fallback || "").replace(/\/+$/, ""); + const protocol = req?.protocol || "https"; + return `${protocol}://${host}`.replace(/\/+$/, ""); +} diff --git a/apps/pwa/src/routes/cli.mjs b/apps/pwa/src/routes/cli.mjs index 1445e85..f96e075 100644 --- a/apps/pwa/src/routes/cli.mjs +++ b/apps/pwa/src/routes/cli.mjs @@ -17,6 +17,7 @@ import { token, sha256 } from "../lib/crypto.mjs"; import { page, footer, appBar, esc } from "../lib/html.mjs"; import { requireAuth, csrfInput } from "../lib/session.mjs"; import { createApiKey, bearer, userForApiKey } from "../lib/apikey.mjs"; +import { requestOrigin } from "../lib/origin.mjs"; import { config } from "../config.mjs"; export const cliRouter = Router(); @@ -127,11 +128,14 @@ cliRouter.post("/cli/device/code", async (req, res) => { `INSERT INTO cli_device_codes (device_code_hash,user_code,name,status,created_at,expires_at) VALUES (?,?,?,'pending',?,?)`, [sha256(deviceCode), code, name, now, now + DEVICE_TTL_MS] ); + // Echo back the host the CLI actually called us on, not $PUBLIC_ORIGIN — the + // user is told to open this link, and it has to be a domain they can reach. + const origin = requestOrigin(req, config.origin); res.json({ device_code: deviceCode, user_code: code, - verification_uri: `${config.origin}/cli/device`, - verification_uri_complete: `${config.origin}/cli/device?user_code=${encodeURIComponent(code)}`, + verification_uri: `${origin}/cli/device`, + verification_uri_complete: `${origin}/cli/device?user_code=${encodeURIComponent(code)}`, expires_in: Math.floor(DEVICE_TTL_MS / 1000), interval: DEVICE_POLL_SECONDS }); diff --git a/apps/pwa/test/origin.test.mjs b/apps/pwa/test/origin.test.mjs new file mode 100644 index 0000000..fe2faaa --- /dev/null +++ b/apps/pwa/test/origin.test.mjs @@ -0,0 +1,47 @@ +// `logicsrc login --device` printed a generated Railway hostname to users on the +// real domain, because /cli/device/code echoed $PUBLIC_ORIGIN instead of the host +// the CLI had just called. These pin the replacement behaviour. +import assert from "node:assert/strict"; +import test from "node:test"; + +import { requestOrigin } from "../src/lib/origin.mjs"; + +/** A minimal stand-in for the Express request surface requestOrigin touches. */ +const req = (host, protocol = "https") => ({ + protocol, + headers: { host }, + get: (h) => (h.toLowerCase() === "host" ? host : undefined), +}); + +const FALLBACK = "https://logicsrc-credentials-production.up.railway.app"; + +test("uses the host the caller actually reached", () => { + assert.equal(requestOrigin(req("logicsrc.com"), FALLBACK), "https://logicsrc.com"); + // The same deployment answering on its Railway hostname still self-describes + // correctly — this is not a hardcode swap, it follows the request. + assert.equal( + requestOrigin(req("logicsrc-credentials-production.up.railway.app"), FALLBACK), + FALLBACK, + ); +}); + +test("keeps the forwarded protocol and any explicit port", () => { + assert.equal(requestOrigin(req("localhost:8080", "http"), FALLBACK), "http://localhost:8080"); +}); + +test("falls back to the configured origin when there is no Host header", () => { + assert.equal(requestOrigin({ protocol: "https" }, FALLBACK), FALLBACK); + assert.equal(requestOrigin({}, `${FALLBACK}/`), FALLBACK, "trailing slash is trimmed"); +}); + +test("reads the header directly when req.get is unavailable", () => { + // Some middleware stacks (and our own tests) pass a bare object. + assert.equal( + requestOrigin({ protocol: "https", headers: { host: "logicsrc.com" } }, FALLBACK), + "https://logicsrc.com", + ); +}); + +test("defaults to https when the request carries no protocol", () => { + assert.equal(requestOrigin({ headers: { host: "logicsrc.com" } }, FALLBACK), "https://logicsrc.com"); +});