From: Stefan Gasser Date: Fri, 31 Jul 2026 08:41:34 +0000 (+0200) Subject: Block browser access to proxy routes (#163) X-Git-Tag: v0.9.2~1 X-Git-Url: http://git.99rst.org/?a=commitdiff_plain;h=09d052fdcb7f169f59fedca28eac56dfb65fc6a4;p=sgasser-llm-shield.git Block browser access to proxy routes (#163) --- diff --git a/docs/configuration/overview.mdx b/docs/configuration/overview.mdx index de81de5..4244d46 100644 --- a/docs/configuration/overview.mdx +++ b/docs/configuration/overview.mdx @@ -39,6 +39,11 @@ server: | `host` | `0.0.0.0` | Bind address | | `request_timeout` | `600` | Request timeout in seconds (0 = no timeout) | +The OpenAI, Anthropic, and Codex proxy routes reject browser requests. SDKs, +CLIs, and other non-browser clients are unaffected. The standalone `/api/mask` +endpoint keeps permissive CORS for the browser extension. Restrict network +access when using a configured provider-key fallback. + ## Dashboard ```yaml diff --git a/src/index.ts b/src/index.ts index 4e24bf9..4f84829 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,10 @@ import { Hono } from "hono"; -import { cors } from "hono/cors"; import { createMiddleware } from "hono/factory"; import { HTTPException } from "hono/http-exception"; import { logger } from "hono/logger"; import { getConfig } from "./config"; import { getLogger } from "./logging/logger"; +import { browserAccessMiddleware } from "./middleware/browser-access"; import { getPIIDetector } from "./pii/detect"; import { anthropicRoutes } from "./routes/anthropic"; import { apiRoutes } from "./routes/api"; @@ -31,18 +31,7 @@ const requestIdMiddleware = createMiddleware<{ Variables: Variables }>(async (c, // Middleware app.use("*", requestIdMiddleware); -// Permissive CORS is applied to the proxy and mask APIs so browser-based clients -// can call them, but NOT to the dashboard. The dashboard UI and its JSON APIs -// (/dashboard, /dashboard/api/*) are served same-origin and may be -// unauthenticated; a wildcard Access-Control-Allow-Origin there would let any -// website the operator visits read logged request data cross-origin. Same-origin -// dashboard use needs no CORS headers, so excluding it changes no legitimate flow. -const corsMiddleware = cors(); -app.use("*", (c, next) => - c.req.path === "/dashboard" || c.req.path.startsWith("/dashboard/") - ? next() - : corsMiddleware(c, next), -); +app.use("*", browserAccessMiddleware); app.use("*", logger()); // Favicon diff --git a/src/middleware/browser-access.test.ts b/src/middleware/browser-access.test.ts new file mode 100644 index 0000000..d50bc57 --- /dev/null +++ b/src/middleware/browser-access.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, test } from "bun:test"; +import { Hono } from "hono"; +import { browserAccessMiddleware } from "./browser-access"; + +function createApp() { + let handledRequests = 0; + const app = new Hono(); + app.use("*", browserAccessMiddleware); + app.all("*", (c) => { + handledRequests++; + return c.json({ ok: true }); + }); + return { app, handledRequests: () => handledRequests }; +} + +describe("browser access middleware", () => { + test.each([ + "/openai/v1/chat/completions", + "/anthropic/v1/messages", + "/codex/responses", + ])("rejects browser requests before proxy route %s runs", async (path) => { + const { app, handledRequests } = createApp(); + const response = await app.request(`http://127.0.0.1:3000${path}`, { + method: "POST", + headers: { Origin: "https://attacker.example" }, + body: "{}", + }); + + expect(response.status).toBe(403); + expect(handledRequests()).toBe(0); + expect(response.headers.get("access-control-allow-origin")).toBeNull(); + }); + + test("rejects cross-site browser requests without an Origin header", async () => { + const { app } = createApp(); + const response = await app.request("http://127.0.0.1:3000/anthropic/v1/messages", { + method: "POST", + headers: { "Sec-Fetch-Site": "cross-site" }, + }); + + expect(response.status).toBe(403); + }); + + test("allows non-browser proxy clients without CORS headers", async () => { + const { app } = createApp(); + const response = await app.request("http://127.0.0.1:3000/openai/v1/chat/completions", { + method: "POST", + }); + + expect(response.status).toBe(200); + expect(response.headers.get("access-control-allow-origin")).toBeNull(); + }); + + test("keeps permissive CORS for the standalone mask API", async () => { + const { app } = createApp(); + const response = await app.request("http://127.0.0.1:3000/api/mask", { + method: "POST", + headers: { Origin: "chrome-extension://example" }, + }); + + expect(response.status).toBe(200); + expect(response.headers.get("access-control-allow-origin")).toBe("*"); + }); + + test("does not add CORS headers to dashboard responses", async () => { + const { app } = createApp(); + const response = await app.request("http://127.0.0.1:3000/dashboard/api/logs", { + headers: { Origin: "https://attacker.example" }, + }); + + expect(response.status).toBe(200); + expect(response.headers.get("access-control-allow-origin")).toBeNull(); + }); +}); diff --git a/src/middleware/browser-access.ts b/src/middleware/browser-access.ts new file mode 100644 index 0000000..b7cb32c --- /dev/null +++ b/src/middleware/browser-access.ts @@ -0,0 +1,21 @@ +import type { MiddlewareHandler } from "hono"; +import { cors } from "hono/cors"; + +const PROXY_PATH_PREFIXES = ["/openai", "/anthropic", "/codex"]; +const publicCors = cors(); + +function isProxyPath(path: string): boolean { + return PROXY_PATH_PREFIXES.some((prefix) => path === prefix || path.startsWith(`${prefix}/`)); +} + +export const browserAccessMiddleware: MiddlewareHandler = async (c, next) => { + if (c.req.path === "/dashboard" || c.req.path.startsWith("/dashboard/")) { + return next(); + } + + if (!isProxyPath(c.req.path)) return publicCors(c, next); + if (c.req.header("origin") || c.req.header("sec-fetch-site") === "cross-site") { + return c.json({ error: { message: "Browser proxy requests are not allowed" } }, 403); + } + return next(); +};