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.
|
# 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
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user