feat: enhance CORS handling to support dynamic loopback origins and update trusted origins logic
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
46
src/env.ts
46
src/env.ts
@@ -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";
|
||||||
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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", {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user