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
4 changes: 2 additions & 2 deletions app/auth/coinpay/callback/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { coinpayConfigured, signSession, SESSION_COOKIE, sessionCookieOptions } from "@/lib/session";
import { exchangeCode, fetchUserinfo } from "@/lib/oauth";
import { requestHost, resolveOrigin, redirectUriFor } from "@/lib/oauth-origin";
import { requestHosts, resolveOrigin, redirectUriFor } from "@/lib/oauth-origin";
import { upsertUser } from "@/lib/db";

export const runtime = "nodejs";
Expand Down Expand Up @@ -32,7 +32,7 @@ export async function GET(req: NextRequest) {

// Same derivation as the authorize step, so the redirect_uri matches byte for
// byte -- the callback arrives on whatever host that step named.
const origin = resolveOrigin(requestHost(req.headers), requestIsHttps(req));
const origin = resolveOrigin(requestHosts(req.headers), requestIsHttps(req));

try {
const tokens = await exchangeCode(code, verifier, redirectUriFor(origin));
Expand Down
4 changes: 2 additions & 2 deletions app/auth/login/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { coinpayConfigured } from "@/lib/session";
import { makePkce, authorizeUrl } from "@/lib/oauth";
import { requestHost, resolveOrigin, redirectUriFor } from "@/lib/oauth-origin";
import { requestHosts, resolveOrigin, redirectUriFor } from "@/lib/oauth-origin";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";
Expand All @@ -20,7 +20,7 @@ export async function GET(req: NextRequest) {
const secure = requestIsHttps(req);
// Send the IdP back to the host the user is actually on, so the host-only
// cp_pkce / cp_state cookies set below are still readable at the callback.
const origin = resolveOrigin(requestHost(req.headers), secure);
const origin = resolveOrigin(requestHosts(req.headers), secure);
const { verifier, challenge, state } = makePkce();
const res = NextResponse.redirect(authorizeUrl(challenge, state, redirectUriFor(origin)));
const opts = { httpOnly: true, sameSite: "lax" as const, secure, path: "/", maxAge: 600 };
Expand Down
34 changes: 24 additions & 10 deletions lib/oauth-origin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,23 @@ function normalizeHost(raw: string): string {
}

/**
* The host this request was addressed to. Prefers x-forwarded-host, which the
* platform edge sets to the client-facing name; `host` alone can be the
* internal upstream. Still untrusted -- resolveOrigin allowlists it.
* Candidate hosts for this request, best first.
*
* `host` leads because that is the client-facing name on our platform -- the
* www -> apex redirect in middleware.ts reads it and works on every domain the
* service answers on. Railway sets x-forwarded-host to the canonical service
* domain rather than the requested one, so preferring it silently sends every
* custom domain to the fallback origin. Other proxies do the reverse, so we
* keep it as a second candidate.
*
* Both are untrusted. resolveOrigin picks the first that is allowlisted, so the
* worst an attacker can do by forging one is select among hostnames we already
* serve and have already registered with the IdP.
*/
export function requestHost(headers: Headers): string {
return headers.get("x-forwarded-host") || headers.get("host") || "";
export function requestHosts(headers: Headers): string[] {
return [headers.get("host"), headers.get("x-forwarded-host")].filter(
(h): h is string => Boolean(h),
);
}

/**
Expand Down Expand Up @@ -72,14 +83,17 @@ export function allowedHosts(env: NodeJS.ProcessEnv = process.env): Set<string>
* authorize byte for byte.
*/
export function resolveOrigin(
hostHeader: string | null | undefined,
hostHeaders: string | string[] | null | undefined,
isHttps: boolean,
env: NodeJS.ProcessEnv = process.env,
): string {
const host = normalizeHost(hostHeader || "");
if (!host) return defaultOrigin(env);
if (!allowedHosts(env).has(host)) return defaultOrigin(env);
return `${isHttps ? "https" : "http"}://${host}`;
const candidates = Array.isArray(hostHeaders) ? hostHeaders : [hostHeaders || ""];
const allowed = allowedHosts(env);
for (const candidate of candidates) {
const host = normalizeHost(candidate || "");
if (host && allowed.has(host)) return `${isHttps ? "https" : "http"}://${host}`;
}
return defaultOrigin(env);
}

/**
Expand Down
31 changes: 25 additions & 6 deletions tests/oauth-origin.test.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import assert from "node:assert/strict";
import test from "node:test";

import { allowedHosts, defaultOrigin, redirectUriFor, requestHost, resolveOrigin } from "../lib/oauth-origin.ts";
import { allowedHosts, defaultOrigin, redirectUriFor, requestHosts, resolveOrigin } from "../lib/oauth-origin.ts";

const env = (over = {}) => ({ APP_BASE_URL: "https://moshcoding.com", ...over });

Expand Down Expand Up @@ -96,9 +96,28 @@ test("a malformed APP_BASE_URL does not throw or widen the allowlist", () => {
assert.equal(resolveOrigin("evil.example", true, e), "not a url");
});

test("requestHost prefers x-forwarded-host over host", () => {
const h = new Headers({ host: "internal.railway.internal", "x-forwarded-host": "pit.moshcode.sh" });
assert.equal(requestHost(h), "pit.moshcode.sh");
assert.equal(requestHost(new Headers({ host: "moshcoding.com" })), "moshcoding.com");
assert.equal(requestHost(new Headers()), "");
test("requestHosts puts the client-facing `host` header first", () => {
// Railway sets x-forwarded-host to the canonical service domain, not the
// requested one; preferring it sent every custom domain to the fallback.
const h = new Headers({ host: "pit.moshcode.sh", "x-forwarded-host": "moshcoding.up.railway.app" });
assert.deepEqual(requestHosts(h), ["pit.moshcode.sh", "moshcoding.up.railway.app"]);
assert.deepEqual(requestHosts(new Headers({ host: "moshcoding.com" })), ["moshcoding.com"]);
assert.deepEqual(requestHosts(new Headers()), []);
});

test("the real Railway header shape resolves to the requested host", () => {
const e = env({ OAUTH_ALLOWED_HOSTS: "pit.moshcode.sh" });
const h = new Headers({ host: "pit.moshcode.sh", "x-forwarded-host": "moshcoding.up.railway.app" });
assert.equal(resolveOrigin(requestHosts(h), true, e), "https://pit.moshcode.sh");
});

test("a later candidate is used when the first is not allowlisted", () => {
const e = env({ OAUTH_ALLOWED_HOSTS: "pit.moshcode.sh" });
assert.equal(resolveOrigin(["internal.upstream", "pit.moshcode.sh"], true, e), "https://pit.moshcode.sh");
});

test("no allowlisted candidate falls back", () => {
const e = env({ OAUTH_ALLOWED_HOSTS: "pit.moshcode.sh" });
assert.equal(resolveOrigin(["evil.example", "also-evil.example"], true, e), "https://moshcoding.com");
assert.equal(resolveOrigin([], true, e), "https://moshcoding.com");
});
Loading