diff --git a/src/auth/auth-metadata.ts b/src/auth/auth-metadata.ts index d06b28d..bf0903f 100644 --- a/src/auth/auth-metadata.ts +++ b/src/auth/auth-metadata.ts @@ -20,17 +20,25 @@ const corsHeaders = { * These should match the "issuer" field in the authorization servers' * OAuth metadata (RFC 8414). * @param resourceUrl - Optional explicit resource URL override. When provided, this URL is - * used instead of deriving it from the request. Use this when running - * behind a proxy that doesn't set standard forwarding headers. - * If not provided, the URL is automatically detected from proxy headers - * (X-Forwarded-Host, X-Forwarded-Proto, Forwarded) or falls back to req.url. + * used instead of deriving it from the request. Strongly recommended + * in production to avoid relying on request-derived origins. + * If not provided, the URL is derived from `req.url` (and from proxy + * headers only when `trustProxy` is `true`). + * @param trustProxy - Whether to trust X-Forwarded-Host / X-Forwarded-Proto / Forwarded + * headers when deriving the resource URL. Defaults to `false`. + * Only set this to `true` when the request has demonstrably traversed + * a trusted reverse proxy that strips/overwrites these headers, + * otherwise clients can spoof the published `resource` identifier + * (CWE-918 / origin spoofing). */ export function protectedResourceHandler({ authServerUrls, resourceUrl: explicitResourceUrl, + trustProxy = false, }: { authServerUrls: string[]; resourceUrl?: string; + trustProxy?: boolean; }) { return (req: Request) => { let resource: string; @@ -39,8 +47,8 @@ export function protectedResourceHandler({ // Use explicit override if provided resource = explicitResourceUrl; } else { - // Auto-detect from proxy headers or req.url - const publicUrl = getPublicUrl(req); + // Auto-detect from req.url (and proxy headers only if trustProxy is true) + const publicUrl = getPublicUrl(req, { trustProxy }); publicUrl.pathname = publicUrl.pathname .replace(/^\/\.well-known\/[^\/]+/, ""); @@ -110,4 +118,3 @@ export function metadataCorsOptionsRequestHandler() { }); }; } - diff --git a/src/auth/auth-wrapper.ts b/src/auth/auth-wrapper.ts index be7c096..9ecfa0d 100644 --- a/src/auth/auth-wrapper.ts +++ b/src/auth/auth-wrapper.ts @@ -24,24 +24,40 @@ export function withMcpAuth( resourceMetadataPath = "/.well-known/oauth-protected-resource", requiredScopes, resourceUrl, + trustProxy = false, }: { required?: boolean; resourceMetadataPath?: string; requiredScopes?: string[]; /** * Explicit resource URL override. When provided, this URL is used as the - * origin for constructing the resource_metadata URL. Use this when running - * behind a proxy that doesn't set standard forwarding headers, or when you - * need to specify a specific public URL. + * origin for constructing the resource_metadata URL. Strongly recommended + * for production deployments — relying on request-derived origins lets + * clients influence the URL advertised in WWW-Authenticate responses + * unless the deployment is behind a proxy that sanitizes forwarding + * headers. * - * If not provided, the origin is automatically detected from proxy headers - * (X-Forwarded-Host, X-Forwarded-Proto, Forwarded) or falls back to req.url. + * If not provided, the origin is derived from `req.url` (and from proxy + * headers only when `trustProxy` is `true`). */ resourceUrl?: string; + /** + * Whether to trust X-Forwarded-Host / X-Forwarded-Proto / Forwarded + * headers when deriving the origin used to build the + * `resource_metadata` URL in WWW-Authenticate responses. Defaults to + * `false`. + * + * Only set this to `true` when the request has demonstrably traversed + * a trusted reverse proxy that strips/overwrites these headers. + * Otherwise, an attacker can spoof the advertised metadata URL, + * potentially redirecting OAuth clients to attacker-controlled servers + * (CWE-918 / origin spoofing). Ignored when `resourceUrl` is set. + */ + trustProxy?: boolean; } = {} ) { return async (req: Request) => { - const origin = resourceUrl ?? getPublicOrigin(req); + const origin = resourceUrl ?? getPublicOrigin(req, { trustProxy }); const resourceMetadataUrl = `${origin}${resourceMetadataPath}`; const authHeader = req.headers.get("Authorization"); diff --git a/src/lib/url.ts b/src/lib/url.ts index c4ca247..8f082cb 100644 --- a/src/lib/url.ts +++ b/src/lib/url.ts @@ -1,38 +1,72 @@ /** - * Get the public-facing origin from a request, respecting proxy headers. + * Options controlling how the public-facing origin is derived from a request. + */ +export interface PublicOriginOptions { + /** + * Whether to trust proxy-supplied forwarding headers + * (X-Forwarded-Host, X-Forwarded-Proto, Forwarded). + * + * Defaults to `false`. When false, these headers are ignored and the origin + * is derived from `req.url`. This is the safe default: clients can otherwise + * spoof the public origin by sending these headers directly when the + * deployment is not behind a reverse proxy that strips/overwrites them. + * + * Set to `true` only when the request has demonstrably traversed a trusted + * reverse proxy that sanitizes these headers (e.g., a properly configured + * load balancer, Vercel, Cloudflare). + * + * SECURITY: Trusting proxy headers in deployments without such a proxy + * allows attackers to control the `resource_metadata` URL in + * WWW-Authenticate responses and the `resource` field of the OAuth + * Protected Resource Metadata document, potentially redirecting OAuth + * clients to attacker-controlled servers (CWE-918 / origin spoofing). + */ + trustProxy?: boolean; +} + +/** + * Get the public-facing origin from a request. * - * When running behind a reverse proxy (e.g., nginx, Vercel, Cloudflare), - * the `req.url` typically reflects the internal URL (e.g., http://localhost:3000). - * This function reconstructs the public-facing origin using standard proxy headers. + * By default (and unless `trustProxy: true` is explicitly passed), this + * function ignores X-Forwarded-* / Forwarded headers and derives the origin + * from `req.url`. This prevents clients from spoofing the public origin in + * deployments that are not behind a reverse proxy that sanitizes these + * headers. See {@link PublicOriginOptions.trustProxy}. * - * Header precedence: + * When `trustProxy` is `true`, header precedence is: * 1. X-Forwarded-Host + X-Forwarded-Proto (most common) * 2. Forwarded header (RFC 7239) * 3. Falls back to req.url origin * * @param req - The incoming request + * @param options - Origin-derivation options * @returns The public-facing origin (e.g., "https://example.org") */ -export function getPublicOrigin(req: Request): string { - const forwardedHost = req.headers.get("x-forwarded-host"); - const forwardedProto = req.headers.get("x-forwarded-proto"); +export function getPublicOrigin( + req: Request, + options: PublicOriginOptions = {} +): string { + if (options.trustProxy) { + const forwardedHost = req.headers.get("x-forwarded-host"); + const forwardedProto = req.headers.get("x-forwarded-proto"); - // If we have X-Forwarded-Host, construct origin from forwarded headers - if (forwardedHost) { - // X-Forwarded-Host can contain multiple comma-separated values; use the first (leftmost) - const host = forwardedHost.split(",")[0].trim(); - // X-Forwarded-Proto can also be comma-separated - const proto = forwardedProto?.split(",")[0].trim() || "https"; - return `${proto}://${host}`; - } + // If we have X-Forwarded-Host, construct origin from forwarded headers + if (forwardedHost) { + // X-Forwarded-Host can contain multiple comma-separated values; use the first (leftmost) + const host = forwardedHost.split(",")[0].trim(); + // X-Forwarded-Proto can also be comma-separated + const proto = forwardedProto?.split(",")[0].trim() || "https"; + return `${proto}://${host}`; + } - // Check RFC 7239 Forwarded header (less common but standardized) - const forwarded = req.headers.get("forwarded"); - if (forwarded) { - const parsed = parseForwardedHeader(forwarded); - if (parsed.host) { - const proto = parsed.proto || "https"; - return `${proto}://${parsed.host}`; + // Check RFC 7239 Forwarded header (less common but standardized) + const forwarded = req.headers.get("forwarded"); + if (forwarded) { + const parsed = parseForwardedHeader(forwarded); + if (parsed.host) { + const proto = parsed.proto || "https"; + return `${proto}://${parsed.host}`; + } } } @@ -41,14 +75,20 @@ export function getPublicOrigin(req: Request): string { } /** - * Get the public-facing URL from a request, respecting proxy headers. + * Get the public-facing URL from a request. + * + * See {@link getPublicOrigin} for the security semantics of `trustProxy`. * * @param req - The incoming request + * @param options - Origin-derivation options * @returns The public-facing URL with the correct origin */ -export function getPublicUrl(req: Request): URL { +export function getPublicUrl( + req: Request, + options: PublicOriginOptions = {} +): URL { const url = new URL(req.url); - const publicOrigin = getPublicOrigin(req); + const publicOrigin = getPublicOrigin(req, options); // Construct a new URL with the public origin but preserve pathname, search, and hash const result = new URL(url.pathname + url.search + url.hash, publicOrigin); diff --git a/tests/auth.test.ts b/tests/auth.test.ts index 86bd43e..612a3b5 100644 --- a/tests/auth.test.ts +++ b/tests/auth.test.ts @@ -47,9 +47,10 @@ describe("auth", () => { }); }); - describe("proxy header support", () => { + describe("proxy header support (trustProxy: true)", () => { const handler = protectedResourceHandler({ authServerUrls: ["https://auth-server.com"], + trustProxy: true, }); it("uses X-Forwarded-Host and X-Forwarded-Proto headers", async () => { @@ -118,6 +119,35 @@ describe("auth", () => { }); }); + describe("origin spoofing protection (default trustProxy=false)", () => { + const handler = protectedResourceHandler({ + authServerUrls: ["https://auth-server.com"], + }); + + it("ignores attacker-supplied X-Forwarded-Host by default", async () => { + const req = new Request("https://real-server.com/.well-known/oauth-protected-resource", { + headers: { + "X-Forwarded-Host": "attacker.example", + "X-Forwarded-Proto": "https", + }, + }); + const res = handler(req); + const json = await res.json(); + expect(json.resource).toBe("https://real-server.com"); + }); + + it("ignores attacker-supplied Forwarded header by default", async () => { + const req = new Request("https://real-server.com/.well-known/oauth-protected-resource", { + headers: { + "Forwarded": "host=attacker.example;proto=https", + }, + }); + const res = handler(req); + const json = await res.json(); + expect(json.resource).toBe("https://real-server.com"); + }); + }); + describe("explicit resourceUrl override", () => { it("uses explicit resourceUrl when provided", async () => { const handler = protectedResourceHandler({