Skip to content

Commit 24e73ff

Browse files
committed
fix: support X-Forwarded-* headers in proxy environments (#10928)
1 parent b4ef14a commit 24e73ff

File tree

3 files changed

+150
-1
lines changed

3 files changed

+150
-1
lines changed
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/**
2+
* Extract the origin from request headers and environment variables.
3+
*
4+
* This function prioritizes X-Forwarded-* headers when AUTH_TRUST_HOST or
5+
* platform-specific environment variables (VERCEL, CF_PAGES) are set.
6+
* https://authjs.dev/getting-started/deployment#auth_trust_host
7+
*
8+
* @param forwardedHost - The value of X-Forwarded-Host or Host header
9+
* @param protocol - The value of X-Forwarded-Proto header
10+
* @returns The detected origin URL string, or undefined to use the default
11+
*/
12+
export function detectOrigin(
13+
forwardedHost: string | undefined,
14+
protocol: string | undefined
15+
): string | undefined {
16+
// If AUTH_URL is set, always use it (highest priority)
17+
if (process.env.AUTH_URL) {
18+
return process.env.AUTH_URL
19+
}
20+
21+
// If we detect a trusted environment, use the forwarded headers
22+
if (
23+
process.env.VERCEL ||
24+
process.env.CF_PAGES ||
25+
process.env.AUTH_TRUST_HOST
26+
) {
27+
if (forwardedHost?.trim()) {
28+
const detectedProtocol = protocol === "http" ? "http" : "https"
29+
return `${detectedProtocol}://${forwardedHost}`
30+
}
31+
}
32+
33+
// Fall back to undefined (will use request URL as-is)
34+
return undefined
35+
}

packages/core/src/lib/utils/web.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import * as cookie from "../vendored/cookie.js"
22
import { UnknownAction } from "../../errors.js"
33
import { setLogger } from "./logger.js"
4+
import { detectOrigin } from "./detect-origin.js"
45

56
import type {
67
AuthAction,
@@ -37,6 +38,20 @@ export async function toInternalRequest(
3738

3839
const url = new URL(req.url)
3940

41+
// Detect origin from X-Forwarded-* headers when in a trusted environment
42+
const headers = Object.fromEntries(req.headers)
43+
const forwardedHost = headers["x-forwarded-host"] ?? headers.host
44+
const forwardedProto = headers["x-forwarded-proto"]
45+
const detectedOrigin = detectOrigin(forwardedHost, forwardedProto)
46+
47+
// Update URL with detected origin if available
48+
if (detectedOrigin) {
49+
const trustedUrl = new URL(detectedOrigin)
50+
url.protocol = trustedUrl.protocol
51+
url.host = trustedUrl.host
52+
url.port = trustedUrl.port
53+
}
54+
4055
const { action, providerId } = parseActionAndProviderId(
4156
url.pathname,
4257
config.basePath
@@ -47,7 +62,7 @@ export async function toInternalRequest(
4762
action,
4863
providerId,
4964
method: req.method,
50-
headers: Object.fromEntries(req.headers),
65+
headers,
5166
body: req.body ? await getBody(req) : undefined,
5267
cookies: parseCookie(req.headers.get("cookie") ?? "") ?? {},
5368
error: url.searchParams.get("error") ?? undefined,
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { afterEach, beforeEach, describe, expect, it } from "vitest"
2+
import { detectOrigin } from "../src/lib/utils/detect-origin.js"
3+
4+
describe("detectOrigin", () => {
5+
const originalEnv = process.env
6+
7+
beforeEach(() => {
8+
// Reset process.env before each test
9+
process.env = { ...originalEnv }
10+
delete process.env.AUTH_URL
11+
delete process.env.AUTH_TRUST_HOST
12+
delete process.env.VERCEL
13+
delete process.env.CF_PAGES
14+
})
15+
16+
afterEach(() => {
17+
process.env = originalEnv
18+
})
19+
20+
describe("AUTH_URL has highest priority", () => {
21+
it("should return AUTH_URL when set, ignoring forwarded headers", () => {
22+
process.env.AUTH_URL = "https://from-env.com"
23+
const result = detectOrigin("forwarded.com", "http")
24+
expect(result).toBe("https://from-env.com")
25+
})
26+
27+
it("should return AUTH_URL even when AUTH_TRUST_HOST is set", () => {
28+
process.env.AUTH_URL = "https://from-env.com"
29+
process.env.AUTH_TRUST_HOST = "true"
30+
const result = detectOrigin("forwarded.com", "https")
31+
expect(result).toBe("https://from-env.com")
32+
})
33+
})
34+
35+
describe("Trusted environments (AUTH_TRUST_HOST, VERCEL, CF_PAGES)", () => {
36+
it("should use forwarded headers when AUTH_TRUST_HOST is set", () => {
37+
process.env.AUTH_TRUST_HOST = "true"
38+
const result = detectOrigin("example.com", "https")
39+
expect(result).toBe("https://example.com")
40+
})
41+
42+
it("should use forwarded headers when VERCEL is set", () => {
43+
process.env.VERCEL = "1"
44+
const result = detectOrigin("vercel-app.com", "https")
45+
expect(result).toBe("https://vercel-app.com")
46+
})
47+
48+
it("should use forwarded headers when CF_PAGES is set", () => {
49+
process.env.CF_PAGES = "1"
50+
const result = detectOrigin("cloudflare.com", "https")
51+
expect(result).toBe("https://cloudflare.com")
52+
})
53+
54+
it("should default to https when protocol is not 'http'", () => {
55+
process.env.AUTH_TRUST_HOST = "true"
56+
const result = detectOrigin("example.com", undefined)
57+
expect(result).toBe("https://example.com")
58+
})
59+
60+
it("should use http when protocol is explicitly 'http'", () => {
61+
process.env.AUTH_TRUST_HOST = "true"
62+
const result = detectOrigin("example.com", "http")
63+
expect(result).toBe("http://example.com")
64+
})
65+
66+
it("should return undefined when no forwardedHost is provided in trusted environment", () => {
67+
process.env.AUTH_TRUST_HOST = "true"
68+
const result = detectOrigin(undefined, "https")
69+
expect(result).toBeUndefined()
70+
})
71+
})
72+
73+
describe("Untrusted environment (no environment variables)", () => {
74+
it("should return undefined when no trust indicators are set", () => {
75+
const result = detectOrigin("example.com", "https")
76+
expect(result).toBeUndefined()
77+
})
78+
79+
it("should return undefined even with forwarded headers", () => {
80+
const result = detectOrigin("malicious.com", "https")
81+
expect(result).toBeUndefined()
82+
})
83+
})
84+
85+
describe("Edge cases", () => {
86+
it("should return undefined for empty forwardedHost", () => {
87+
process.env.AUTH_TRUST_HOST = "true"
88+
const result = detectOrigin("", "https")
89+
// Empty string should be treated as invalid
90+
expect(result).toBeUndefined()
91+
})
92+
93+
it("should handle forwardedHost with port", () => {
94+
process.env.VERCEL = "1"
95+
const result = detectOrigin("example.com:3000", "http")
96+
expect(result).toBe("http://example.com:3000")
97+
})
98+
})
99+
})

0 commit comments

Comments
 (0)