From 6b476878d442fa745894bf28772799ffaa461210 Mon Sep 17 00:00:00 2001 From: Hernan Alvarado Date: Sun, 15 Feb 2026 16:21:30 -0500 Subject: [PATCH 1/6] fix(core): fix trusted origin matching logic --- packages/core/src/@types/index.ts | 1 + .../core/src/actions/signIn/authorization.ts | 58 +-- packages/core/src/logger.ts | 6 + .../test/actions/signIn/authorization.test.ts | 89 +++- .../core/test/actions/signIn/signIn.test.ts | 410 ++++++++++++++++-- 5 files changed, 476 insertions(+), 88 deletions(-) diff --git a/packages/core/src/@types/index.ts b/packages/core/src/@types/index.ts index d8770c95..0affd1b5 100644 --- a/packages/core/src/@types/index.ts +++ b/packages/core/src/@types/index.ts @@ -289,6 +289,7 @@ export type AuthInternalErrorCode = | "INVALID_ENVIRONMENT_CONFIGURATION" | "INVALID_URL" | "INVALID_SALT_SECRET_VALUE" + | "UNTRUSTED_ORIGIN" export type AuthSecurityErrorCode = | "INVALID_STATE" diff --git a/packages/core/src/actions/signIn/authorization.ts b/packages/core/src/actions/signIn/authorization.ts index 8eb2390b..9abca70f 100644 --- a/packages/core/src/actions/signIn/authorization.ts +++ b/packages/core/src/actions/signIn/authorization.ts @@ -44,21 +44,32 @@ export const createAuthorizationURL = ( return `${authorizeURL}?${searchParams}` } -export const getOriginURL = (request: Request, trustedProxyHeaders?: boolean, logger?: InternalLogger) => { - let origin = new URL(request.url).origin +/** + * Resolves trusted origins from config (array or function). + */ +export const getTrustedOrigins = async (request: Request, trustedOrigins: AuthConfig["trustedOrigins"]): Promise => { + if (!trustedOrigins) return [] + const raw = typeof trustedOrigins === "function" ? await trustedOrigins(request) : trustedOrigins + return Array.isArray(raw) ? raw : typeof raw === "string" ? [raw] : [] +} + +export const getOriginURL = async (request: Request, context?: GlobalContext) => { const headers = request.headers - if (trustedProxyHeaders) { - const protocol = headers.get("X-Forwarded-Proto") ?? headers.get("Forwarded")?.match(/proto=([^;]+)/i)?.[1] ?? "http" + let origin = new URL(request.url).origin + const trustedOrigins = await getTrustedOrigins(request, context?.trustedOrigins) + trustedOrigins.push(origin) + if (context?.trustedProxyHeaders) { + const protocol = headers.get("Forwarded")?.match(/proto=([^;]+)/i)?.[1] ?? headers.get("X-Forwarded-Proto") ?? "http" const host = - headers.get("X-Forwarded-Host") ?? headers.get("Host") ?? headers.get("Forwarded")?.match(/host=([^;]+)/i)?.[1] ?? + headers.get("X-Forwarded-Host") ?? null origin = `${protocol}://${host}` } - if (!isValidURL(origin)) { - logger?.log("INVALID_URL", { structuredData: { origin: origin } }) - throw new AuthInternalError("INVALID_URL", "The constructed origin URL is invalid.") + if (!isTrustedOrigin(origin, trustedOrigins)) { + context?.logger?.log("UNTRUSTED_ORIGIN", { structuredData: { origin: origin } }) + throw new AuthInternalError("UNTRUSTED_ORIGIN", "The constructed origin URL is not trusted.") } return origin } @@ -72,19 +83,10 @@ export const getOriginURL = (request: Request, trustedProxyHeaders?: boolean, lo * @returns The redirect URI for the OAuth callback. */ export const createRedirectURI = async (request: Request, oauth: string, context: GlobalContext) => { - const origin = getOriginURL(request, context.trustedProxyHeaders, context.logger) + const origin = await getOriginURL(request, context) return `${origin}${context.basePath}/callback/${oauth}` } -/** - * Resolves trusted origins from config (array or function). - */ -export const getTrustedOrigins = async (request: Request, trustedOrigins: AuthConfig["trustedOrigins"]): Promise => { - if (!trustedOrigins) return [] - const raw = typeof trustedOrigins === "function" ? await trustedOrigins(request) : trustedOrigins - return Array.isArray(raw) ? raw : typeof raw === "string" ? [raw] : [] -} - /** * Verifies if the request's origin matches the expected origin. It accepts the redirectTo search * parameter for redirection. It checks the Referer and Origin headers and the request URL against @@ -101,22 +103,26 @@ export const getTrustedOrigins = async (request: Request, trustedOrigins: AuthCo export const createRedirectTo = async (request: Request, redirectTo?: string, context?: GlobalContext) => { try { const headers = request.headers + const requestOrigin = await getOriginURL(request, context) const origins = await getTrustedOrigins(request, context?.trustedOrigins) - const requestOrigin = getOriginURL(request, context?.trustedProxyHeaders, context?.logger) const validateURL = (url: string): string => { if (!isRelativeURL(url) && !isValidURL(url)) return "/" if (isRelativeURL(url)) return url - if (origins.length > 0 && isTrustedOrigin(url, origins)) { - const urlOrigin = new URL(url).origin - for (const pattern of origins) { - const regex = patternToRegex(pattern) - if (regex?.test(urlOrigin)) { - return isSameOrigin(url, requestOrigin) ? extractPath(url) : url + if (origins.length > 0) { + if (isTrustedOrigin(url, origins)) { + const urlOrigin = new URL(url).origin + for (const pattern of origins) { + const regex = patternToRegex(pattern) + if (regex?.test(urlOrigin)) { + return isSameOrigin(url, request.url) ? extractPath(url) : url + } + if (isValidURL(pattern) && equals(new URL(pattern).origin, urlOrigin)) return url } - if (isValidURL(pattern) && equals(new URL(pattern).origin, urlOrigin)) return url } + context?.logger?.log("OPEN_REDIRECT_ATTACK") + return "/" } if (isSameOrigin(url, requestOrigin)) { return extractPath(url) diff --git a/packages/core/src/logger.ts b/packages/core/src/logger.ts index c4224733..75991aac 100644 --- a/packages/core/src/logger.ts +++ b/packages/core/src/logger.ts @@ -253,6 +253,12 @@ export const logMessages = { msgId: "COOKIE_HOST_STRATEGY_INSECURE", message: "__Host- cookies require a secure HTTPS context. Falling back to standard cookie settings.", }, + UNTRUSTED_ORIGIN: { + facility: 10, + severity: "error", + msgId: "UNTRUSTED_ORIGIN", + message: "The constructed origin URL is not trusted.", + }, } as const export const createLogEntry = (key: T, overrides?: Partial): SyslogOptions => { diff --git a/packages/core/test/actions/signIn/authorization.test.ts b/packages/core/test/actions/signIn/authorization.test.ts index cf7e9a5a..edd84615 100644 --- a/packages/core/test/actions/signIn/authorization.test.ts +++ b/packages/core/test/actions/signIn/authorization.test.ts @@ -468,7 +468,6 @@ describe("createRedirectTo", () => { headers: { Referer: "https://example.dev/auth" }, }), }, - { description: "with redirectTo parameter containing full URL with different origin", request: new Request(signInURL), @@ -493,44 +492,54 @@ describe("createRedirectTo", () => { const request = new Request("https://example.com/auth/signIn/github", { headers: { Referer: "https://example.com/dashboard" }, }) - const result = await createRedirectTo(request, undefined, { + const redirectTo = await createRedirectTo(request, undefined, { trustedOrigins: ["https://example.com", "https://admin.example.com"], } as GlobalContext) - expect(result).toBe("/dashboard") + expect(redirectTo).toBe("/dashboard") }) test("accepts referer matching wildcard pattern", async () => { const request = new Request("https://app.example.com/auth/signIn/github", { headers: { Referer: "https://app.example.com/dashboard" }, }) - const result = await createRedirectTo(request, undefined, { + const redirectTo = await createRedirectTo(request, undefined, { trustedOrigins: ["https://*.example.com"], } as GlobalContext) - expect(result).toBe("/dashboard") + expect(redirectTo).toBe("/dashboard") }) test("rejects referer not in trusted origins", async () => { const request = new Request("https://example.com/auth/signIn/github", { headers: { Referer: "https://malicious.com/phishing" }, }) - const result = await createRedirectTo(request, undefined, { + const redirectTo = await createRedirectTo(request, undefined, { trustedOrigins: ["https://example.com"], } as GlobalContext) - expect(result).toBe("/") + expect(redirectTo).toBe("/") }) test("accepts redirectTo from trusted origin", async () => { const request = new Request("https://example.com/auth/signIn/github") - const result = await createRedirectTo(request, "https://example.com/dashboard", {} as GlobalContext) - expect(result).toBe("/dashboard") + const redirectTo = await createRedirectTo(request, "https://example.com/dashboard", {} as GlobalContext) + expect(redirectTo).toBe("/dashboard") }) test("with trusted origins that are not same origin", async () => { const request = new Request("https://example.com/auth/signIn/github") - const result = await createRedirectTo(request, "https://api.example.com/data", { + const redirectTo = await createRedirectTo(request, "https://api.example.com/redirect", { trustedOrigins: ["https://api.example.com"], + trustedProxyHeaders: false, + } as GlobalContext) + expect(redirectTo).toBe("https://api.example.com/redirect") + }) + + test("misconfigurated", async () => { + const request = new Request("https://example.com/signIn/github") + const redirectTo = await createRedirectTo(request, "https://api.example.com/redirect", { + trustedOrigins: ["https://example.com"], + trustedProxyHeaders: false, } as GlobalContext) - expect(result).toBe("https://api.example.com/data") + expect(redirectTo).toBe("/") }) }) }) @@ -541,18 +550,21 @@ describe("getOriginURL", () => { description: "with standard URL", request: new Request("https://example.com/auth/signIn/github"), trustedProxyHeaders: false, + trustedOrigins: [], expected: "https://example.com", }, { description: "with localhost URL", request: new Request("http://localhost:3000/auth/signIn/github"), trustedProxyHeaders: false, + trustedOrigins: [], expected: "http://localhost:3000", }, { description: "with IP address URL", request: new Request("http://192.168.0.1/auth/signIn/github"), trustedProxyHeaders: false, + trustedOrigins: [], expected: "http://192.168.0.1", }, { @@ -564,6 +576,7 @@ describe("getOriginURL", () => { }, }), trustedProxyHeaders: false, + trustedOrigins: [], expected: "http://localhost:3000", }, { @@ -575,6 +588,7 @@ describe("getOriginURL", () => { }, }), trustedProxyHeaders: true, + trustedOrigins: ["https://example.com"], expected: "https://example.com", }, { @@ -586,14 +600,61 @@ describe("getOriginURL", () => { }, }), trustedProxyHeaders: true, + trustedOrigins: ["http://192.168.0.1"], expected: "http://192.168.0.1", }, + { + description: "priority of Forwarded header over X-Forwarded headers", + request: new Request("http://localhost:3000/auth/signIn/github", { + headers: { + Forwarded: "proto=https;host=app.com", + "X-Forwarded-Proto": "http", + "X-Forwarded-Host": "malicious.com", + }, + }), + trustedProxyHeaders: true, + trustedOrigins: ["https://app.com"], + expected: "https://app.com", + }, ] - for (const { description, request, trustedProxyHeaders, expected } of testCases) { - test(description, () => { - const originURL = getOriginURL(request, trustedProxyHeaders) + for (const { description, request, trustedProxyHeaders, trustedOrigins, expected } of testCases) { + test(description, async () => { + const originURL = await getOriginURL(request, { trustedProxyHeaders, trustedOrigins } as GlobalContext) expect(originURL).toBe(expected) }) } + + describe("Invalid origins", () => { + const testCases = [ + { + description: "origin not set in trustedOrigins", + request: new Request("https://example.com/signIn/github", { + headers: { + "X-Forwarded-Proto": "http", + "X-Forwarded-Host": "localhost:3000", + }, + }), + trustedOrigins: ["https://example.com"], + trustedProxyHeaders: true, + }, + { + description: "trustedOrigins empty", + request: new Request("https://example.com/signIn/github", { + headers: { + "X-Forwarded-Proto": "http", + "X-Forwarded-Host": "localhost:3000", + }, + }), + trustedOrigins: [], + trustedProxyHeaders: true, + }, + ] + + for (const { description, request, trustedOrigins, trustedProxyHeaders } of testCases) { + test(description, async () => { + await expect(getOriginURL(request, { trustedOrigins, trustedProxyHeaders } as GlobalContext)).rejects.toThrow() + }) + } + }) }) diff --git a/packages/core/test/actions/signIn/signIn.test.ts b/packages/core/test/actions/signIn/signIn.test.ts index 6ecdfe18..f3dfe3c8 100644 --- a/packages/core/test/actions/signIn/signIn.test.ts +++ b/packages/core/test/actions/signIn/signIn.test.ts @@ -1,9 +1,10 @@ import { describe, test, expect } from "vitest" -import { GET } from "@test/presets.js" +import { createAuth } from "@/index.js" import { getSetCookie } from "@/cookie.js" +import { GET, oauthCustomService } from "@test/presets.js" describe("signIn action", () => { - test("unsupported oauth provider", async () => { + test("rejects unsupported OAuth provider", async () => { const request = await GET(new Request("http://example.com/auth/signIn/unsupported")) expect(request.status).toBe(422) expect(await request.json()).toEqual({ @@ -18,52 +19,365 @@ describe("signIn action", () => { }) }) - describe("valid signIn requests", () => { - const testCases = [ - { - description: "standard case", - url: "http://localhost:3000/auth/signIn/oauth-provider", - expected: "http://localhost:3000/auth/callback/oauth-provider", - }, - { - description: "with query parameters", - url: "https://myapp.com/auth/signIn/oauth-provider?ref=homepage", - expected: "https://myapp.com/auth/callback/oauth-provider", - }, - { - description: "different domain", - url: "https://anotherdomain.com/auth/signIn/oauth-provider", - expected: "https://anotherdomain.com/auth/callback/oauth-provider", - }, - { - description: "with redirectTo parameter", - url: "http://localhost:3000/auth/signIn/oauth-provider?redirectTo=/dashboard", - expected: "http://localhost:3000/auth/callback/oauth-provider", - }, - ] - - for (const { description, url, expected } of testCases) { - test.concurrent(description, async ({ expect }) => { - const request = await GET(new Request(url)) - const headers = new Headers(request.headers) - - /** - * @todo: review state parameter handling and cookie management. - * If the connection is https, the cookie should have the Secure attribute. - */ - const stateCookie = getSetCookie(request, `aura-auth.state`) - const redirectToCookie = getSetCookie(request, `aura-auth.redirect_to`) - - const location = headers.get("Location") as string - const searchParams = new URL(location).searchParams - - expect(request.status).toBe(302) - expect(location).toContain("https://example.com/oauth/authorize?") - expect(searchParams.get("client_id")).toBe("oauth_client_id") - expect(searchParams.get("redirect_uri")).toMatch(expected) - expect(stateCookie).toBeDefined() - expect(redirectToCookie).toEqual(url.includes("redirectTo") ? expect.stringContaining("/dashboard") : "/") + test("stores redirectTo cookie over HTTPS", async () => { + const { + handlers: { GET }, + } = createAuth({ + oauth: [oauthCustomService], + }) + const request = await GET(new Request("https://example.com/auth/signIn/oauth-provider?redirectTo=/dashboard")) + expect(getSetCookie(request, "__Secure-aura-auth.redirect_to")).toEqual("/dashboard") + }) + + test("stores redirectTo cookie over HTTP", async () => { + const { + handlers: { GET }, + } = createAuth({ + oauth: [oauthCustomService], + }) + const request = await GET(new Request("http://example.com/auth/signIn/oauth-provider?redirectTo=/dashboard")) + expect(getSetCookie(request, "aura-auth.redirect_to")).toEqual("/dashboard") + }) + + test("stores redirectTo when origin is trusted", async () => { + const { + handlers: { GET }, + } = createAuth({ + oauth: [oauthCustomService], + trustedOrigins: ["http://app.com"], + }) + const request = await GET( + new Request("http://example.com/auth/signIn/oauth-provider?redirectTo=http://app.com/dashboard") + ) + expect(getSetCookie(request, "aura-auth.redirect_to")).toEqual("http://app.com/dashboard") + }) + + test("falls back to root when redirectTo origin is untrusted", async () => { + const { + handlers: { GET }, + } = createAuth({ + oauth: [oauthCustomService], + }) + const request = await GET( + new Request("http://example.com/auth/signIn/oauth-provider?redirectTo=http://app.com/dashboard") + ) + expect(getSetCookie(request, "aura-auth.redirect_to")).toEqual("/") + }) + + test("uses same-origin Referer as redirectTo", async () => { + const { + handlers: { GET }, + } = createAuth({ + oauth: [oauthCustomService], + }) + const request = await GET( + new Request("http://example.com/auth/signIn/oauth-provider", { + headers: { + Referer: "http://example.com/dashboard", + }, + }) + ) + expect(getSetCookie(request, "aura-auth.redirect_to")).toEqual("/dashboard") + }) + + test("falls back to root when Referer is cross-origin", async () => { + const { + handlers: { GET }, + } = createAuth({ + oauth: [oauthCustomService], + }) + const request = await GET( + new Request("http://example.com/auth/signIn/oauth-provider", { + headers: { + Referer: "http://app.com/dashboard", + }, + }) + ) + expect(getSetCookie(request, "aura-auth.redirect_to")).toEqual("/") + }) + + test("accepts cross-origin Referer when origin is trusted", async () => { + const { + handlers: { GET }, + } = createAuth({ + oauth: [oauthCustomService], + trustedOrigins: ["http://app.com"], + }) + const request = await GET( + new Request("http://example.com/auth/signIn/oauth-provider", { + headers: { + Referer: "http://app.com/dashboard", + }, + }) + ) + expect(getSetCookie(request, "aura-auth.redirect_to")).toEqual("http://app.com/dashboard") + }) + + test("rejects cross-origin Referer when trusted proxy headers are used", async () => { + const { + handlers: { GET }, + } = createAuth({ + oauth: [oauthCustomService], + trustedProxyHeaders: true, + }) + const request = await GET( + new Request("http://example.com/auth/signIn/oauth-provider", { + headers: { + Referer: "http://app.com/dashboard", + "X-Forwarded-Proto": "http", + "X-Forwarded-Host": "app.com", + }, + }) + ) + expect(await request.json()).toEqual({ + type: "AUTH_INTERNAL_ERROR", + message: "The constructed origin URL is not trusted.", + }) + }) + + test("accepts cross-origin Referer when trusted proxy headers and origins are set", async () => { + const { + handlers: { GET }, + } = createAuth({ + oauth: [oauthCustomService], + trustedProxyHeaders: true, + trustedOrigins: ["http://app.com"], + }) + const request = await GET( + new Request("http://example.com/auth/signIn/oauth-provider", { + headers: { + Referer: "http://app.com/dashboard", + "X-Forwarded-Proto": "http", + "X-Forwarded-Host": "app.com", + }, + }) + ) + expect(getSetCookie(request, "aura-auth.redirect_to")).toEqual("http://app.com/dashboard") + }) + + test("rejects subdomain Referer when wildcard origin is not trusted", async () => { + const { + handlers: { GET }, + } = createAuth({ + oauth: [oauthCustomService], + }) + const request = await GET( + new Request("http://example.com/auth/signIn/oauth-provider", { + headers: { + Referer: "http://app.example.com/dashboard", + }, + }) + ) + expect(getSetCookie(request, "aura-auth.redirect_to")).toEqual("/") + }) + + test("accepts subdomain Referer when wildcard origin is trusted", async () => { + const { + handlers: { GET }, + } = createAuth({ + oauth: [oauthCustomService], + trustedOrigins: ["http://*.example.com"], + }) + const request = await GET( + new Request("http://example.com/auth/signIn/oauth-provider", { + headers: { + Referer: "http://app.example.com/dashboard", + }, + }) + ) + expect(getSetCookie(request, "aura-auth.redirect_to")).toEqual("http://app.example.com/dashboard") + }) + + test("rejects proxy-derived origin when trusted origins are not configured", async () => { + const { + handlers: { GET }, + } = createAuth({ + oauth: [oauthCustomService], + trustedProxyHeaders: true, + }) + const request = await GET( + new Request("http://example.com/auth/signIn/oauth-provider", { + headers: { + Referer: "http://evil.com/dashboard", + "X-Forwarded-Proto": "http", + "X-Forwarded-Host": "app.example.com", + }, + }) + ) + expect(await request.json()).toEqual({ + type: "AUTH_INTERNAL_ERROR", + message: "The constructed origin URL is not trusted.", + }) + }) + + test("rejects proxy-derived origin not present in trustedOrigins", async () => { + const { + handlers: { GET }, + } = createAuth({ + oauth: [oauthCustomService], + trustedProxyHeaders: true, + trustedOrigins: ["http://example.com"], + }) + const request = await GET( + new Request("http://example.com/auth/signIn/oauth-provider", { + headers: { + Referer: "http://evil.com/dashboard", + "X-Forwarded-Proto": "http", + "X-Forwarded-Host": "app.example.com", + }, + }) + ) + expect(await request.json()).toEqual({ + type: "AUTH_INTERNAL_ERROR", + message: "The constructed origin URL is not trusted.", + }) + }) + + test("stores redirectTo for subdomain when wildcard origin is trusted", async () => { + const { + handlers: { GET }, + } = createAuth({ + oauth: [oauthCustomService], + trustedOrigins: ["http://*.example.com"], + }) + const request = await GET( + new Request("http://example.com/auth/signIn/oauth-provider", { + headers: { + Referer: "http://app.example.com/dashboard", + }, + }) + ) + expect(getSetCookie(request, "aura-auth.redirect_to")).toEqual("http://app.example.com/dashboard") + }) + + test("builds redirect URL using request origin without proxy headers", async () => { + const { + handlers: { GET }, + } = createAuth({ + oauth: [oauthCustomService], + }) + const request = await GET(new Request("https://example.com/auth/signIn/oauth-provider")) + + const headers = new Headers(request.headers) + const location = headers.get("Location")! + const searchParams = new URL(location).searchParams + expect(location).toMatch( + new URLSearchParams({ + response_type: "code", + client_id: "oauth_client_id", + redirect_uri: "https://example.com/auth/callback/oauth-provider", + state: searchParams.get("state")!, + }).toString() + ) + }) + + test("builds redirect URL from trusted proxy headers with same origin", async () => { + const { + handlers: { GET }, + } = createAuth({ + oauth: [oauthCustomService], + trustedProxyHeaders: true, + }) + + const request = await GET( + new Request("https://example.com/auth/signIn/oauth-provider", { + headers: { + "X-Forwarded-Proto": "https", + "X-Forwarded-Host": "example.com", + }, + }) + ) + const headers = new Headers(request.headers) + const location = headers.get("Location")! + const state = getSetCookie(request, "__Secure-aura-auth.state")! + expect(location).toMatch( + new URLSearchParams({ + response_type: "code", + client_id: "oauth_client_id", + redirect_uri: "https://example.com/auth/callback/oauth-provider", + state, + }).toString() + ) + }) + + test("rejects proxy-derived origin when it is not trusted", async () => { + const { + handlers: { GET }, + } = createAuth({ + oauth: [oauthCustomService], + trustedProxyHeaders: true, + }) + + const request = await GET( + new Request("https://example.com/auth/signIn/oauth-provider", { + headers: { + "X-Forwarded-Proto": "https", + "X-Forwarded-Host": "app.com", + }, + }) + ) + expect(await request.json()).toEqual({ + type: "AUTH_INTERNAL_ERROR", + message: "The constructed origin URL is not trusted.", + }) + }) + + test("builds redirect URL even with empty trustedOrigins", async () => { + const { + handlers: { GET }, + } = createAuth({ + oauth: [oauthCustomService], + trustedOrigins: [], + }) + + const request = await GET( + new Request("https://example.com/auth/signIn/oauth-provider", { + headers: { + "X-Forwarded-Proto": "https", + "X-Forwarded-Host": "app.com", + }, + }) + ) + const headers = new Headers(request.headers) + const location = headers.get("Location")! + const state = getSetCookie(request, "__Secure-aura-auth.state")! + expect(location).toMatch( + new URLSearchParams({ + response_type: "code", + client_id: "oauth_client_id", + redirect_uri: "https://example.com/auth/callback/oauth-provider", + state, + }).toString() + ) + }) + + test("builds redirect URL from trusted proxy headers when origin is trusted", async () => { + const { + handlers: { GET }, + } = createAuth({ + oauth: [oauthCustomService], + trustedProxyHeaders: true, + trustedOrigins: ["https://app.com"], + }) + + const request = await GET( + new Request("https://example.com/auth/signIn/oauth-provider", { + headers: { + "X-Forwarded-Proto": "https", + "X-Forwarded-Host": "app.com", + }, }) - } + ) + const headers = new Headers(request.headers) + const location = headers.get("Location")! + const state = getSetCookie(request, "__Secure-aura-auth.state")! + expect(location).toMatch( + new URLSearchParams({ + response_type: "code", + client_id: "oauth_client_id", + redirect_uri: "https://app.com/auth/callback/oauth-provider", + state, + }).toString() + ) }) }) From 3b621116093dd556de0158ca8621ce7f7b98abd2 Mon Sep 17 00:00:00 2001 From: Hernan Alvarado Date: Sun, 15 Feb 2026 16:27:04 -0500 Subject: [PATCH 2/6] fix(core): replace vulnerable regex to prevent ReDoS --- packages/core/src/assert.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/core/src/assert.ts b/packages/core/src/assert.ts index 0c27be4e..94275820 100644 --- a/packages/core/src/assert.ts +++ b/packages/core/src/assert.ts @@ -79,8 +79,10 @@ export const isSameOrigin = (origin: string, expected: string): boolean => { */ export const patternToRegex = (pattern: string): RegExp | null => { try { + if (pattern.length > 2048) return null + pattern = pattern.replace(/\\/g, "") - const match = pattern.match(/^(https?):\/\/([^\/\s:]+)(?::(\d+|\*))?(\/.*)?$/) + const match = pattern.match(/^(https?):\/\/([a-zA-Z0-9.*-]{1,253})(?::(\d{1,5}|\*))?(?:\/.*)?$/) if (!match) return null const [, protocol, host, port] = match From f6819e4f61fd772cd283bde74eda304133d57c65 Mon Sep 17 00:00:00 2001 From: Hernan Alvarado Date: Sun, 15 Feb 2026 16:31:48 -0500 Subject: [PATCH 3/6] fix(core): resolve inconsistent redirect validation in callback --- .../core/src/actions/callback/callback.ts | 46 +++++++++++++------ 1 file changed, 32 insertions(+), 14 deletions(-) diff --git a/packages/core/src/actions/callback/callback.ts b/packages/core/src/actions/callback/callback.ts index 44025cb6..d0fee1a6 100644 --- a/packages/core/src/actions/callback/callback.ts +++ b/packages/core/src/actions/callback/callback.ts @@ -2,11 +2,11 @@ import { z } from "zod" import { createEndpoint, createEndpointConfig, HeadersBuilder } from "@aura-stack/router" import { createCSRF } from "@/secure.js" import { cacheControl } from "@/headers.js" -import { isRelativeURL, isTrustedOrigin, safeEquals } from "@/assert.js" +import { isRelativeURL, isSameOrigin, isTrustedOrigin, safeEquals } from "@/assert.js" import { getUserInfo } from "@/actions/callback/userinfo.js" import { OAuthAuthorizationErrorResponse } from "@/schemas.js" import { AuthSecurityError, OAuthProtocolError } from "@/errors.js" -import { getTrustedOrigins } from "@/actions/signIn/authorization.js" +import { getOriginURL, getTrustedOrigins } from "@/actions/signIn/authorization.js" import { createAccessToken } from "@/actions/callback/access-token.js" import { createSessionCookie, getCookie, expiredCookieAttributes } from "@/cookie.js" import type { JWTPayload } from "@/jose.js" @@ -61,8 +61,9 @@ export const callbackAction = (oauth: OAuthProviderRecord) => { request, params: { oauth }, searchParams: { code, state }, - context: { oauth: providers, cookies, jose, logger, trustedOrigins }, + context } = ctx + const { oauth: providers, cookies, jose, logger, trustedOrigins } = context const oauthConfig = providers[oauth] const cookieState = getCookie(request, cookies.state.name) @@ -84,17 +85,34 @@ export const callbackAction = (oauth: OAuthProviderRecord) => { const accessToken = await createAccessToken(oauthConfig, cookieRedirectURI, code, codeVerifier, logger) const origins = await getTrustedOrigins(request, trustedOrigins) - if (!isRelativeURL(cookieRedirectTo) && !isTrustedOrigin(cookieRedirectTo, origins)) { - logger?.log("POTENTIAL_OPEN_REDIRECT_ATTACK_DETECTED", { - structuredData: { - redirect_path: cookieRedirectTo, - provider: oauth, - }, - }) - throw new AuthSecurityError( - "POTENTIAL_OPEN_REDIRECT_ATTACK_DETECTED", - "Invalid redirect path. Potential open redirect attack detected." - ) + const requestOrigin = await getOriginURL(request, context) + + if (!isRelativeURL(cookieRedirectTo)) { + if (origins.length > 0) { + if (!isTrustedOrigin(cookieRedirectTo, origins)) { + logger?.log("POTENTIAL_OPEN_REDIRECT_ATTACK_DETECTED", { + structuredData: { + redirect_path: cookieRedirectTo, + provider: oauth, + }, + }) + throw new AuthSecurityError( + "POTENTIAL_OPEN_REDIRECT_ATTACK_DETECTED", + "Invalid redirect path. Potential open redirect attack detected." + ) + } + } else if(!isSameOrigin(cookieRedirectTo, requestOrigin)) { + logger?.log("POTENTIAL_OPEN_REDIRECT_ATTACK_DETECTED", { + structuredData: { + redirect_path: cookieRedirectTo, + provider: oauth, + }, + }) + throw new AuthSecurityError( + "POTENTIAL_OPEN_REDIRECT_ATTACK_DETECTED", + "Invalid redirect path. Potential open redirect attack detected." + ) + } } const userInfo = await getUserInfo(oauthConfig, accessToken.access_token, logger) From cc47d93827ae340f70a54c63d484a1e62abb8fa1 Mon Sep 17 00:00:00 2001 From: Hernan Alvarado Date: Sun, 15 Feb 2026 16:46:31 -0500 Subject: [PATCH 4/6] chore: clean up callback code --- .../core/src/actions/callback/callback.ts | 21 ++++++------------- packages/core/test/assert.test.ts | 18 ++++++++++++++++ 2 files changed, 24 insertions(+), 15 deletions(-) diff --git a/packages/core/src/actions/callback/callback.ts b/packages/core/src/actions/callback/callback.ts index d0fee1a6..19cbfa01 100644 --- a/packages/core/src/actions/callback/callback.ts +++ b/packages/core/src/actions/callback/callback.ts @@ -61,7 +61,7 @@ export const callbackAction = (oauth: OAuthProviderRecord) => { request, params: { oauth }, searchParams: { code, state }, - context + context, } = ctx const { oauth: providers, cookies, jose, logger, trustedOrigins } = context @@ -88,24 +88,15 @@ export const callbackAction = (oauth: OAuthProviderRecord) => { const requestOrigin = await getOriginURL(request, context) if (!isRelativeURL(cookieRedirectTo)) { - if (origins.length > 0) { - if (!isTrustedOrigin(cookieRedirectTo, origins)) { - logger?.log("POTENTIAL_OPEN_REDIRECT_ATTACK_DETECTED", { - structuredData: { - redirect_path: cookieRedirectTo, - provider: oauth, - }, - }) - throw new AuthSecurityError( - "POTENTIAL_OPEN_REDIRECT_ATTACK_DETECTED", - "Invalid redirect path. Potential open redirect attack detected." - ) - } - } else if(!isSameOrigin(cookieRedirectTo, requestOrigin)) { + let isValid = + origin.length > 0 ? isTrustedOrigin(cookieRedirectTo, origins) : isSameOrigin(cookieRedirectTo, requestOrigin) + if (!isValid) { logger?.log("POTENTIAL_OPEN_REDIRECT_ATTACK_DETECTED", { structuredData: { redirect_path: cookieRedirectTo, provider: oauth, + has_trusted_origins: origins.length > 0, + request_origin: requestOrigin, }, }) throw new AuthSecurityError( diff --git a/packages/core/test/assert.test.ts b/packages/core/test/assert.test.ts index 9ab710b9..938e4a48 100644 --- a/packages/core/test/assert.test.ts +++ b/packages/core/test/assert.test.ts @@ -422,6 +422,24 @@ describe("isTrustedOrigin", () => { trustedOrigins: ["https://exa*mple.com"], expected: false, }, + { + description: "invalid subdomain depth with wildcard", + url: "https://sub.sub.example.com", + trustedOrigins: ["https://*.example.com"], + expected: false, + }, + { + description: "invalid pattern with wildcard in the middle of the domain", + url: "https://example.com", + trustedOrigins: ["https://example.*.com"], + expected: false, + }, + { + description: "invalid pattern with wildcard in the middle of the domain", + url: "https://api.example.com", + trustedOrigins: ["https://api.*.com"], + expected: false, + }, ] for (const { description, url, trustedOrigins, expected } of testCases) { From 1d2532fa59ec940e1f13060c177e01ea206eea5d Mon Sep 17 00:00:00 2001 From: Hernan Alvarado Date: Sun, 15 Feb 2026 17:15:44 -0500 Subject: [PATCH 5/6] docs(core): expand documentation on wildcard usage --- .../content/docs/configuration/options.mdx | 38 ++++++++++++++++++- .../core/src/actions/callback/callback.ts | 4 +- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/docs/src/content/docs/configuration/options.mdx b/docs/src/content/docs/configuration/options.mdx index 5a46c42f..1c1507e2 100644 --- a/docs/src/content/docs/configuration/options.mdx +++ b/docs/src/content/docs/configuration/options.mdx @@ -205,7 +205,43 @@ export const auth = createAuth({ #### Wildcard Patterns -The `*` wildcard is supported for flexible matching of subdomains and ports. The wildcard can only be used at the start of a subdomain (e.g., `*.example.com`) or as an entire port (e.g., `:3000`). +The `*` wildcard is supported for flexible matching of subdomains and ports. The wildcard can only be used at the start of a subdomain or as an entire port. + + + Use wildcards with caution. Overly permissive patterns can introduce security risks. Always prefer specific origins when + possible. It's recommended to be as restrictive as possible with allowed origins to minimize attacks and risks. Use + `trustedOrigins` carefully and only allow known origins. + + +##### Subdomain Wildcards [!toc] + +Trust one level of subdomains under a specific domain. + +```ts title="@/auth" lineNumbers +import { createAuth } from "@aura-stack/auth" + +export const auth = createAuth({ + oauth: [], + trustedOrigins: ["https://*.example.com"], +}) +``` + +It matches any origin that starts with `https://` and ends with `.example.com`, allowing for any single subdomain level (e.g., `https://app.example.com`, `https://www.example.com`), but not the root domain itself (`https://example.com`) or multiple subdomain levels (`https://sub.app.example.com`). + +##### Port Wildcards [!toc] + +Trust any port on a specific domain. + +```ts title="@/auth" lineNumbers +import { createAuth } from "@aura-stack/auth" + +export const auth = createAuth({ + oauth: [], + trustedOrigins: ["https://example.com:*"], +}) +``` + +It matches any port on `https://example.com` (e.g., `https://example.com:3000`, `https://example.com:3001`, `https://example.com:8080`), but not the domain without a port (`https://example.com`) or with a different protocol (`http://example.com:3000`). | Pattern | Matches | Does Not Match | | ---------------------------- | -------------------------------------------------------------- | ---------------------------------------------------------------------------- | diff --git a/packages/core/src/actions/callback/callback.ts b/packages/core/src/actions/callback/callback.ts index 19cbfa01..aee5d8e9 100644 --- a/packages/core/src/actions/callback/callback.ts +++ b/packages/core/src/actions/callback/callback.ts @@ -89,7 +89,9 @@ export const callbackAction = (oauth: OAuthProviderRecord) => { if (!isRelativeURL(cookieRedirectTo)) { let isValid = - origin.length > 0 ? isTrustedOrigin(cookieRedirectTo, origins) : isSameOrigin(cookieRedirectTo, requestOrigin) + origins.length > 0 + ? isTrustedOrigin(cookieRedirectTo, origins) + : isSameOrigin(cookieRedirectTo, requestOrigin) if (!isValid) { logger?.log("POTENTIAL_OPEN_REDIRECT_ATTACK_DETECTED", { structuredData: { From aae4052c6c610ba7d446a3289ab116326d0e6449 Mon Sep 17 00:00:00 2001 From: Hernan Alvarado Date: Sun, 15 Feb 2026 17:23:23 -0500 Subject: [PATCH 6/6] chore: apply coderabbit suggestion --- packages/core/src/actions/callback/callback.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/actions/callback/callback.ts b/packages/core/src/actions/callback/callback.ts index aee5d8e9..b63dfae6 100644 --- a/packages/core/src/actions/callback/callback.ts +++ b/packages/core/src/actions/callback/callback.ts @@ -88,7 +88,7 @@ export const callbackAction = (oauth: OAuthProviderRecord) => { const requestOrigin = await getOriginURL(request, context) if (!isRelativeURL(cookieRedirectTo)) { - let isValid = + const isValid = origins.length > 0 ? isTrustedOrigin(cookieRedirectTo, origins) : isSameOrigin(cookieRedirectTo, requestOrigin)