From 1f6fd7acfe87c9fdf2fc03412bc33810b1c37c96 Mon Sep 17 00:00:00 2001 From: Chris Farhood Date: Tue, 7 Jul 2026 09:33:24 -0400 Subject: [PATCH] security: allowlist DCR redirect_uri hosts (fixes consent-phishing / account takeover) Unauthenticated DCR previously accepted any redirect_uri; combined with the auto-approving /consent handler an attacker could register a client pointing at their domain, phish a logged-in user, and silently receive an auth code for the victim's identity. Reject non-allowlisted redirect_uri hosts at registration (default: claude.ai, claude.com, and the BETTER_AUTH_URL/portal host). Co-Authored-By: Claude Opus 4.8 (1M context) --- .env.example | 6 ++ src/server.ts | 157 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 163 insertions(+) diff --git a/.env.example b/.env.example index e9f906b..79d04ec 100644 --- a/.env.example +++ b/.env.example @@ -22,6 +22,12 @@ VALID_AUDIENCES=https://intervalsicu.farhoodlabs.com/mcp # Comma-separated trusted origins for Better Auth. TRUSTED_ORIGINS=https://intervalsicu.farhoodlabs.com +# Comma-separated hostnames allowed as DCR client redirect_uri hosts. When unset, +# defaults to claude.ai, claude.com and the BETTER_AUTH_URL host (the portal). +# This is a security control: it blocks attacker-registered clients from pointing +# authorization codes at their own domain. Set only to override the defaults. +# ALLOWED_REDIRECT_HOSTS=claude.ai,claude.com,intervalsicu.farhoodlabs.com + # Logger level: debug | info (default info). LOG_LEVEL=info # HTTP listen port (default 8080). diff --git a/src/server.ts b/src/server.ts index caeb032..02e50da 100644 --- a/src/server.ts +++ b/src/server.ts @@ -6,12 +6,162 @@ * (the OIDC `loginPage` target) with Google/Apple buttons, and a health check. */ import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import { Readable } from "node:stream"; import { toNodeHandler } from "better-auth/node"; import { auth } from "./auth.js"; const PORT = Number(process.env.PORT ?? 8080); const handler = toNodeHandler(auth); +// --- Dynamic Client Registration (DCR) redirect_uri allowlist ------------------ +// +// SECURITY: DCR is unauthenticated (Claude must self-register before it has any +// credentials) and consent is auto-approved (`skipConsent` + the /consent route). +// Without this control, an attacker could register a client whose redirect_uri +// points at their own domain, phish a logged-in user with an /authorize link, and +// silently receive the auth code at their host -> mint a JWT with the victim's +// identity (full account takeover). We close that vector by requiring every +// registered redirect_uri to be an https URL (http only for dev loopback) whose +// host is on an allowlist, so an authorization code can only ever be delivered to +// a trusted host. DCR itself stays open; only the redirect target is constrained. +const REGISTER_PATH = "/api/auth/oauth2/register"; + +const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "::1"]); + +function normalizeHost(host: string): string { + // Strip IPv6 brackets and lowercase for exact, case-insensitive comparison. + return host.replace(/^\[/, "").replace(/\]$/, "").toLowerCase(); +} + +function isLoopbackHost(host: string): boolean { + const h = normalizeHost(host); + return LOOPBACK_HOSTS.has(h) || h.endsWith(".localhost"); +} + +function hostFromUrl(value: string | undefined): string | null { + if (!value) return null; + try { + return normalizeHost(new URL(value).hostname); + } catch { + return null; + } +} + +// Build the allowlist once at startup. `ALLOWED_REDIRECT_HOSTS` (comma-separated +// hostnames), when set, is authoritative. When unset, the default includes +// claude.ai, claude.com and the BETTER_AUTH_URL host (the portal's origin) so it +// works in production with no env change. Dev loopback hosts are added only when +// this server is itself running on loopback. +function buildAllowedRedirectHosts(): Set { + const baseHost = hostFromUrl(process.env.BETTER_AUTH_URL); + const hosts = new Set(); + const configured = process.env.ALLOWED_REDIRECT_HOSTS?.trim(); + if (configured) { + for (const h of configured.split(",").map((s) => normalizeHost(s.trim())).filter(Boolean)) { + hosts.add(h); + } + } else { + hosts.add("claude.ai"); + hosts.add("claude.com"); + if (baseHost) hosts.add(baseHost); + } + // Only trust loopback redirect targets when the auth server itself is loopback + // (i.e. local development), never in production. + if (baseHost && isLoopbackHost(baseHost)) { + for (const h of LOOPBACK_HOSTS) hosts.add(h); + } + return hosts; +} + +const ALLOWED_REDIRECT_HOSTS = buildAllowedRedirectHosts(); + +/** + * Validate a DCR request's `redirect_uris`. Returns `null` if every entry is an + * allowed https (or dev-loopback http) URL, otherwise an error description string. + */ +export function validateRedirectUris(value: unknown): string | null { + if (!Array.isArray(value) || value.length === 0) { + return "redirect_uris is required and must be a non-empty array."; + } + for (const uri of value) { + if (typeof uri !== "string") { + return "Each redirect_uri must be a string."; + } + let parsed: URL; + try { + parsed = new URL(uri); + } catch { + return `redirect_uri is not a valid absolute URL: ${uri}`; + } + const host = normalizeHost(parsed.hostname); + const httpsOk = parsed.protocol === "https:"; + const loopbackHttpOk = parsed.protocol === "http:" && isLoopbackHost(host); + if (!httpsOk && !loopbackHttpOk) { + return `redirect_uri must use https: ${uri}`; + } + if (!ALLOWED_REDIRECT_HOSTS.has(host)) { + return `redirect_uri host is not allowed: ${parsed.host}`; + } + } + return null; +} + +function sendJsonError(res: ServerResponse, status: number, error: string, description: string) { + res.writeHead(status, { "content-type": "application/json" }); + res.end(JSON.stringify({ error, error_description: description })); +} + +function readRequestBody(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + req.on("data", (chunk: Buffer) => chunks.push(chunk)); + req.on("end", () => resolve(Buffer.concat(chunks))); + req.on("error", reject); + }); +} + +// Intercept the (unauthenticated) DCR registration POST: buffer + parse the body, +// reject any non-allowlisted redirect_uri BEFORE it reaches Better Auth. Because +// buffering consumes the request stream, we replay the exact original bytes to +// Better Auth's node handler via a fresh Readable that carries over method/url/ +// headers/socket, so content-length still matches and the flow is unchanged. +async function handleRegister(req: IncomingMessage, res: ServerResponse) { + let raw: Buffer; + try { + raw = await readRequestBody(req); + } catch { + sendJsonError(res, 400, "invalid_client_metadata", "Could not read request body."); + return; + } + let parsed: unknown; + try { + parsed = raw.length ? JSON.parse(raw.toString("utf8")) : {}; + } catch { + sendJsonError(res, 400, "invalid_client_metadata", "Request body must be valid JSON."); + return; + } + const redirectUris = (parsed as { redirect_uris?: unknown } | null)?.redirect_uris; + const problem = validateRedirectUris(redirectUris); + if (problem) { + // eslint-disable-next-line no-console + console.warn(`[register] rejected DCR: ${problem}`); + sendJsonError(res, 400, "invalid_redirect_uri", problem); + return; + } + // Replay the buffered body to Better Auth. `Readable.from([raw])` emits the + // exact original bytes as a single chunk; copy over the fields better-auth's + // node adapter reads (headers, method, url, httpVersion*, socket). + const replay = Readable.from([raw]) as unknown as IncomingMessage; + replay.headers = req.headers; + replay.method = req.method; + replay.url = req.url; + replay.httpVersion = req.httpVersion; + replay.httpVersionMajor = req.httpVersionMajor; + replay.httpVersionMinor = req.httpVersionMinor; + replay.socket = req.socket; + void handler(replay, res); +} + // Better Auth's oauth-provider redirects here (its loginPage) with the original // signed authorize params in the query. Rather than serve an interactive HTML // page (Claude's OAuth window doesn't run our JS), initiate Google sign-in @@ -113,6 +263,13 @@ const server = createServer((req, res) => { url.searchParams.delete("prompt"); req.url = url.pathname + (url.searchParams.toString() ? `?${url.searchParams}` : ""); } + // Guard unauthenticated DCR: reject any non-allowlisted redirect_uri before + // Better Auth registers the client. This closes the consent-phishing chain — + // an attacker can't register a client that redirects codes to their domain. + if (url.pathname === REGISTER_PATH && req.method === "POST") { + void handleRegister(req, res); + return; + } // Everything else -> Better Auth (async handler). void handler(req, res); });