]> git.99rst.org Git - sgasser-llm-shield.git/commitdiff
Block browser access to proxy routes (#163)
authorStefan Gasser <redacted>
Fri, 31 Jul 2026 08:41:34 +0000 (10:41 +0200)
committerGitHub <redacted>
Fri, 31 Jul 2026 08:41:34 +0000 (10:41 +0200)
docs/configuration/overview.mdx
src/index.ts
src/middleware/browser-access.test.ts [new file with mode: 0644]
src/middleware/browser-access.ts [new file with mode: 0644]

index de81de5648803db1ca9bf07d5a56b266c75701aa..4244d462d1e55ba529e6d212e3127ccdfe0ffb6a 100644 (file)
@@ -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
index 4e24bf938291542b3753186327ac9e0d421331ca..4f84829bba1e00fb2ce3576e304cc385026042b8 100644 (file)
@@ -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 (file)
index 0000000..d50bc57
--- /dev/null
@@ -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 (file)
index 0000000..b7cb32c
--- /dev/null
@@ -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();
+};
git clone https://git.99rst.org/PROJECT