From e09490babe4b3925a6bed6783442b891bd034cb2 Mon Sep 17 00:00:00 2001 From: Flea Flicker <22+gb_flea@noreply.git.farh.net> Date: Fri, 26 Jun 2026 13:35:59 +0000 Subject: [PATCH] fix(GRO-2586): enforce trusted-origins allowlist on Better Auth CORS responses (#219) fix(GRO-2586): enforce trusted-origins allowlist on Better Auth CORS responses Co-Authored-By: Paperclip --- UAT_PLAYBOOK.md | 3 ++ src/__tests__/authCors.test.ts | 60 ++++++++++++++++++++++++++++++++++ src/index.ts | 6 ++-- src/lib/auth-cors.ts | 22 +++++++++++++ 4 files changed, 89 insertions(+), 2 deletions(-) create mode 100644 src/__tests__/authCors.test.ts create mode 100644 src/lib/auth-cors.ts diff --git a/UAT_PLAYBOOK.md b/UAT_PLAYBOOK.md index 66ef0d5..60acc87 100644 --- a/UAT_PLAYBOOK.md +++ b/UAT_PLAYBOOK.md @@ -110,6 +110,9 @@ Expected: one row, `role = 'groomer'`. If zero rows return, the request hit the | TC-API-1.26 | Auto-provision skipped during OOBE | During fresh setup (needsSetup: true), complete OIDC login — verify no duplicate staff record created before setup completes | No duplicate staff, OOBE completes successfully | Duplicate staff record, 403 before setup, auto-provision interferes with OOBE | | TC-API-1.27 | Multi-origin CORS — demo host sign-in | `POST /api/auth/sign-in/social` with `callbackURL=https://demo.groombook.dev` | 200 OK, no origin-mismatch error | 400/403 "Origin mismatch" | | TC-API-1.28 | Multi-origin CORS — farh.net host sign-in | `POST /api/auth/sign-in/social` with `callbackURL=https://groombook.farh.net` | 200 OK, no origin-mismatch error | 400/403 "Origin mismatch" | +| TC-API-1.29 | CORS — untrusted origin blocked (GRO-2586) | POST /api/auth/sign-in/social with `Origin: https://evil.example.com` header | Response has **no** `Access-Control-Allow-Origin` header — attacker origin is not reflected | `Access-Control-Allow-Origin: https://evil.example.com` present in response | +| TC-API-1.30 | CORS — trusted origin allowed (GRO-2586) | POST /api/auth/sign-in/social with `Origin: https://uat.groombook.dev` header | `Access-Control-Allow-Origin: https://uat.groombook.dev` + `Access-Control-Allow-Credentials: true` | CORS header absent or trusted origin rejected | +| TC-API-1.31 | CORS — untrusted preflight blocked (GRO-2586) | `curl -i -X OPTIONS https://uat.groombook.dev/api/auth/sign-in/social -H 'Origin: https://evil.example.com' -H 'Access-Control-Request-Method: POST'` | Response has **no** `Access-Control-Allow-Origin: https://evil.example.com` | Preflight reflects attacker origin | ### 4.2 Client Management diff --git a/src/__tests__/authCors.test.ts b/src/__tests__/authCors.test.ts new file mode 100644 index 0000000..2603279 --- /dev/null +++ b/src/__tests__/authCors.test.ts @@ -0,0 +1,60 @@ +import { describe, it, expect } from "vitest"; +import { enforceAuthCors } from "../lib/auth-cors.js"; + +const TRUSTED = ["https://uat.groombook.dev", "https://dev.groombook.dev"]; + +/** Simulates Better Auth reflecting the request Origin (the pre-fix bug). */ +function makeReflectedResponse(origin: string | null): Response { + return new Response('{"ok":true}', { + status: 200, + headers: { + "Content-Type": "application/json", + ...(origin + ? { + "Access-Control-Allow-Origin": origin, + "Access-Control-Allow-Credentials": "true", + } + : {}), + }, + }); +} + +describe("enforceAuthCors (GRO-2586)", () => { + it("passes trusted origin through with credentials", () => { + const origin = "https://uat.groombook.dev"; + const res = enforceAuthCors(origin, TRUSTED, makeReflectedResponse(origin)); + expect(res.headers.get("Access-Control-Allow-Origin")).toBe(origin); + expect(res.headers.get("Access-Control-Allow-Credentials")).toBe("true"); + }); + + it("strips ACAO for attacker origin (credentialed cross-origin read blocked)", () => { + const origin = "https://evil.example.com"; + const res = enforceAuthCors(origin, TRUSTED, makeReflectedResponse(origin)); + expect(res.headers.get("Access-Control-Allow-Origin")).toBeNull(); + expect(res.headers.get("Access-Control-Allow-Credentials")).toBeNull(); + }); + + it("strips ACAO when no Origin header (undefined)", () => { + const res = enforceAuthCors(undefined, TRUSTED, makeReflectedResponse(null)); + expect(res.headers.get("Access-Control-Allow-Origin")).toBeNull(); + expect(res.headers.get("Access-Control-Allow-Credentials")).toBeNull(); + }); + + it("preserves non-CORS response headers and status from Better Auth", () => { + const origin = "https://evil.example.com"; + const res = enforceAuthCors(origin, TRUSTED, makeReflectedResponse(origin)); + expect(res.headers.get("Content-Type")).toBe("application/json"); + expect(res.status).toBe(200); + }); + + it("second trusted origin is also allowed", () => { + const origin = "https://dev.groombook.dev"; + const res = enforceAuthCors(origin, TRUSTED, makeReflectedResponse(origin)); + expect(res.headers.get("Access-Control-Allow-Origin")).toBe(origin); + }); + + it("empty string origin is treated as untrusted", () => { + const res = enforceAuthCors("", TRUSTED, makeReflectedResponse("")); + expect(res.headers.get("Access-Control-Allow-Origin")).toBeNull(); + }); +}); diff --git a/src/index.ts b/src/index.ts index 681d731..6c0c930 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,7 @@ import { Hono } from "hono"; import { logger } from "hono/logger"; import { cors } from "hono/cors"; import { getAuth, initAuth, getActiveProviders } from "./lib/auth.js"; +import { enforceAuthCors } from "./lib/auth-cors.js"; import { clientsRouter } from "./routes/clients.js"; import { petsRouter } from "./routes/pets.js"; import { servicesRouter } from "./routes/services.js"; @@ -200,9 +201,10 @@ api.use("*", resolveStaffMiddleware); // Better-Auth handler — mounted as sub-app to handle all /api/auth/* routes // authMiddleware and resolveStaffMiddleware both skip /api/auth/ paths const authRouter = new Hono(); -authRouter.all("/*", (c) => { +authRouter.all("/*", async (c) => { try { - return getAuth().handler(c.req.raw); + const res = await getAuth().handler(c.req.raw); + return enforceAuthCors(c.req.header("origin"), TRUSTED_ORIGINS, res); } catch { return c.json({ error: "Authentication not configured" }, 503); } diff --git a/src/lib/auth-cors.ts b/src/lib/auth-cors.ts new file mode 100644 index 0000000..bd68f38 --- /dev/null +++ b/src/lib/auth-cors.ts @@ -0,0 +1,22 @@ +/** + * Enforces the trusted-origins CORS allowlist on a raw Response from Better Auth. + * Better Auth reflects the request Origin into Access-Control-Allow-Origin + * regardless of the trustedOrigins config, allowing credentialed cross-origin reads + * from arbitrary attacker origins. This wrapper strips CORS headers for any origin + * not in the allowlist. (GRO-2586) + */ +export function enforceAuthCors( + requestOrigin: string | undefined, + trustedOrigins: string[], + res: Response +): Response { + const headers = new Headers(res.headers); + if (requestOrigin && trustedOrigins.includes(requestOrigin)) { + headers.set("Access-Control-Allow-Origin", requestOrigin); + headers.set("Access-Control-Allow-Credentials", "true"); + } else { + headers.delete("Access-Control-Allow-Origin"); + headers.delete("Access-Control-Allow-Credentials"); + } + return new Response(res.body, { status: res.status, statusText: res.statusText, headers }); +} -- 2.52.0