security: allowlist DCR redirect_uri hosts (fixes consent-phishing / account takeover)
build / test (push) Successful in 41s
build / build (push) Successful in 14s

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) <noreply@anthropic.com>
This commit is contained in:
2026-07-07 09:33:24 -04:00
parent 0f6f006ea2
commit 1f6fd7acfe
2 changed files with 163 additions and 0 deletions
+6
View File
@@ -22,6 +22,12 @@ VALID_AUDIENCES=https://intervalsicu.farhoodlabs.com/mcp
# Comma-separated trusted origins for Better Auth. # Comma-separated trusted origins for Better Auth.
TRUSTED_ORIGINS=https://intervalsicu.farhoodlabs.com 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). # Logger level: debug | info (default info).
LOG_LEVEL=info LOG_LEVEL=info
# HTTP listen port (default 8080). # HTTP listen port (default 8080).
+157
View File
@@ -6,12 +6,162 @@
* (the OIDC `loginPage` target) with Google/Apple buttons, and a health check. * (the OIDC `loginPage` target) with Google/Apple buttons, and a health check.
*/ */
import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { Readable } from "node:stream";
import { toNodeHandler } from "better-auth/node"; import { toNodeHandler } from "better-auth/node";
import { auth } from "./auth.js"; import { auth } from "./auth.js";
const PORT = Number(process.env.PORT ?? 8080); const PORT = Number(process.env.PORT ?? 8080);
const handler = toNodeHandler(auth); 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<string> {
const baseHost = hostFromUrl(process.env.BETTER_AUTH_URL);
const hosts = new Set<string>();
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<Buffer> {
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 // Better Auth's oauth-provider redirects here (its loginPage) with the original
// signed authorize params in the query. Rather than serve an interactive HTML // 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 // 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"); url.searchParams.delete("prompt");
req.url = url.pathname + (url.searchParams.toString() ? `?${url.searchParams}` : ""); 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). // Everything else -> Better Auth (async handler).
void handler(req, res); void handler(req, res);
}); });