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 <noreply@paperclip.ing>
This commit was merged in pull request #219.
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
+4
-2
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
Reference in New Issue
Block a user