feat: enhance CORS handling to support dynamic loopback origins and update trusted origins logic

This commit is contained in:
2026-06-12 03:37:36 -07:00
parent c93010acab
commit 8b32529749
6 changed files with 109 additions and 6 deletions

View File

@@ -8,6 +8,7 @@ import { createAuthPlugins } from "./plugins";
export interface AuthRuntime { export interface AuthRuntime {
waitUntil?: (promise: Promise<unknown>) => void; waitUntil?: (promise: Promise<unknown>) => void;
requestOrigin?: string | null;
} }
export function createAuth(env: Env, runtime: AuthRuntime = {}) { export function createAuth(env: Env, runtime: AuthRuntime = {}) {
@@ -17,7 +18,7 @@ export function createAuth(env: Env, runtime: AuthRuntime = {}) {
database: env.DB, database: env.DB,
secret: env.BETTER_AUTH_SECRET ?? "development-secret-change-before-production", secret: env.BETTER_AUTH_SECRET ?? "development-secret-change-before-production",
baseURL: env.BETTER_AUTH_URL, baseURL: env.BETTER_AUTH_URL,
trustedOrigins: trustedOrigins(env), trustedOrigins: trustedOrigins(env, runtime.requestOrigin),
session: { session: {
cookieCache: { cookieCache: {
enabled: true, enabled: true,

View File

@@ -59,10 +59,52 @@ export function optionalEnv(
return source[key] || undefined; return source[key] || undefined;
} }
export function trustedOrigins(env: Env): string[] { export function trustedOrigins(env: Env, requestOrigin?: string | null): string[] {
return [...csvEnv(env.TRUSTED_ORIGINS), ...expoOrigins(env)]; 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[] { export function expoOrigins(env: Env): string[] {
return csvEnv(env.EXPO_SCHEME).map((scheme) => `${scheme}://`); 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";
}

View File

@@ -1,7 +1,7 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { cors } from "hono/cors"; import { cors } from "hono/cors";
import { createAuth } from "./auth"; import { createAuth } from "./auth";
import { trustedOrigins, type Env } from "./env"; import { isTrustedOrigin, trustedOrigins, type Env } from "./env";
import { withAuthRequestLogging } from "./observability"; import { withAuthRequestLogging } from "./observability";
const app = new Hono<{ Bindings: Env }>(); const app = new Hono<{ Bindings: Env }>();
@@ -11,7 +11,7 @@ app.use(
cors({ cors({
origin: (origin, c) => { origin: (origin, c) => {
const allowed = trustedOrigins(c.env); const allowed = trustedOrigins(c.env);
return origin && allowed.includes(origin) ? origin : null; return origin && isTrustedOrigin(origin, allowed) ? origin : null;
}, },
allowHeaders: ["Content-Type", "Authorization"], allowHeaders: ["Content-Type", "Authorization"],
allowMethods: ["GET", "POST", "OPTIONS"], allowMethods: ["GET", "POST", "OPTIONS"],
@@ -24,6 +24,7 @@ app.on(["POST", "GET"], "/api/auth/*", (c) => {
waitUntil: (promise) => { waitUntil: (promise) => {
c.executionCtx.waitUntil(promise); c.executionCtx.waitUntil(promise);
}, },
requestOrigin: c.req.header("Origin"),
}); });
return withAuthRequestLogging(c.req.raw, () => auth.handler(c.req.raw)); return withAuthRequestLogging(c.req.raw, () => auth.handler(c.req.raw));
}); });

View File

@@ -4,6 +4,7 @@ import { createAuth } from "../src/auth";
import { import {
booleanEnv, booleanEnv,
csvEnv, csvEnv,
isTrustedOrigin,
optionalEnv, optionalEnv,
requiredEnv, requiredEnv,
trustedOrigins, trustedOrigins,
@@ -29,6 +30,34 @@ describe("auth env helpers", () => {
expect(trustedOrigins(env)).toEqual(["http://localhost:8787", "https://app.example.com"]); 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", () => { it("adds Expo app schemes to trusted origins", () => {
expect(trustedOrigins({ ...env, EXPO_SCHEME: "cfwauth" })).toEqual([ expect(trustedOrigins({ ...env, EXPO_SCHEME: "cfwauth" })).toEqual([
"http://localhost:8787", "http://localhost:8787",
@@ -68,6 +97,20 @@ describe("production auth config", () => {
expect(csvEnv(config.vars.TRUSTED_ORIGINS)).toContain("https://web.bowong.cc"); 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", () => { describe("auth performance config", () => {

View File

@@ -72,6 +72,22 @@ describe("cfw-auth worker", () => {
expect(response.headers.get("access-control-allow-origin")).toBe("http://localhost:8787"); 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 () => { it("does not fall back to the first configured CORS origin for unknown origins", async () => {
const response = await worker.fetch( const response = await worker.fetch(
new Request("http://auth.local/api/auth/reference", { new Request("http://auth.local/api/auth/reference", {

View File

@@ -20,7 +20,7 @@
"vars": { "vars": {
"NODE_ENV": "production", "NODE_ENV": "production",
"BETTER_AUTH_URL": "https://cfw-auth.bowong.cc", "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_PROVIDER": "resend",
"MAIL_FROM": "support@bowong.ai", "MAIL_FROM": "support@bowong.ai",
"CAPTCHA_PROVIDER": "cloudflare-turnstile", "CAPTCHA_PROVIDER": "cloudflare-turnstile",