From add059fe5e1c9c29e8847c595b22f3ba64bac6f7 Mon Sep 17 00:00:00 2001 From: Hernan Alvarado Date: Fri, 11 Sep 2026 17:11:31 -0500 Subject: [PATCH 1/3] feat(core): add experimental trusted proxy headers support --- packages/core/CHANGELOG.md | 4 ++ packages/core/src/@types/config.ts | 19 +++++- packages/core/src/@types/internal.ts | 3 +- packages/core/src/router/context.ts | 16 ++++- packages/core/src/shared/assert.ts | 24 +++++++ packages/core/src/shared/errors.ts | 10 +++ packages/core/src/shared/utils.ts | 65 +++++++++++++++++-- .../core/src/shared/utils/authorization.ts | 22 ++++--- 8 files changed, 144 insertions(+), 19 deletions(-) diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 7a60e8f2..b54f8f9b 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -8,6 +8,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +### Added + +- Added experimental support for configuring trusted proxy header sources used to construct request URLs. Header sources can provide the complete URL through `{ url: "header" }` or specify separate protocol and host headers through `{ protocol: "header-proto", host: "header-host" }`. The configuration also includes built-in sources such as `forwarded`, `forwarded.proto`, and `forwarded.host`. [#275](https://github.com/aura-stack-ts/auth/pull/275) + ### Changed - Upgraded `@aura-stack/router` to `v0.11.4`, adding response schema validation and adopting the built-in `handle()` function for processing incoming requests. This release also updates unsupported HTTP method handling to return a standardized `404 Not Found` response instead of `405 Method Not Allowed`. [#274](https://github.com/aura-stack-ts/auth/pull/274) diff --git a/packages/core/src/@types/config.ts b/packages/core/src/@types/config.ts index 35f0169b..33930471 100644 --- a/packages/core/src/@types/config.ts +++ b/packages/core/src/@types/config.ts @@ -15,6 +15,7 @@ import type { Awaitable, ZodIdentitySchema, EditableShape, + LiteralUnion, } from "@/@types/index.ts" import type { ZodObject } from "zod" import type { SerializeOptions } from "@aura-stack/router/cookie" @@ -212,7 +213,7 @@ export type TrustedProxyHeadersConfig = * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Forwarded * @experimental */ - trustedProxyHeaders: true + trustedProxyHeaders: true | TrustedProxyHeaderSource[] /** * Defines trusted origins for your application to prevent open redirect attacks. * URLs from the Referer header, Origin header, request URL, and redirectTo option @@ -489,3 +490,19 @@ export type RateLimiterConfig = Partial< > >["rules"] > + +/** + * Defines the source of trusted proxy headers to construct the incoming request's origin. + * It can be a URL string or an object with protocol and host properties. + * + * @example + * { url: "x-custom-header" } + * // or + * { protocol: "x-forwarded-proto", host: "x-forwarded-host" } + */ +export type TrustedProxyHeaderSource = + | { url: LiteralUnion<"forwarded"> } + | { + protocol: LiteralUnion<"forwarded.proto" | "x-forwarded-proto"> + host: LiteralUnion<"host" | "forwarded.host" | "x-forwarded-host"> + } diff --git a/packages/core/src/@types/internal.ts b/packages/core/src/@types/internal.ts index 171543b6..5def97cc 100644 --- a/packages/core/src/@types/internal.ts +++ b/packages/core/src/@types/internal.ts @@ -21,6 +21,7 @@ import type { RateLimiterConfig, SignUpConfig, TrustedOrigin, + TrustedProxyHeaderSource, } from "@/@types/config.ts" import type { JWTKey, @@ -80,7 +81,7 @@ export interface RouterGlobalContext Promise | TrustedOrigin[]) logger?: InternalLogger sessionStrategy: SessionStrategy diff --git a/packages/core/src/router/context.ts b/packages/core/src/router/context.ts index f8686b2b..67f4738d 100644 --- a/packages/core/src/router/context.ts +++ b/packages/core/src/router/context.ts @@ -2,7 +2,7 @@ import { createJoseInstance } from "@/jose.ts" import { createCookieStore } from "@/cookie.ts" import { AuraAuthError } from "@/shared/errors.ts" import { createProxyLogger } from "@/shared/logger.ts" -import { isStatelessStrategy } from "@/shared/assert.ts" +import { isBoolean, isStatelessStrategy, isTrustedProxyHeaderSource } from "@/shared/assert.ts" import { createSessionStrategy } from "@/session/strategy.ts" import { createJoseManager } from "@/session/jose-manager.ts" import { createSchemaRegistry } from "@/validator/registry.ts" @@ -16,9 +16,19 @@ import type { InternalContext } from "@/@types/internal.ts" export const createContext = ( config?: AuthConfig ) => { + const { trustedProxyHeaders } = config ?? {} const trustedProxyHeadersEnv = getEnv("TRUSTED_PROXY_HEADERS") + /** + * @todo clean up the logic here, it's a bit messy + */ const useProxyHeaders = - trustedProxyHeadersEnv === undefined ? (config?.trustedProxyHeaders ?? false) : getEnvBoolean("TRUSTED_PROXY_HEADERS") + trustedProxyHeadersEnv === undefined + ? isBoolean(trustedProxyHeaders) + ? trustedProxyHeaders + : isTrustedProxyHeaderSource(trustedProxyHeaders) && trustedProxyHeaders.length > 0 + ? trustedProxyHeaders + : false + : getEnvBoolean("TRUSTED_PROXY_HEADERS") const envTrustedOrigins = getEnvArray("TRUSTED_ORIGINS") const resolvedTrustedOrigins = envTrustedOrigins.length > 0 ? envTrustedOrigins : config?.trustedOrigins const logger = createProxyLogger(config) @@ -63,7 +73,7 @@ export const createContext = ctx.sessionStrategy = createSessionStrategy({ diff --git a/packages/core/src/shared/assert.ts b/packages/core/src/shared/assert.ts index 69d1a94a..f2e7fb69 100644 --- a/packages/core/src/shared/assert.ts +++ b/packages/core/src/shared/assert.ts @@ -16,6 +16,7 @@ import type { SessionConfig, StatefulStrategyConfig, StatelessStrategyConfig, + TrustedProxyHeaderSource, } from "@/@types/index.ts" export const isFalsy = (value: unknown): boolean => { @@ -275,3 +276,26 @@ export const isInvalidSlidingThreshold = (value: unknown): value is number => { export const isHeadersInit = (value: unknown): value is HeadersInit => { return typeof value === "object" && value !== null && (value instanceof Headers || Array.isArray(value) || isObject(value)) } + +export const isTrustedProxyHeaderSource = (value: unknown): value is TrustedProxyHeaderSource => { + return isTrustedProxyHeaderSourceURL(value) || isTrustedProxyHeaderSourceProtocolHost(value) +} + +export const isTrustedProxyHeaderSourceURL = (value: unknown): value is { url: string } => { + return typeof value === "object" && value !== null && "url" in value +} + +export const isTrustedProxyHeaderSourceProtocolHost = (value: unknown): value is { protocol: string; host: string } => { + return ( + typeof value === "object" && + value !== null && + "protocol" in value && + "host" in value && + typeof (value as any).protocol === "string" && + typeof (value as any).host === "string" + ) +} + +export const isBoolean = (value: unknown): value is boolean => { + return typeof value === "boolean" +} diff --git a/packages/core/src/shared/errors.ts b/packages/core/src/shared/errors.ts index a0ed6e69..45b1e75d 100644 --- a/packages/core/src/shared/errors.ts +++ b/packages/core/src/shared/errors.ts @@ -139,6 +139,7 @@ export const AuraErrorCode = { */ INVALID_SLIDING_THRESHOLD_CONFIG_VALUE: "INVALID_SLIDING_THRESHOLD_CONFIG_VALUE", INVALID_CSRF_TOKEN: "INVALID_CSRF_TOKEN", + INVALID_CUSTOM_TRUSTED_PROXY_HEADERS_CONFIG: "INVALID_CUSTOM_TRUSTED_PROXY_HEADERS_CONFIG", } as const export type AuraErrorCode = (typeof AuraErrorCode)[keyof typeof AuraErrorCode] @@ -958,6 +959,15 @@ export const ERROR_CATALOG: Record = { "CSRF security verification failed. The provided anti-CSRF token does not match the token embedded in the secure session cookie context or failed cryptographic validation.", userMessage: "Security verification failed. Invalid or missing CSRF token.", }, + INVALID_CUSTOM_TRUSTED_PROXY_HEADERS_CONFIG: { + type: "AUTH_FLOW", + statusCode: 500, + name: "ConfigError", + message: + "Initialization aborted: The custom 'trustedProxyHeaders' configuration is insecure or invalid. Allowing arbitrary or untrusted headers to construct the absolute application URL exposes the server to Host Header Injection and request forgery vulnerabilities.", + userMessage: + "Internal library configuration error. The custom trusted proxy headers configuration allows unsafe URL construction.", + }, } export interface AuraErrorOptions extends ErrorOptions { diff --git a/packages/core/src/shared/utils.ts b/packages/core/src/shared/utils.ts index 47a6f1d5..03033287 100644 --- a/packages/core/src/shared/utils.ts +++ b/packages/core/src/shared/utils.ts @@ -3,9 +3,16 @@ import { getCookie } from "@/cookie.ts" import { createHash } from "@/shared/crypto.ts" import { encoder } from "@aura-stack/jose/crypto" import { AuraAuthError } from "@/shared/errors.ts" -import { isRelativeURL, isString, isValidURL } from "@/shared/assert.ts" +import { + isBoolean, + isRelativeURL, + isString, + isTrustedProxyHeaderSource, + isTrustedProxyHeaderSourceURL, + isValidURL, +} from "@/shared/assert.ts" import type { DeviceType } from "@/@types/entities.ts" -import type { OAuthTokenPayload } from "@/@types/index.ts" +import type { AuthConfig, OAuthTokenPayload, TrustedProxyHeaderSource } from "@/@types/index.ts" import type { InternalCookieStoreConfig, InternalLogger, @@ -26,14 +33,21 @@ export const getBaseURL = (request: Request) => { return `${url.origin}${url.pathname}` } -export const isSecureConnection = (request: Request | Headers, trustedProxyHeaders: boolean): boolean => { +export const isSecureConnection = ( + request: Request | Headers, + trustedProxyHeaders: AuthConfig["trustedProxyHeaders"] +): boolean => { const headers = request instanceof Headers ? request : request.headers const url = request instanceof Headers ? null : request.url - return trustedProxyHeaders - ? url?.startsWith("https://") || + return isBoolean(trustedProxyHeaders) + ? trustedProxyHeaders + ? url?.startsWith("httpss://") || headers.get("X-Forwarded-Proto") === "https" || (headers.get("Forwarded")?.includes("proto=https") ?? false) - : (url?.startsWith("https://") ?? false) + : (url?.startsWith("https://") ?? false) + : isTrustedProxyHeaderSource(trustedProxyHeaders) + ? getBaseURLFromProxyHeaders(headers, trustedProxyHeaders).startsWith("https://") + : false } export const extractPath = (url: string): string => { @@ -271,3 +285,42 @@ export const getDeviceInfo = (request: Request) => { deviceType: getDeviceType(userAgent, secChUaMobile), } } + +export const getProtoFromForwarded = (headers: Headers) => { + return headers?.get("Forwarded")?.match(/proto=([^;]+)/i)?.[1] +} + +export const getHostFromForwarded = (headers: Headers) => { + return headers?.get("Forwarded")?.match(/host=([^;]+)/i)?.[1] +} + +/** + * Extracts the base URL from a set of trusted proxy headers. + * + * @param headers - The request headers. + * @param proxyHeaders - The configured trusted proxy headers. + * @returns The base URL derived from the proxy headers. + */ +export const getBaseURLFromProxyHeaders = (headers: Headers, proxyHeaders: TrustedProxyHeaderSource[]): string => { + let baseURL = "" + proxyHeaders.find((config) => { + try { + if (isTrustedProxyHeaderSourceURL(config)) { + const url = + config.url === "forwarded" + ? `${getProtoFromForwarded(headers)}://${getHostFromForwarded(headers)}` + : config.url + return (baseURL = new URL(url).origin) + } else { + const protocol = + config.protocol === "forwarded.proto" ? getProtoFromForwarded(headers) : headers.get(config.protocol)! + const host = config.host === "forwarded.host" ? getHostFromForwarded(headers) : headers.get(config.host)! + return (baseURL = new URL(`${protocol}://${host}`).origin) + } + } catch { + return false + } + }) + if (!baseURL) throw new AuraAuthError({ code: "INVALID_CUSTOM_TRUSTED_PROXY_HEADERS_CONFIG" }) + return baseURL +} diff --git a/packages/core/src/shared/utils/authorization.ts b/packages/core/src/shared/utils/authorization.ts index f263b8fe..55064866 100644 --- a/packages/core/src/shared/utils/authorization.ts +++ b/packages/core/src/shared/utils/authorization.ts @@ -2,7 +2,14 @@ import { getEnv } from "@/shared/env.ts" import { OAuthAuthorization } from "@/shared/schemas/general.ts" import { AuraAuthError } from "@/shared/errors.ts" import { createPKCE, createSecretValue } from "@/shared/crypto.ts" -import { equals, extractPath, patternToRegex } from "@/shared/utils.ts" +import { + equals, + extractPath, + getBaseURLFromProxyHeaders, + getHostFromForwarded, + getProtoFromForwarded, + patternToRegex, +} from "@/shared/utils.ts" import { isRelativeURL, isSameOrigin, isValidURL, isTrustedOrigin } from "@/shared/assert.ts" import type { GlobalContext } from "@aura-stack/router" import type { AuthConfig, OAuthProvider } from "@/@types/index.ts" @@ -32,13 +39,12 @@ export const getBaseURL = async ({ const origin = getEnv("BASE_URL") || ctx?.baseURL if (origin && origin !== "/") return origin if (ctx?.trustedProxyHeaders) { - const headers = (headersInit && new Headers(headersInit)) || request?.headers - const protocol = headers?.get("Forwarded")?.match(/proto=([^;]+)/i)?.[1] ?? headers?.get("X-Forwarded-Proto") ?? "http" - const host = - headers?.get("Host") ?? - headers?.get("Forwarded")?.match(/host=([^;]+)/i)?.[1] ?? - headers?.get("X-Forwarded-Host") ?? - null + const headers = new Headers(headersInit || request?.headers) + if (Array.isArray(ctx.trustedProxyHeaders)) { + return getBaseURLFromProxyHeaders(headers, ctx.trustedProxyHeaders) + } + const protocol = getProtoFromForwarded(headers) ?? headers?.get("X-Forwarded-Proto") ?? "http" + const host = headers?.get("Host") ?? getHostFromForwarded(headers) ?? headers?.get("X-Forwarded-Host") ?? null if (host) return `${protocol}://${host}` throw new AuraAuthError({ code: "INVALID_AUTH_CONFIGURATION" }) } From 86443628621702e6815e40f343d003328e1502f6 Mon Sep 17 00:00:00 2001 From: Hernan Alvarado Date: Sun, 20 Sep 2026 19:31:11 -0500 Subject: [PATCH 2/3] test(core): verify trusted header sources --- packages/core/src/@types/config.ts | 4 +- packages/core/src/@types/internal.ts | 4 +- packages/core/src/router/context.ts | 31 ++- packages/core/src/shared/assert.ts | 10 +- packages/core/src/shared/utils.ts | 16 +- .../core/src/shared/utils/authorization.ts | 2 +- .../test/config/trustedProxyHeaders.test.ts | 179 ++++++++++++++++++ packages/core/test/instance.test.ts | 60 ------ 8 files changed, 212 insertions(+), 94 deletions(-) create mode 100644 packages/core/test/config/trustedProxyHeaders.test.ts diff --git a/packages/core/src/@types/config.ts b/packages/core/src/@types/config.ts index 33930471..4b57e506 100644 --- a/packages/core/src/@types/config.ts +++ b/packages/core/src/@types/config.ts @@ -213,7 +213,7 @@ export type TrustedProxyHeadersConfig = * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Forwarded * @experimental */ - trustedProxyHeaders: true | TrustedProxyHeaderSource[] + trustedProxyHeaders: true | TrustedProxyHeadersSource[] /** * Defines trusted origins for your application to prevent open redirect attacks. * URLs from the Referer header, Origin header, request URL, and redirectTo option @@ -500,7 +500,7 @@ export type RateLimiterConfig = Partial< * // or * { protocol: "x-forwarded-proto", host: "x-forwarded-host" } */ -export type TrustedProxyHeaderSource = +export type TrustedProxyHeadersSource = | { url: LiteralUnion<"forwarded"> } | { protocol: LiteralUnion<"forwarded.proto" | "x-forwarded-proto"> diff --git a/packages/core/src/@types/internal.ts b/packages/core/src/@types/internal.ts index 5def97cc..b3e92e6a 100644 --- a/packages/core/src/@types/internal.ts +++ b/packages/core/src/@types/internal.ts @@ -21,7 +21,7 @@ import type { RateLimiterConfig, SignUpConfig, TrustedOrigin, - TrustedProxyHeaderSource, + TrustedProxyHeadersSource, } from "@/@types/config.ts" import type { JWTKey, @@ -81,7 +81,7 @@ export interface RouterGlobalContext Promise | TrustedOrigin[]) logger?: InternalLogger sessionStrategy: SessionStrategy diff --git a/packages/core/src/router/context.ts b/packages/core/src/router/context.ts index 67f4738d..c7c92971 100644 --- a/packages/core/src/router/context.ts +++ b/packages/core/src/router/context.ts @@ -2,12 +2,12 @@ import { createJoseInstance } from "@/jose.ts" import { createCookieStore } from "@/cookie.ts" import { AuraAuthError } from "@/shared/errors.ts" import { createProxyLogger } from "@/shared/logger.ts" -import { isBoolean, isStatelessStrategy, isTrustedProxyHeaderSource } from "@/shared/assert.ts" +import { isBoolean, isStatelessStrategy } from "@/shared/assert.ts" import { createSessionStrategy } from "@/session/strategy.ts" import { createJoseManager } from "@/session/jose-manager.ts" import { createSchemaRegistry } from "@/validator/registry.ts" import { createBuiltInOAuthProviders } from "@/oauth/index.ts" -import { getEnv, getEnvArray, getEnvBoolean } from "@/shared/env.ts" +import { getEnvArray, getEnvBoolean } from "@/shared/env.ts" import { createRateLimiterInstance } from "@/router/rate-limiter.ts" import type { Identities, SchemaTypes } from "@/identity/index.ts" import type { AuthConfig, FromShapeToObject } from "@/@types/index.ts" @@ -17,18 +17,15 @@ export const createContext = ) => { const { trustedProxyHeaders } = config ?? {} - const trustedProxyHeadersEnv = getEnv("TRUSTED_PROXY_HEADERS") - /** - * @todo clean up the logic here, it's a bit messy - */ - const useProxyHeaders = - trustedProxyHeadersEnv === undefined - ? isBoolean(trustedProxyHeaders) - ? trustedProxyHeaders - : isTrustedProxyHeaderSource(trustedProxyHeaders) && trustedProxyHeaders.length > 0 - ? trustedProxyHeaders - : false - : getEnvBoolean("TRUSTED_PROXY_HEADERS") + const trustedProxyHeadersEnv = getEnvBoolean("TRUSTED_PROXY_HEADERS") + + const proxyHeaders = trustedProxyHeadersEnv + ? trustedProxyHeadersEnv + : Array.isArray(trustedProxyHeaders) + ? trustedProxyHeaders + : isBoolean(trustedProxyHeaders) + ? trustedProxyHeaders + : false const envTrustedOrigins = getEnvArray("TRUSTED_ORIGINS") const resolvedTrustedOrigins = envTrustedOrigins.length > 0 ? envTrustedOrigins : config?.trustedOrigins const logger = createProxyLogger(config) @@ -48,7 +45,7 @@ export const createContext = ctx.sessionStrategy = createSessionStrategy({ diff --git a/packages/core/src/shared/assert.ts b/packages/core/src/shared/assert.ts index f2e7fb69..77c8f1c1 100644 --- a/packages/core/src/shared/assert.ts +++ b/packages/core/src/shared/assert.ts @@ -16,7 +16,7 @@ import type { SessionConfig, StatefulStrategyConfig, StatelessStrategyConfig, - TrustedProxyHeaderSource, + TrustedProxyHeadersSource, } from "@/@types/index.ts" export const isFalsy = (value: unknown): boolean => { @@ -277,15 +277,15 @@ export const isHeadersInit = (value: unknown): value is HeadersInit => { return typeof value === "object" && value !== null && (value instanceof Headers || Array.isArray(value) || isObject(value)) } -export const isTrustedProxyHeaderSource = (value: unknown): value is TrustedProxyHeaderSource => { - return isTrustedProxyHeaderSourceURL(value) || isTrustedProxyHeaderSourceProtocolHost(value) +export const isTrustedProxyHeadersSource = (value: unknown): value is TrustedProxyHeadersSource => { + return isTrustedProxyHeadersSourceURL(value) || isTrustedProxyHeadersSourceProtocolHost(value) } -export const isTrustedProxyHeaderSourceURL = (value: unknown): value is { url: string } => { +export const isTrustedProxyHeadersSourceURL = (value: unknown): value is { url: string } => { return typeof value === "object" && value !== null && "url" in value } -export const isTrustedProxyHeaderSourceProtocolHost = (value: unknown): value is { protocol: string; host: string } => { +export const isTrustedProxyHeadersSourceProtocolHost = (value: unknown): value is { protocol: string; host: string } => { return ( typeof value === "object" && value !== null && diff --git a/packages/core/src/shared/utils.ts b/packages/core/src/shared/utils.ts index 03033287..28246c1f 100644 --- a/packages/core/src/shared/utils.ts +++ b/packages/core/src/shared/utils.ts @@ -7,12 +7,12 @@ import { isBoolean, isRelativeURL, isString, - isTrustedProxyHeaderSource, - isTrustedProxyHeaderSourceURL, + isTrustedProxyHeadersSource, + isTrustedProxyHeadersSourceURL, isValidURL, } from "@/shared/assert.ts" import type { DeviceType } from "@/@types/entities.ts" -import type { AuthConfig, OAuthTokenPayload, TrustedProxyHeaderSource } from "@/@types/index.ts" +import type { AuthConfig, OAuthTokenPayload, TrustedProxyHeadersSource } from "@/@types/index.ts" import type { InternalCookieStoreConfig, InternalLogger, @@ -45,7 +45,7 @@ export const isSecureConnection = ( headers.get("X-Forwarded-Proto") === "https" || (headers.get("Forwarded")?.includes("proto=https") ?? false) : (url?.startsWith("https://") ?? false) - : isTrustedProxyHeaderSource(trustedProxyHeaders) + : isTrustedProxyHeadersSource(trustedProxyHeaders) ? getBaseURLFromProxyHeaders(headers, trustedProxyHeaders).startsWith("https://") : false } @@ -301,20 +301,22 @@ export const getHostFromForwarded = (headers: Headers) => { * @param proxyHeaders - The configured trusted proxy headers. * @returns The base URL derived from the proxy headers. */ -export const getBaseURLFromProxyHeaders = (headers: Headers, proxyHeaders: TrustedProxyHeaderSource[]): string => { +export const getBaseURLFromProxyHeaders = (headers: Headers, proxyHeaders: TrustedProxyHeadersSource[]): string => { let baseURL = "" proxyHeaders.find((config) => { try { - if (isTrustedProxyHeaderSourceURL(config)) { + if (isTrustedProxyHeadersSourceURL(config)) { const url = config.url === "forwarded" ? `${getProtoFromForwarded(headers)}://${getHostFromForwarded(headers)}` - : config.url + : (headers.get(config.url) ?? null) + if (!url || !isValidURL(url)) return false return (baseURL = new URL(url).origin) } else { const protocol = config.protocol === "forwarded.proto" ? getProtoFromForwarded(headers) : headers.get(config.protocol)! const host = config.host === "forwarded.host" ? getHostFromForwarded(headers) : headers.get(config.host)! + if (!protocol || !host || !isValidURL(`${protocol}://${host}`)) return false return (baseURL = new URL(`${protocol}://${host}`).origin) } } catch { diff --git a/packages/core/src/shared/utils/authorization.ts b/packages/core/src/shared/utils/authorization.ts index 55064866..090d28c5 100644 --- a/packages/core/src/shared/utils/authorization.ts +++ b/packages/core/src/shared/utils/authorization.ts @@ -39,7 +39,7 @@ export const getBaseURL = async ({ const origin = getEnv("BASE_URL") || ctx?.baseURL if (origin && origin !== "/") return origin if (ctx?.trustedProxyHeaders) { - const headers = new Headers(headersInit || request?.headers) + const headers = new Headers(request?.headers ?? headersInit ?? {}) if (Array.isArray(ctx.trustedProxyHeaders)) { return getBaseURLFromProxyHeaders(headers, ctx.trustedProxyHeaders) } diff --git a/packages/core/test/config/trustedProxyHeaders.test.ts b/packages/core/test/config/trustedProxyHeaders.test.ts new file mode 100644 index 00000000..c3342c5a --- /dev/null +++ b/packages/core/test/config/trustedProxyHeaders.test.ts @@ -0,0 +1,179 @@ +import { describe, test, expect, vi, beforeEach } from "vitest" +import { createAuth } from "@/createAuth.ts" +import { oauthCustomService, sessionPayload } from "@test/presets.ts" +import { createCSRF } from "@/shared/crypto.ts" + +beforeEach(() => { + vi.stubEnv("BASE_URL", undefined) +}) + +describe("trustedProxyHeaders", () => { + describe("valid cases", () => { + const testCases: { + title: string + trustedProxyHeaders: any[] + trustedOrigins: string[] + headers: Record + }[] = [ + { + title: "trustedProxyHeaders with built-in 'forwarded' url source", + trustedProxyHeaders: [{ url: "forwarded" }], + trustedOrigins: ["https://example.com"], + headers: { + Forwarded: "host=example.com;proto=https", + }, + }, + { + title: "trustedProxyHeaders with custom url source", + trustedProxyHeaders: [{ url: "x-custom-header" }], + trustedOrigins: ["https://example.com"], + headers: { + "X-Custom-Header": "https://example.com", + }, + }, + { + title: "trustedProxyHeaders with built-in 'forwarded.host' and 'forwarded.proto' url source", + trustedProxyHeaders: [{ protocol: "forwarded.proto", host: "forwarded.host" }], + trustedOrigins: ["https://example.com"], + headers: { + Forwarded: "host=example.com;proto=https", + }, + }, + { + title: "trustedProxyHeaders with built-in 'x-forwarded-host' and 'x-forwarded-proto' url source", + trustedProxyHeaders: [{ protocol: "x-forwarded-proto", host: "x-forwarded-host" }], + trustedOrigins: ["https://example.com"], + headers: { + "X-Forwarded-Proto": "https", + "X-Forwarded-Host": "example.com", + }, + }, + ] + + for (const { title, trustedProxyHeaders, trustedOrigins, headers } of testCases) { + test.concurrent(title, async ({ expect }) => { + const auth = createAuth({ + oauth: [oauthCustomService], + trustedProxyHeaders, + trustedOrigins, + credentials: { + authorize: () => { + return { + sub: "1234567890", + ...sessionPayload, + } + }, + }, + }) + const sessionToken = await auth.jose.encodeJWT(sessionPayload) + const csrfToken = await createCSRF(auth.jose) + const output = await auth.api.signInCredentials({ + payload: { username: "testuser", password: "testpassword" }, + headers: { + ...headers, + Cookie: `__Secure-aura-auth.session_token=${sessionToken}; __Host-aura-auth.csrf_token=${csrfToken}`, + }, + }) + expect(output).toEqual({ + success: true, + redirect: false, + redirectURL: null, + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + }) + } + + test("trustedProxyHeaders with built-in 'forwarded' url source and multiple trusted origins", async ({ expect }) => { + const auth = createAuth({ + oauth: [oauthCustomService], + trustedProxyHeaders: [{ url: "forwarded" }], + trustedOrigins: ["https://example.com", "https://another-example.com"], + credentials: { + authorize: () => { + return { + sub: "1234567890", + ...sessionPayload, + } + }, + }, + }) + const sessionToken = await auth.jose.encodeJWT(sessionPayload) + const csrfToken = await createCSRF(auth.jose) + const output = await auth.api.signInCredentials({ + payload: { username: "testuser", password: "testpassword" }, + headers: { + Forwarded: "host=example.com;proto=https", + Cookie: `__Secure-aura-auth.session_token=${sessionToken}; __Host-aura-auth.csrf_token=${csrfToken}`, + }, + }) + expect(output).toEqual({ + success: true, + redirect: false, + redirectURL: null, + headers: expect.any(Headers), + toResponse: expect.any(Function), + }) + }) + }) + + describe("invalid cases", () => { + test("throws error if trustedProxyHeaders is enabled but trustedOrigins is not set", () => { + expect(() => + createAuth({ + oauth: [], + trustedProxyHeaders: true, + trustedOrigins: [], + }) + ).toThrow( + "Security assertion failed during instantiation: 'trustedProxyHeaders' was enabled, but 'trustedOrigins' is completely empty or undefined. Real proxy networks require explicit origin mapping rules to mitigate host-header hijacking and cache-poisoning vectors." + ) + }) + + test("does not throw error if trustedProxyHeaders is enabled and trustedOrigins is set", () => { + expect(() => + createAuth({ + oauth: [], + trustedProxyHeaders: true, + trustedOrigins: ["https://example.com"], + }) + ).not.toThrow() + }) + + test("throws error if trustedProxyHeaders is enabled but trustedOrigins is not set via env", () => { + vi.stubEnv("TRUSTED_PROXY_HEADERS", "true") + + expect(() => + createAuth({ + oauth: [], + }) + ).toThrow( + "Security assertion failed during instantiation: 'trustedProxyHeaders' was enabled, but 'trustedOrigins' is completely empty or undefined. Real proxy networks require explicit origin mapping rules to mitigate host-header hijacking and cache-poisoning vectors." + ) + }) + + test("throws error if trustedProxyHeaders is enabled but trustedOrigins is empty set via env", () => { + vi.stubEnv("TRUSTED_PROXY_HEADERS", "true") + vi.stubEnv("TRUSTED_ORIGINS", "") + + expect(() => + createAuth({ + oauth: [], + }) + ).toThrow( + "Security assertion failed during instantiation: 'trustedProxyHeaders' was enabled, but 'trustedOrigins' is completely empty or undefined. Real proxy networks require explicit origin mapping rules to mitigate host-header hijacking and cache-poisoning vectors." + ) + }) + + test("does not throw error if trustedProxyHeaders is enabled and trustedOrigins is set via env", () => { + vi.stubEnv("TRUSTED_PROXY_HEADERS", "true") + vi.stubEnv("TRUSTED_ORIGINS", "https://example.com") + + expect(() => + createAuth({ + oauth: [], + }) + ).not.toThrow() + }) + }) +}) diff --git a/packages/core/test/instance.test.ts b/packages/core/test/instance.test.ts index 8eab99a7..82922551 100644 --- a/packages/core/test/instance.test.ts +++ b/packages/core/test/instance.test.ts @@ -62,64 +62,4 @@ describe("createAuth", () => { expect(response.status).toBe(404) }) }) - - describe("trustedProxyHeader", () => { - test("throws error if trustedProxyHeaders is enabled but trustedOrigins is not set", () => { - expect(() => - createAuth({ - oauth: [], - trustedProxyHeaders: true, - trustedOrigins: [], - }) - ).toThrow( - "Security assertion failed during instantiation: 'trustedProxyHeaders' was enabled, but 'trustedOrigins' is completely empty or undefined. Real proxy networks require explicit origin mapping rules to mitigate host-header hijacking and cache-poisoning vectors." - ) - }) - - test("does not throw error if trustedProxyHeaders is enabled and trustedOrigins is set", () => { - expect(() => - createAuth({ - oauth: [], - trustedProxyHeaders: true, - trustedOrigins: ["https://example.com"], - }) - ).not.toThrow() - }) - - test("throws error if trustedProxyHeaders is enabled but trustedOrigins is not set via env", () => { - vi.stubEnv("TRUSTED_PROXY_HEADERS", "true") - - expect(() => - createAuth({ - oauth: [], - }) - ).toThrow( - "Security assertion failed during instantiation: 'trustedProxyHeaders' was enabled, but 'trustedOrigins' is completely empty or undefined. Real proxy networks require explicit origin mapping rules to mitigate host-header hijacking and cache-poisoning vectors." - ) - }) - - test("throws error if trustedProxyHeaders is enabled but trustedOrigins is empty set via env", () => { - vi.stubEnv("TRUSTED_PROXY_HEADERS", "true") - vi.stubEnv("TRUSTED_ORIGINS", "") - - expect(() => - createAuth({ - oauth: [], - }) - ).toThrow( - "Security assertion failed during instantiation: 'trustedProxyHeaders' was enabled, but 'trustedOrigins' is completely empty or undefined. Real proxy networks require explicit origin mapping rules to mitigate host-header hijacking and cache-poisoning vectors." - ) - }) - - test("does not throw error if trustedProxyHeaders is enabled and trustedOrigins is set via env", () => { - vi.stubEnv("TRUSTED_PROXY_HEADERS", "true") - vi.stubEnv("TRUSTED_ORIGINS", "https://example.com") - - expect(() => - createAuth({ - oauth: [], - }) - ).not.toThrow() - }) - }) }) From ca9b59a399d5bca7901ec583b9d3fbcb62940203 Mon Sep 17 00:00:00 2001 From: Hernan Alvarado Date: Sun, 20 Sep 2026 20:58:54 -0500 Subject: [PATCH 3/3] chore: apply coderabbit --- packages/core/src/shared/utils.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/src/shared/utils.ts b/packages/core/src/shared/utils.ts index 28246c1f..653825ee 100644 --- a/packages/core/src/shared/utils.ts +++ b/packages/core/src/shared/utils.ts @@ -41,11 +41,11 @@ export const isSecureConnection = ( const url = request instanceof Headers ? null : request.url return isBoolean(trustedProxyHeaders) ? trustedProxyHeaders - ? url?.startsWith("httpss://") || + ? url?.startsWith("https://") || headers.get("X-Forwarded-Proto") === "https" || (headers.get("Forwarded")?.includes("proto=https") ?? false) : (url?.startsWith("https://") ?? false) - : isTrustedProxyHeadersSource(trustedProxyHeaders) + : Array.isArray(trustedProxyHeaders) ? getBaseURLFromProxyHeaders(headers, trustedProxyHeaders).startsWith("https://") : false }