Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 14 additions & 7 deletions src/auth/auth-metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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\/[^\/]+/, "");
Expand Down Expand Up @@ -110,4 +118,3 @@ export function metadataCorsOptionsRequestHandler() {
});
};
}

28 changes: 22 additions & 6 deletions src/auth/auth-wrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
92 changes: 66 additions & 26 deletions src/lib/url.ts
Original file line number Diff line number Diff line change
@@ -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}`;
}
}
}

Expand All @@ -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);
Expand Down
32 changes: 31 additions & 1 deletion tests/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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({
Expand Down