diff --git a/src/auth.ts b/src/auth.ts index 41f298a..3bd935d 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -8,6 +8,7 @@ import { createAuthPlugins } from "./plugins"; export interface AuthRuntime { waitUntil?: (promise: Promise) => void; + requestOrigin?: string | null; } export function createAuth(env: Env, runtime: AuthRuntime = {}) { @@ -17,7 +18,7 @@ export function createAuth(env: Env, runtime: AuthRuntime = {}) { database: env.DB, secret: env.BETTER_AUTH_SECRET ?? "development-secret-change-before-production", baseURL: env.BETTER_AUTH_URL, - trustedOrigins: trustedOrigins(env), + trustedOrigins: trustedOrigins(env, runtime.requestOrigin), session: { cookieCache: { enabled: true, diff --git a/src/env.ts b/src/env.ts index 321a3c6..bd2d4b5 100644 --- a/src/env.ts +++ b/src/env.ts @@ -59,10 +59,52 @@ export function optionalEnv( return source[key] || undefined; } -export function trustedOrigins(env: Env): string[] { - return [...csvEnv(env.TRUSTED_ORIGINS), ...expoOrigins(env)]; +export function trustedOrigins(env: Env, requestOrigin?: string | null): string[] { + const origins = [...csvEnv(env.TRUSTED_ORIGINS), ...expoOrigins(env)]; + + if (requestOrigin && isTrustedOrigin(requestOrigin, origins) && !origins.includes(requestOrigin)) { + origins.push(requestOrigin); + } + + return origins; +} + +export function isTrustedOrigin(origin: string, trustedOrigins: string[]): boolean { + if (trustedOrigins.includes(origin)) { + return true; + } + + const originUrl = parseOrigin(origin); + if (!originUrl) { + return false; + } + + return trustedOrigins.some((trustedOrigin) => { + const trustedUrl = parseOrigin(trustedOrigin); + if (!trustedUrl || !isLoopbackHostname(trustedUrl.hostname) || trustedUrl.port) { + return false; + } + + return ( + trustedUrl.protocol === originUrl.protocol && + trustedUrl.hostname === originUrl.hostname && + isLoopbackHostname(originUrl.hostname) + ); + }); } export function expoOrigins(env: Env): string[] { return csvEnv(env.EXPO_SCHEME).map((scheme) => `${scheme}://`); } + +function parseOrigin(origin: string): URL | undefined { + try { + return new URL(origin); + } catch { + return undefined; + } +} + +function isLoopbackHostname(hostname: string): boolean { + return hostname === "localhost" || hostname === "127.0.0.1"; +} diff --git a/src/index.ts b/src/index.ts index 6fb7b5c..95875cd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,7 @@ import { Hono } from "hono"; import { cors } from "hono/cors"; import { createAuth } from "./auth"; -import { trustedOrigins, type Env } from "./env"; +import { isTrustedOrigin, trustedOrigins, type Env } from "./env"; import { withAuthRequestLogging } from "./observability"; const app = new Hono<{ Bindings: Env }>(); @@ -11,7 +11,7 @@ app.use( cors({ origin: (origin, c) => { const allowed = trustedOrigins(c.env); - return origin && allowed.includes(origin) ? origin : null; + return origin && isTrustedOrigin(origin, allowed) ? origin : null; }, allowHeaders: ["Content-Type", "Authorization"], allowMethods: ["GET", "POST", "OPTIONS"], @@ -24,6 +24,7 @@ app.on(["POST", "GET"], "/api/auth/*", (c) => { waitUntil: (promise) => { c.executionCtx.waitUntil(promise); }, + requestOrigin: c.req.header("Origin"), }); return withAuthRequestLogging(c.req.raw, () => auth.handler(c.req.raw)); }); diff --git a/tests/auth-config.test.ts b/tests/auth-config.test.ts index be8d782..efcd8d2 100644 --- a/tests/auth-config.test.ts +++ b/tests/auth-config.test.ts @@ -4,6 +4,7 @@ import { createAuth } from "../src/auth"; import { booleanEnv, csvEnv, + isTrustedOrigin, optionalEnv, requiredEnv, trustedOrigins, @@ -29,6 +30,34 @@ describe("auth env helpers", () => { expect(trustedOrigins(env)).toEqual(["http://localhost:8787", "https://app.example.com"]); }); + it("allows loopback trusted origins to match any port", () => { + const trusted = ["http://localhost", "http://127.0.0.1", "https://app.example.com"]; + + expect(isTrustedOrigin("http://localhost:5173", trusted)).toBe(true); + expect(isTrustedOrigin("http://localhost:9999", trusted)).toBe(true); + expect(isTrustedOrigin("http://127.0.0.1:5173", trusted)).toBe(true); + expect(isTrustedOrigin("https://app.example.com", trusted)).toBe(true); + }); + + it("does not allow non-loopback trusted origins to match arbitrary ports", () => { + const trusted = ["https://app.example.com"]; + + expect(isTrustedOrigin("https://app.example.com:8443", trusted)).toBe(false); + expect(isTrustedOrigin("https://evil.example.com", trusted)).toBe(false); + }); + + it("adds a trusted dynamic loopback origin for request-scoped auth config", () => { + expect( + trustedOrigins( + { + ...env, + TRUSTED_ORIGINS: "http://localhost,https://app.example.com", + }, + "http://localhost:5174", + ), + ).toContain("http://localhost:5174"); + }); + it("adds Expo app schemes to trusted origins", () => { expect(trustedOrigins({ ...env, EXPO_SCHEME: "cfwauth" })).toEqual([ "http://localhost:8787", @@ -68,6 +97,20 @@ describe("production auth config", () => { expect(csvEnv(config.vars.TRUSTED_ORIGINS)).toContain("https://web.bowong.cc"); }); + + it("allows gateway and local Web Shell origins in Wrangler trusted origins", () => { + const config = JSON.parse(readFileSync("wrangler.jsonc", "utf8")) as { + vars: { TRUSTED_ORIGINS: string }; + }; + + expect(csvEnv(config.vars.TRUSTED_ORIGINS)).toEqual( + expect.arrayContaining([ + "https://cfw-gateway.bowong.cc", + "http://localhost", + "http://127.0.0.1", + ]), + ); + }); }); describe("auth performance config", () => { diff --git a/tests/auth-worker.test.ts b/tests/auth-worker.test.ts index 82ed711..ea5aa86 100644 --- a/tests/auth-worker.test.ts +++ b/tests/auth-worker.test.ts @@ -72,6 +72,22 @@ describe("cfw-auth worker", () => { expect(response.headers.get("access-control-allow-origin")).toBe("http://localhost:8787"); }); + it("applies CORS for loopback origins without restricting the port", async () => { + const response = await worker.fetch( + new Request("http://auth.local/api/auth/reference", { + headers: { + Origin: "http://localhost:5174", + }, + }), + { + ...env, + TRUSTED_ORIGINS: "http://localhost", + }, + ); + + expect(response.headers.get("access-control-allow-origin")).toBe("http://localhost:5174"); + }); + it("does not fall back to the first configured CORS origin for unknown origins", async () => { const response = await worker.fetch( new Request("http://auth.local/api/auth/reference", { diff --git a/wrangler.jsonc b/wrangler.jsonc index 85e7b47..2be3227 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -20,7 +20,7 @@ "vars": { "NODE_ENV": "production", "BETTER_AUTH_URL": "https://cfw-auth.bowong.cc", - "TRUSTED_ORIGINS": "https://mixvideo.bowong.cc,https://admin.mixvideo.bowong.cc,https://app.ad1dollar.com,https://web.bowong.cc", + "TRUSTED_ORIGINS": "https://mixvideo.bowong.cc,https://admin.mixvideo.bowong.cc,https://app.ad1dollar.com,https://web.bowong.cc,https://cfw-gateway.bowong.cc,http://localhost,http://127.0.0.1", "MAIL_PROVIDER": "resend", "MAIL_FROM": "support@bowong.ai", "CAPTCHA_PROVIDER": "cloudflare-turnstile",