diff --git a/packages/api/src/core/cors.test.ts b/packages/api/src/core/cors.test.ts new file mode 100644 index 0000000..3bca97c --- /dev/null +++ b/packages/api/src/core/cors.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect, beforeEach, afterAll } from "bun:test"; +import { addCorsHeaders, corsHeadersFor } from "./cors"; + +const ORIGINAL_ALLOWED = process.env.ALLOWED_ORIGINS; + +function makeReq(origin?: string): Request { + return new Request("http://localhost/api/tasks", { + headers: origin ? { origin } : {}, + }); +} + +describe("ENG-1666 CORS conformance", () => { + beforeEach(() => { + delete process.env.ALLOWED_ORIGINS; + }); + + afterAll(() => { + if (ORIGINAL_ALLOWED === undefined) { + delete process.env.ALLOWED_ORIGINS; + } else { + process.env.ALLOWED_ORIGINS = ORIGINAL_ALLOWED; + } + }); + + describe("corsHeadersFor", () => { + it("never emits wildcard by default", () => { + const headers = corsHeadersFor(makeReq("https://evil.example.com")); + expect(headers["Access-Control-Allow-Origin"]).toBeUndefined(); + }); + + it("echoes origin when in the allowlist", () => { + process.env.ALLOWED_ORIGINS = + "https://app.example.com,https://admin.example.com"; + const headers = corsHeadersFor(makeReq("https://app.example.com")); + expect(headers["Access-Control-Allow-Origin"]).toBe( + "https://app.example.com", + ); + }); + + it("omits Allow-Origin for origins not in the allowlist", () => { + process.env.ALLOWED_ORIGINS = "https://app.example.com"; + const headers = corsHeadersFor(makeReq("https://evil.example.com")); + expect(headers["Access-Control-Allow-Origin"]).toBeUndefined(); + }); + + it("trims whitespace in the comma-separated list", () => { + process.env.ALLOWED_ORIGINS = + " https://app.example.com , https://admin.example.com "; + const headers = corsHeadersFor(makeReq("https://admin.example.com")); + expect(headers["Access-Control-Allow-Origin"]).toBe( + "https://admin.example.com", + ); + }); + + it("supports explicit wildcard opt-in", () => { + process.env.ALLOWED_ORIGINS = "*"; + const headers = corsHeadersFor(makeReq("https://anything.example.com")); + expect(headers["Access-Control-Allow-Origin"]).toBe("*"); + }); + + it("omits Allow-Origin for requests without Origin header", () => { + process.env.ALLOWED_ORIGINS = "https://app.example.com"; + const headers = corsHeadersFor(makeReq()); + expect(headers["Access-Control-Allow-Origin"]).toBeUndefined(); + }); + + it("always includes Vary: Origin and method/header allowances", () => { + const headers = corsHeadersFor(makeReq("https://app.example.com")); + expect(headers.Vary).toBe("Origin"); + expect(headers["Access-Control-Allow-Methods"]).toContain("OPTIONS"); + expect(headers["Access-Control-Allow-Headers"]).toContain( + "Authorization", + ); + }); + }); + + describe("addCorsHeaders", () => { + it("adds CORS headers to a response preserving status and body", async () => { + process.env.ALLOWED_ORIGINS = "https://app.example.com"; + const res = addCorsHeaders( + Response.json({ ok: true }, { status: 201 }), + makeReq("https://app.example.com"), + ); + expect(res.status).toBe(201); + expect(res.headers.get("Access-Control-Allow-Origin")).toBe( + "https://app.example.com", + ); + expect(res.headers.get("Vary")).toBe("Origin"); + expect(await res.json()).toEqual({ ok: true }); + }); + + it("does not add Allow-Origin for untrusted origins", () => { + process.env.ALLOWED_ORIGINS = "https://app.example.com"; + const res = addCorsHeaders( + Response.json({ ok: true }), + makeReq("https://evil.example.com"), + ); + expect(res.headers.get("Access-Control-Allow-Origin")).toBeNull(); + }); + }); +}); diff --git a/packages/api/src/core/cors.ts b/packages/api/src/core/cors.ts new file mode 100644 index 0000000..ba69913 --- /dev/null +++ b/packages/api/src/core/cors.ts @@ -0,0 +1,45 @@ +// ============================================ +// CORS — restrito a origens confiáveis (ENG-1666) +// ============================================ +// +// ALLOWED_ORIGINS: lista comma-separated de origens confiáveis. +// Ex.: ALLOWED_ORIGINS="https://app.example.com,https://admin.example.com" +// "*" pode ser incluído explicitamente para opt-in de wildcard (não recomendado). +// Sem match (ou sem ALLOWED_ORIGINS configurado), nenhum +// Access-Control-Allow-Origin é emitido — secure by default. + +function getAllowedOrigins(): string[] { + return (process.env.ALLOWED_ORIGINS ?? "") + .split(",") + .map((o) => o.trim()) + .filter(Boolean); +} + +export function corsHeadersFor(req: Request): Record { + const headers: Record = { + "Access-Control-Allow-Methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization", + Vary: "Origin", + }; + const origin = req.headers.get("origin"); + if (!origin) return headers; + const allowed = getAllowedOrigins(); + if (allowed.includes("*")) { + headers["Access-Control-Allow-Origin"] = "*"; + } else if (allowed.includes(origin)) { + headers["Access-Control-Allow-Origin"] = origin; + } + return headers; +} + +export function addCorsHeaders(response: Response, req: Request): Response { + const newHeaders = new Headers(response.headers); + for (const [key, value] of Object.entries(corsHeadersFor(req))) { + newHeaders.set(key, value); + } + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers: newHeaders, + }); +} diff --git a/packages/api/src/router.ts b/packages/api/src/router.ts index 57060e7..199a0dd 100644 --- a/packages/api/src/router.ts +++ b/packages/api/src/router.ts @@ -24,6 +24,7 @@ import { addRateLimitHeaders, getRateLimitStats, } from "./core/rate-limiter"; +import { addCorsHeaders, corsHeadersFor } from "./core/cors"; import { generateOpenAPISpec, getOpenAPIJSON } from "./core/openapi"; import { generateSwaggerHTML, generateReDocHTML } from "./core/swagger-ui"; import { VisualTestRunner } from "./agents/computer-use/visual-test-runner"; @@ -4363,7 +4364,7 @@ route("GET", "/api/logs/stream", async (req) => { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", Connection: "keep-alive", - "Access-Control-Allow-Origin": "*", + ...corsHeadersFor(req), }, }); }); @@ -7741,24 +7742,7 @@ route("POST", "/api/plan-conversations/:id/convert", async (req) => { // Router // ============================================ -// CORS headers for cross-origin requests -const CORS_HEADERS = { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type, Authorization", -}; - -function addCorsHeaders(response: Response): Response { - const newHeaders = new Headers(response.headers); - for (const [key, value] of Object.entries(CORS_HEADERS)) { - newHeaders.set(key, value); - } - return new Response(response.body, { - status: response.status, - statusText: response.statusText, - headers: newHeaders, - }); -} +// CORS restrito a origens confiáveis via ALLOWED_ORIGINS (ENG-1666) — ver core/cors.ts export async function handleRequest(req: Request): Promise { const url = new URL(req.url); @@ -7767,13 +7751,13 @@ export async function handleRequest(req: Request): Promise { // Handle CORS preflight requests if (method === "OPTIONS") { - return new Response(null, { status: 204, headers: CORS_HEADERS }); + return new Response(null, { status: 204, headers: corsHeadersFor(req) }); } // Apply rate limiting const rateLimitResponse = rateLimitMiddleware(req); if (rateLimitResponse) { - return addCorsHeaders(rateLimitResponse); + return addCorsHeaders(rateLimitResponse, req); } for (const route of routes) { @@ -7781,15 +7765,19 @@ export async function handleRequest(req: Request): Promise { try { const response = await route.handler(req); // Add rate limit headers and CORS headers to successful responses - return addCorsHeaders(addRateLimitHeaders(response, req)); + return addCorsHeaders(addRateLimitHeaders(response, req), req); } catch (error) { console.error(`[Router] Error handling ${method} ${path}:`, error); return addCorsHeaders( Response.json({ error: "Internal server error" }, { status: 500 }), + req, ); } } } - return addCorsHeaders(Response.json({ error: "Not found" }, { status: 404 })); + return addCorsHeaders( + Response.json({ error: "Not found" }, { status: 404 }), + req, + ); }