From e6051c8c0a58ac4906dc2a9d9df97e2f45d0ebf4 Mon Sep 17 00:00:00 2001 From: Ronaldo Martins Date: Mon, 10 Aug 2026 17:44:25 -0300 Subject: [PATCH] =?UTF-8?q?feat(api):=20CORS=20restrito=20a=20origens=20co?= =?UTF-8?q?nfi=C3=A1veis=20via=20ALLOWED=5FORIGINS=20(ENG-1666)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove Access-Control-Allow-Origin: * (router e endpoint SSE) - Novo módulo core/cors.ts: allowlist via ALLOWED_ORIGINS (comma-separated) - Echo do Origin apenas para origens confiáveis + Vary: Origin - Wildcard só com opt-in explícito; secure by default sem config - Aplicado a todos os endpoints (preflight, respostas, erros, SSE) - 9 testes de conformidade em core/cors.test.ts --- packages/api/src/core/cors.test.ts | 101 +++++++++++++++++++++++++++++ packages/api/src/core/cors.ts | 45 +++++++++++++ packages/api/src/router.ts | 34 ++++------ 3 files changed, 157 insertions(+), 23 deletions(-) create mode 100644 packages/api/src/core/cors.test.ts create mode 100644 packages/api/src/core/cors.ts 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, + ); }