From 06265f90b5caffff970ead27e641137e8e79be1c Mon Sep 17 00:00:00 2001 From: Lee Sang Hoon Date: Thu, 2 Jul 2026 14:28:20 +0900 Subject: [PATCH] fix(web): restrict browser CORS origins --- src/services/cors.ts | 59 +++++++++++++++++++++++++++++++ src/services/web-server-worker.ts | 13 +++++-- src/services/web-server.ts | 13 +++++-- tests/web-server-cors.test.ts | 47 ++++++++++++++++++++++++ 4 files changed, 126 insertions(+), 6 deletions(-) create mode 100644 src/services/cors.ts create mode 100644 tests/web-server-cors.test.ts diff --git a/src/services/cors.ts b/src/services/cors.ts new file mode 100644 index 0000000..0cd5c29 --- /dev/null +++ b/src/services/cors.ts @@ -0,0 +1,59 @@ +const ALLOWED_LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]); +const CORS_ALLOWED_METHODS = "GET, POST, PUT, DELETE, OPTIONS"; +const CORS_ALLOWED_HEADERS = "Content-Type"; + +export function isAllowedBrowserOrigin(origin: string | null): boolean { + if (!origin) return true; + + try { + const url = new URL(origin); + return ( + (url.protocol === "http:" || url.protocol === "https:") && + ALLOWED_LOOPBACK_HOSTS.has(url.hostname) + ); + } catch { + return false; + } +} + +function corsHeaders(origin: string | null): Record { + if (!origin || !isAllowedBrowserOrigin(origin)) return {}; + + return { + "Access-Control-Allow-Origin": origin, + "Access-Control-Allow-Methods": CORS_ALLOWED_METHODS, + "Access-Control-Allow-Headers": CORS_ALLOWED_HEADERS, + Vary: "Origin", + }; +} + +export function corsPreflightResponse(req: Request): Response { + const origin = req.headers.get("Origin"); + + if (!isAllowedBrowserOrigin(origin)) { + return disallowedCorsResponse(); + } + + return new Response(null, { + status: 204, + headers: { + ...corsHeaders(origin), + "Access-Control-Max-Age": "600", + }, + }); +} + +export function disallowedCorsResponse(): Response { + return new Response( + JSON.stringify({ + success: false, + error: "Cross-origin requests are restricted to loopback origins.", + }), + { + status: 403, + headers: { + "Content-Type": "application/json", + }, + } + ); +} diff --git a/src/services/web-server-worker.ts b/src/services/web-server-worker.ts index 489f609..1a40718 100644 --- a/src/services/web-server-worker.ts +++ b/src/services/web-server-worker.ts @@ -1,6 +1,7 @@ import { readFileSync } from "node:fs"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; +import { corsPreflightResponse, disallowedCorsResponse, isAllowedBrowserOrigin } from "./cors.js"; import { handleListTags, handleListMemories, @@ -49,6 +50,15 @@ async function handleRequest(req: Request): Promise { const url = new URL(req.url); const path = url.pathname; const method = req.method; + const origin = req.headers.get("Origin"); + + if (!isAllowedBrowserOrigin(origin)) { + return disallowedCorsResponse(); + } + + if (method === "OPTIONS") { + return corsPreflightResponse(req); + } try { if (path === "/" || path === "/index.html") { @@ -289,9 +299,6 @@ function jsonResponse(data: any, status: number = 200): Response { status, headers: { "Content-Type": "application/json", - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type", }, }); } diff --git a/src/services/web-server.ts b/src/services/web-server.ts index f490179..3e58d6e 100644 --- a/src/services/web-server.ts +++ b/src/services/web-server.ts @@ -4,6 +4,7 @@ import { Readable } from "node:stream"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { log } from "./logger.js"; +import { corsPreflightResponse, disallowedCorsResponse, isAllowedBrowserOrigin } from "./cors.js"; import { handleListTags, handleListMemories, @@ -289,6 +290,15 @@ export class WebServer { const url = new URL(req.url); const path = url.pathname; const method = req.method; + const origin = req.headers.get("Origin"); + + if (!isAllowedBrowserOrigin(origin)) { + return disallowedCorsResponse(); + } + + if (method === "OPTIONS") { + return corsPreflightResponse(req); + } try { if (path === "/" || path === "/index.html") { @@ -533,9 +543,6 @@ export class WebServer { status, headers: { "Content-Type": "application/json", - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type", }, }); } diff --git a/tests/web-server-cors.test.ts b/tests/web-server-cors.test.ts new file mode 100644 index 0000000..c1915fa --- /dev/null +++ b/tests/web-server-cors.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "bun:test"; +import { + corsPreflightResponse, + disallowedCorsResponse, + isAllowedBrowserOrigin, +} from "../src/services/cors.js"; + +describe("web server CORS policy", () => { + it("allows non-browser requests without an Origin header", () => { + expect(isAllowedBrowserOrigin(null)).toBe(true); + }); + + it("allows loopback browser origins", () => { + expect(isAllowedBrowserOrigin("http://127.0.0.1:4747")).toBe(true); + expect(isAllowedBrowserOrigin("http://localhost:4747")).toBe(true); + expect(isAllowedBrowserOrigin("http://[::1]:4747")).toBe(true); + }); + + it("rejects non-loopback browser origins", () => { + expect(isAllowedBrowserOrigin("https://example.com")).toBe(false); + expect(isAllowedBrowserOrigin("null")).toBe(false); + }); + + it("returns a loopback-bound preflight response", () => { + const response = corsPreflightResponse( + new Request("http://127.0.0.1:4747/api/memories", { + method: "OPTIONS", + headers: { + Origin: "http://localhost:4747", + "Access-Control-Request-Method": "POST", + }, + }) + ); + + expect(response.status).toBe(204); + expect(response.headers.get("Access-Control-Allow-Origin")).toBe("http://localhost:4747"); + expect(response.headers.get("Access-Control-Allow-Methods")).toContain("POST"); + expect(response.headers.get("Vary")).toBe("Origin"); + }); + + it("does not expose CORS headers on rejected origins", () => { + const response = disallowedCorsResponse(); + + expect(response.status).toBe(403); + expect(response.headers.get("Access-Control-Allow-Origin")).toBeNull(); + }); +});