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";
// 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
--- /dev/null
+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();
+ });
+});
--- /dev/null
+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();
+};