Skip to content
Merged
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
30 changes: 30 additions & 0 deletions apps/pwa/src/lib/origin.mjs
Original file line number Diff line number Diff line change
@@ -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<string, unknown> }} 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(/\/+$/, "");
}
8 changes: 6 additions & 2 deletions apps/pwa/src/routes/cli.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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
});
Expand Down
47 changes: 47 additions & 0 deletions apps/pwa/test/origin.test.mjs
Original file line number Diff line number Diff line change
@@ -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");
});
Loading