From a04244f53161615ea5f457878271a3ede457e20b Mon Sep 17 00:00:00 2001 From: heliosran Date: Fri, 13 Mar 2026 15:50:48 +0000 Subject: [PATCH] Harden CORS with explicit origin allowlist --- .dev.vars.example | 4 +- README.md | 12 ++++++ src/index.ts | 64 +++++++++++++++++++++++++------- src/types.ts | 1 + test/index.spec.ts | 91 ++++++++++++++++++++++++++++++++++++++-------- vitest.config.mts | 2 +- 6 files changed, 143 insertions(+), 31 deletions(-) diff --git a/.dev.vars.example b/.dev.vars.example index b8eafe0..8e9bb9d 100644 --- a/.dev.vars.example +++ b/.dev.vars.example @@ -21,4 +21,6 @@ DEBUG_MODEL= REASONING_EFFORT=medium # Options: minimal, low, medium, high REASONING_SUMMARY=auto # Options: auto, on, off REASONING_COMPAT=think-tags # Options: think-tags, standard -VERBOSE=false # Options: true, false \ No newline at end of file +VERBOSE=false # Options: true, false +# Optional CORS allowlist (comma-separated exact origins) +ALLOWED_ORIGINS=https://app.example.com diff --git a/README.md b/README.md index d5c3966..2ea104c 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,9 @@ REASONING_COMPAT=think-tags # Optional: Debug settings VERBOSE=false DEBUG_MODEL= + +# Optional: CORS allowlist (comma-separated exact origins) +ALLOWED_ORIGINS=https://app.example.com,https://admin.example.com ``` For production, set the secrets: @@ -243,6 +246,15 @@ The service will be available at `http://localhost:8787` | `OLLAMA_API_URL` | `http://localhost:11434` | Ollama instance URL for local model integration | | `DEBUG_MODEL` | - | Override model for debugging purposes | | `VERBOSE` | `false` | Enable detailed debug logging | +| `ALLOWED_ORIGINS` | - | Comma-separated exact origins allowed for CORS (for example: `https://app.example.com,https://admin.example.com`) | + + +#### CORS Configuration + +- CORS is deny-by-default unless an origin is listed in `ALLOWED_ORIGINS`. +- `Access-Control-Allow-Credentials: true` is only returned for explicitly allowlisted origins. +- Allowed methods are restricted to `POST`, `GET`, and `OPTIONS`. +- Allowed request headers are restricted to `Content-Type` and `Authorization`. #### Authentication Security diff --git a/src/index.ts b/src/index.ts index 2be8362..ad4959b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,21 +1,57 @@ import { Hono } from "hono"; -import { cors } from "hono/cors"; import openai from "./routes/openai"; // Import the openai router import ollama from "./routes/ollama"; // Import the ollama router +import type { Env } from "./types"; -const app = new Hono(); - -app.use( - "*", - cors({ - origin: "*", // Or specify allowed origins - allowHeaders: ["Content-Type", "Authorization", "OpenAI-Beta", "chatgpt-account-id"], - allowMethods: ["POST", "GET", "OPTIONS"], - exposeHeaders: ["Content-Length", "X-Kuma-Revision"], - maxAge: 600, - credentials: true - }) -); +const DEFAULT_ALLOWED_HEADERS = ["Content-Type", "Authorization"]; +const DEFAULT_ALLOWED_METHODS = ["POST", "GET", "OPTIONS"]; + +const parseAllowedOrigins = (allowedOrigins?: string): string[] => { + if (!allowedOrigins) { + return []; + } + + return allowedOrigins + .split(",") + .map((origin) => origin.trim()) + .filter((origin) => origin.length > 0); +}; + +const resolveAllowedOrigin = (origin: string, allowedOrigins: string[]): string | null => { + if (!origin || allowedOrigins.length === 0) { + return null; + } + + return allowedOrigins.includes(origin) ? origin : null; +}; + +const app = new Hono<{ Bindings: Env }>(); + +app.use("*", async (c, next) => { + const origin = c.req.header("origin") || ""; + const allowedOrigin = resolveAllowedOrigin(origin, parseAllowedOrigins(c.env.ALLOWED_ORIGINS)); + + if (allowedOrigin) { + c.header("Access-Control-Allow-Origin", allowedOrigin); + c.header("Vary", "Origin"); + } + + if (c.req.method === "OPTIONS") { + if (allowedOrigin) { + c.header("Access-Control-Allow-Methods", DEFAULT_ALLOWED_METHODS.join(",")); + c.header("Access-Control-Allow-Headers", DEFAULT_ALLOWED_HEADERS.join(",")); + c.header("Access-Control-Max-Age", "600"); + c.header("Access-Control-Allow-Credentials", "true"); + } + return c.body(null, 204); + } + + await next(); + + if (allowedOrigin) { + c.header("Access-Control-Allow-Credentials", "true"); + } +}); app.get("/", (c) => c.json({ status: "ok" })); diff --git a/src/types.ts b/src/types.ts index 65c43a5..72f7ed1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -22,6 +22,7 @@ export interface Env { REASONING_SUMMARY?: ReasoningSummary; REASONING_COMPAT?: ReasoningCompat; VERBOSE?: VerboseMode; + ALLOWED_ORIGINS?: string; } export type AuthTokens = { diff --git a/test/index.spec.ts b/test/index.spec.ts index 5197296..baf337f 100644 --- a/test/index.spec.ts +++ b/test/index.spec.ts @@ -1,24 +1,85 @@ -import { env, createExecutionContext, waitOnExecutionContext, SELF } from 'cloudflare:test'; -import { describe, it, expect } from 'vitest'; -import worker from '../src/index'; +import { env, createExecutionContext, waitOnExecutionContext } from "cloudflare:test"; +import { describe, it, expect } from "vitest"; +import worker from "../src/index"; -// For now, you'll need to do something like this to get a correctly-typed -// `Request` to pass to `worker.fetch()`. const IncomingRequest = Request; -describe('Hello World worker', () => { - it('responds with Hello World! (unit style)', async () => { - const request = new IncomingRequest('http://example.com'); - // Create an empty context to pass to `worker.fetch()`. +describe("CORS configuration", () => { + it("returns CORS headers and credentials for allowlisted origin preflight", async () => { + const request = new IncomingRequest("http://example.com/v1/chat/completions", { + method: "OPTIONS", + headers: { + Origin: "https://app.example.com", + "Access-Control-Request-Method": "POST" + } + }); const ctx = createExecutionContext(); - const response = await worker.fetch(request, env, ctx); - // Wait for all `Promise`s passed to `ctx.waitUntil()` to settle before running test assertions + const response = await worker.fetch( + request, + { ...env, ALLOWED_ORIGINS: "https://app.example.com,https://admin.example.com" }, + ctx + ); await waitOnExecutionContext(ctx); - expect(await response.text()).toMatchInlineSnapshot(`"Hello World!"`); + + expect(response.status).toBe(204); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe("https://app.example.com"); + expect(response.headers.get("Access-Control-Allow-Credentials")).toBe("true"); + expect(response.headers.get("Access-Control-Allow-Methods")).toBe("POST,GET,OPTIONS"); + expect(response.headers.get("Access-Control-Allow-Headers")).toBe("Content-Type,Authorization"); + }); + + it("does not return origin or credential headers for disallowed origin preflight", async () => { + const request = new IncomingRequest("http://example.com/v1/chat/completions", { + method: "OPTIONS", + headers: { + Origin: "https://evil.example.com", + "Access-Control-Request-Method": "POST" + } + }); + const ctx = createExecutionContext(); + const response = await worker.fetch( + request, + { ...env, ALLOWED_ORIGINS: "https://app.example.com" }, + ctx + ); + await waitOnExecutionContext(ctx); + + expect(response.status).toBe(204); + expect(response.headers.get("Access-Control-Allow-Origin")).toBeNull(); + expect(response.headers.get("Access-Control-Allow-Credentials")).toBeNull(); + }); + + it("returns origin and credentials for allowlisted origin on regular responses", async () => { + const request = new IncomingRequest("http://example.com/health", { + headers: { + Origin: "https://app.example.com" + } + }); + const ctx = createExecutionContext(); + const response = await worker.fetch( + request, + { ...env, ALLOWED_ORIGINS: "https://app.example.com" }, + ctx + ); + await waitOnExecutionContext(ctx); + + expect(response.status).toBe(200); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe("https://app.example.com"); + expect(response.headers.get("Access-Control-Allow-Credentials")).toBe("true"); }); - it('responds with Hello World! (integration style)', async () => { - const response = await SELF.fetch('https://example.com'); - expect(await response.text()).toMatchInlineSnapshot(`"Hello World!"`); + it("omits credentials header when no origin is allowlisted", async () => { + const request = new IncomingRequest("http://example.com/health", { + headers: { + Origin: "https://app.example.com" + } + }); + const ctx = createExecutionContext(); + const response = await worker.fetch(request, { ...env, ALLOWED_ORIGINS: "" }, ctx); + await waitOnExecutionContext(ctx); + + expect(response.status).toBe(200); + expect(response.headers.get("Access-Control-Allow-Origin")).toBeNull(); + expect(response.headers.get("Access-Control-Allow-Credentials")).toBeNull(); }); }); diff --git a/vitest.config.mts b/vitest.config.mts index 977f64c..503a717 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -4,7 +4,7 @@ export default defineWorkersConfig({ test: { poolOptions: { workers: { - wrangler: { configPath: './wrangler.jsonc' }, + wrangler: { configPath: './wrangler.toml' }, }, }, },