From 4ce87e8c30e81cfdd572f352676048dc1ecd557a Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Sat, 27 Jun 2026 13:18:26 -0300 Subject: [PATCH] fix(api): replace #5083 global middleware CSP with declarative ws: scheme (#5083) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to PR #5177 (merged): that version implemented the LAN-CSP fix (Bug 1) with a new global `src/middleware.ts` + `src/server/csp.ts`, which contradicts the project's documented architecture — 'No global Next.js middleware — interception is route-specific' (CLAUDE.md / AGENTS.md) — and was merged unverified (middleware vs next.config header precedence was never confirmed in a real build). This replaces that approach with the minimal, declarative equivalent: • next.config.mjs: connect-src now permits the bare `ws:` scheme (symmetric with the bare `wss:` already allowed) so the dashboard can reach its own Live WS server from a LAN/Tailscale host. No middleware. • Removes src/middleware.ts, src/server/csp.ts, and tests/unit/csp-host-aware.test.ts. • Adds tests/unit/csp-lan-ws-5083.test.ts (incl. a guard asserting src/middleware.ts does NOT exist, so the global-middleware approach cannot silently return). Bugs 2 (GET-exempt /api/system/version) and 3 (COMBO_002 field surfacing) from #5177 are unaffected and remain in place. Co-authored-by: KooshaPari --- CHANGELOG.md | 2 +- next.config.mjs | 6 +- src/middleware.ts | 65 ------------ src/server/csp.ts | 116 -------------------- tests/unit/csp-host-aware.test.ts | 163 ----------------------------- tests/unit/csp-lan-ws-5083.test.ts | 71 +++++++++++++ 6 files changed, 77 insertions(+), 346 deletions(-) delete mode 100644 src/middleware.ts delete mode 100644 src/server/csp.ts delete mode 100644 tests/unit/csp-host-aware.test.ts create mode 100644 tests/unit/csp-lan-ws-5083.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index be0dd0d877..51d944461c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ _In development — bullets added per PR; finalized at release._ ### 🔧 Bug Fixes -- **fix(api): LAN/Tailscale dashboard access — host-aware CSP, GET-exempt version route, surface combo field errors** — three failures when opening the dashboard from a non-loopback host: (1) CSP `connect-src` was a static loopback-only string, blocking `ws://:*` WebSocket connections from LAN/Tailscale clients; the CSP is now built per-request from a validated `Host` header (`src/server/csp.ts` + `src/middleware.ts`) with a strict hostname/IPv4 regex so injection-shaped values are never interpolated; (2) `GET /api/system/version` was blocked by `LOCAL_ONLY_API_PREFIXES` for all methods despite only `POST` spawning child processes (git/npm/pm2) — a new `LOCAL_ONLY_API_GET_EXEMPTIONS` set exempts safe read methods for this path while keeping `POST`/`PUT`/`PATCH`/`DELETE` strictly loopback-only; (3) `COMBO_002` validation errors only surfaced the generic message — `firstField`/`firstMessage` are now extracted from the first Zod issue and included in the response body. ([#5083](https://github.com/diegosouzapw/OmniRoute/issues/5083) — thanks @KooshaPari for the diagnosis and original PR #5084) +- **fix(api): LAN/Tailscale dashboard access — `ws:` CSP scheme, GET-exempt version route, surface combo field errors** — three failures when opening the dashboard from a non-loopback host: (1) CSP `connect-src` allowed the `ws:` scheme only for loopback origins, blocking the dashboard's `ws://:*` Live WebSocket from LAN/Tailscale clients; the bare `ws:` scheme is now permitted (symmetric with the bare `wss:` already allowed), kept declarative in `next.config.mjs` with no global middleware (the project has none by design); (2) `GET /api/system/version` was blocked by `LOCAL_ONLY_API_PREFIXES` for all methods despite only `POST` spawning child processes (git/npm/pm2) — a new `LOCAL_ONLY_API_GET_EXEMPTIONS` set exempts safe read methods for this path while keeping `POST`/`PUT`/`PATCH`/`DELETE` strictly loopback-only; (3) `COMBO_002` validation errors only surfaced the generic message — `firstField`/`firstMessage` are now extracted from the first Zod issue and included in the response body. ([#5083](https://github.com/diegosouzapw/OmniRoute/issues/5083) — thanks @KooshaPari for the diagnosis and original PR #5084) - **fix(sse): defer `` close so it never leaks before `tool_calls` in Claude→OpenAI streaming** — when a Claude thinking block was followed by a tool_use block, the translator unconditionally emitted a `content: ""` chunk at `content_block_stop`, injecting a spurious assistant text chunk immediately before the `tool_calls` delta and corrupting OpenAI-compatible clients (e.g. Kimi Coding). The close marker is now deferred: it is flushed at the first `text_delta` that follows the thinking block (preserving the #4633 / decolua/9router#454 behavior for Claude Code / Cursor) or at stream finish when no tool_calls were collected. Tool-use streams never get a `text_delta` after the thinking block, so `` is never emitted into content before `tool_calls`. ([#5123](https://github.com/diegosouzapw/OmniRoute/issues/5123)) - **fix(sse): normalize array user-message content in the Command Code executor to prevent upstream 400** — when a client sends a user turn whose `content` is an array of content parts (e.g. `[{type:"text",text:"…"}, …]`), the raw array was forwarded verbatim to the Command Code upstream, which requires `messages[N].content` for the `user` role to be a plain string — resulting in `expected string, received array` / HTTP 400 on DeepSeek V4-Pro and other Command Code models. The user branch of `convertMessages` now calls `normalizeContentText()` (already used by system, assistant, and tool branches) so multi-part user content is joined to a string before dispatch. Partially addresses ([#5166](https://github.com/diegosouzapw/OmniRoute/issues/5166)); the 0-output-token symptom on reasoning-only models is tracked separately. diff --git a/next.config.mjs b/next.config.mjs index aefe08fadd..220e747be0 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -21,7 +21,11 @@ const contentSecurityPolicy = [ "font-src 'self' https://fonts.gstatic.com data:", "img-src 'self' data: blob: https:", "media-src 'self' data: blob:", - "connect-src 'self' http://localhost:* http://127.0.0.1:* ws://localhost:* ws://127.0.0.1:* https: wss:", + // `ws:` is permitted scheme-wide (mirroring the bare `wss:` already allowed) so the + // dashboard can open `ws://:*` to its own Live WS server when + // OmniRoute is reached from a non-loopback host. Same-origin HTTP fetches stay covered + // by `'self'`; the loopback origins remain listed explicitly for clarity. (#5083) + "connect-src 'self' http://localhost:* http://127.0.0.1:* ws://localhost:* ws://127.0.0.1:* https: ws: wss:", "worker-src 'self' blob:", "manifest-src 'self'", ].join("; "); diff --git a/src/middleware.ts b/src/middleware.ts deleted file mode 100644 index 21443db57e..0000000000 --- a/src/middleware.ts +++ /dev/null @@ -1,65 +0,0 @@ -/** - * Next.js Edge Middleware — per-request Content-Security-Policy (#5083). - * - * The static CSP in next.config.mjs only covers loopback origins in - * connect-src. When OmniRoute is reached from a LAN, Tailscale, or public - * hostname the dashboard cannot establish WebSocket connections because - * ws://:* is absent from the static policy. - * - * This middleware reads the trusted Host header, validates it with a strict - * regex, and — for valid non-loopback hosts — appends - * ws://:* http://:* - * to connect-src before the response reaches the browser. - * - * The CSP header set here overrides the static next.config.mjs header - * because middleware runs before the static route headers are applied. - * The static CSP is kept in next.config.mjs as a build-time fallback for - * environments where middleware is disabled. - * - * Security: - * - Host values are validated with a bounded hostname/IPv4 regex before - * interpolation. Invalid / injection-shaped values are ignored. - * - /dashboard/providers/services/*/embed/* keeps "frame-ancestors 'self'" - * (overrides the baseline "frame-ancestors 'none'") so the embedded - * service UI can be iframed by the OmniRoute dashboard. - * - All other hard-coded security directives (object-src, form-action …) - * are preserved verbatim from the baseline. - */ - -import { NextResponse } from "next/server"; -import type { NextRequest } from "next/server"; -import { buildCspForHost } from "@/server/csp"; - -/** Path prefix for embedded service reverse-proxy pages (Hard Rule #17). */ -const EMBED_PREFIX = "/dashboard/providers/services/"; - -export function middleware(request: NextRequest): NextResponse { - const response = NextResponse.next(); - - const { pathname } = request.nextUrl; - const host = request.headers.get("host"); - - // Embedded service UI pages need `frame-ancestors 'self'` so that the - // OmniRoute dashboard can render them inside an