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) <noreply@anthropic.com>
This commit is contained in:
@@ -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).
|
||||
|
||||
+157
@@ -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<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
|
||||
// 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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user