From 77a44e26bc4248beba6513cfe1db90bb5ded1638 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Tue, 26 May 2026 11:05:40 -0300 Subject: [PATCH] feat(security): harden relay and runtime defaults Enable key security feature flags by default and add a per-token/IP relay rate limit to reduce leaked token blast radius. Add live dashboard WebSocket feature-flag metadata, restart-required filtering and restart prompts in the settings UI, plus onboarding documentation for new contributors. --- .env.example | 9 +- ONBOARDING.md | 72 +++++++++++ .../settings/components/FeatureFlagsGrid.tsx | 121 +++++++++++++++++- .../api/v1/relay/chat/completions/route.ts | 63 ++++++++- src/lib/db/migrations/071_services.sql | 2 +- src/server/ws/liveServer.ts | 20 ++- .../constants/featureFlagDefinitions.ts | 18 ++- tests/unit/feature-flags-settings.test.ts | 10 +- 8 files changed, 293 insertions(+), 22 deletions(-) create mode 100644 ONBOARDING.md diff --git a/.env.example b/.env.example index fe3e9c3e8a..4aaa4b31fb 100644 --- a/.env.example +++ b/.env.example @@ -96,11 +96,12 @@ PORT=20128 # Used by: scripts/start-ws-server.mjs (CI/embedded harness toggle). # OMNIROUTE_DISABLE_LIVE_WS=0 -# Enable the real-time dashboard WebSocket server (opt-in). +# Enable the real-time dashboard WebSocket server. # Used by: src/server/ws/liveServer.ts, scripts/start-ws-server.mjs -# Default: OFF — set to 1 or true to start the server on import. -# Combine with LIVE_WS_HOST / LIVE_WS_ALLOWED_ORIGINS above when enabling. -# OMNIROUTE_ENABLE_LIVE_WS=0 +# Default: ON. Set to 0 or false to disable startup of the live WS server. +# Combine with LIVE_WS_HOST / LIVE_WS_ALLOWED_ORIGINS above when exposing +# beyond loopback. +# OMNIROUTE_ENABLE_LIVE_WS=1 # Per-(token,IP) relay rate limit, requests/minute. In-memory, per instance. # 0 or negative disables the IP-dimension gate (per-token DB limit still applies). diff --git a/ONBOARDING.md b/ONBOARDING.md new file mode 100644 index 0000000000..e0cba2070d --- /dev/null +++ b/ONBOARDING.md @@ -0,0 +1,72 @@ +# Welcome to OmniRoute + +## How We Use Claude + +Based on diegosouzapw's usage over the last 30 days: + +Work Type Breakdown: +Build Feature ████████████████████ 50% +Plan Design ██████████░░░░░░░░░░ 25% +Improve Quality ██████░░░░░░░░░░░░░░ 15% +Write Docs ████░░░░░░░░░░░░░░░░ 10% + +Top Skills & Commands: +_no slash commands captured in this window_ + +Top MCP Servers: +_no MCP usage captured in this window_ + +## Your Setup Checklist + +### Codebases + +- [ ] omniroute — https://github.com/diegosouzapw/omniroute +- [ ] OpenCode_Ecosystem (fork) — https://github.com/diegosouzapw/OpenCode_Ecosystem +- [ ] OpenCode_Ecosystem (upstream) — https://github.com/MarceloClaro/OpenCode_Ecosystem + +### MCP Servers to Activate + +- [ ] _none required from current usage. If you'll be working on OmniRoute itself, ask the team about the project's own embedded MCP server at `/api/mcp/stream`._ + +### Skills to Know About + +- _no skills surfaced from usage data. The team's workflow leaned heavily on direct file edits, git/gh CLI, and subagent dispatch for parallel work — Claude figures these out from context._ + +## Team Tips + +- **Read `CLAUDE.md` first.** It has hard rules that override defaults — e.g. never write raw SQL in routes (use `src/lib/db/` modules), never add `Co-Authored-By: Claude` to commits, error responses must go through `buildErrorBody()` / `sanitizeErrorMessage()`. +- **Subagents for parallel work.** When tasks are independent, dispatch multiple sonnet subagents in one message instead of doing them yourself. Always audit `git diff` after a subagent finishes — its summary describes intent, not necessarily the result. +- **Conventional Commits** for everything: `feat(scope):`, `fix(scope):`, `chore(scope):`, `docs:`. Scopes used here include `db`, `sse`, `oauth`, `dashboard`, `api`, `agents`, `plugin`, `skills`, `commands`. +- **Run the full validation suite before declaring done** — `npm run check` (lint + tests) at minimum; `npm run test:coverage` if you changed production code. Hard gate: 75/75/75/70 (statements/lines/functions/branches). +- **Husky pre-push runs unit tests.** Don't `--no-verify` past it without explicit approval — the project documents this as a hard rule. + +## Get Started + +- Clone the repo and run `npm install` (auto-generates `.env` from `.env.example`). +- Generate secrets: `openssl rand -base64 48` for `JWT_SECRET`, `openssl rand -hex 32` for `API_KEY_SECRET`. Paste into `.env`. +- `npm run dev` → dashboard at `http://localhost:20128`. +- Read `docs/architecture/REPOSITORY_MAP.md` for the file layout, then `docs/architecture/ARCHITECTURE.md` for how requests flow. +- For a first PR: pick something from `_tasks/` if there's a backlog, or grep for `// TODO` and pick a small one. Run `npm run check` before opening the PR. + + diff --git a/src/app/(dashboard)/dashboard/settings/components/FeatureFlagsGrid.tsx b/src/app/(dashboard)/dashboard/settings/components/FeatureFlagsGrid.tsx index 5325bf68ff..19dc255fcc 100644 --- a/src/app/(dashboard)/dashboard/settings/components/FeatureFlagsGrid.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/FeatureFlagsGrid.tsx @@ -31,9 +31,12 @@ const CATEGORIES = [ { value: "security", label: "Security (6)" }, { value: "network", label: "Network (5)" }, { value: "policies", label: "Policies (3)" }, - { value: "runtime", label: "Runtime (5)" }, + { value: "runtime", label: "Runtime (6)" }, { value: "cli", label: "CLI (3)" }, { value: "health", label: "Health (3)" }, + // Synthetic "category" that filters by requiresRestart=true regardless of + // real category — surfaces flags that need a process restart to take effect. + { value: "__restart", label: "Requires Restart" }, ]; export default function FeatureFlagsGrid() { @@ -47,6 +50,11 @@ export default function FeatureFlagsGrid() { const [resettingAll, setResettingAll] = useState(false); const [showResetConfirm, setShowResetConfirm] = useState(false); const [debouncedSearch, setDebouncedSearch] = useState(""); + // Set of flag keys whose DB override was changed in this session without + // a subsequent restart. Used to surface the restart banner. + const [pendingRestartKeys, setPendingRestartKeys] = useState>(new Set()); + const [restarting, setRestarting] = useState(false); + const [showRestartConfirm, setShowRestartConfirm] = useState(false); const loadFlags = useCallback(async () => { setLoading(true); @@ -75,7 +83,11 @@ export default function FeatureFlagsGrid() { const filteredFlags = useMemo(() => { return flags - .filter((f) => category === "all" || f.category === category) + .filter((f) => { + if (category === "all") return true; + if (category === "__restart") return f.requiresRestart; + return f.category === category; + }) .filter( (f) => debouncedSearch === "" || @@ -108,6 +120,9 @@ export default function FeatureFlagsGrid() { f.key === key ? { ...f, effectiveValue: result.effectiveValue, source: result.source } : f ); }); + if (result.requiresRestart) { + setPendingRestartKeys((prev) => new Set(prev).add(key)); + } } catch (err) { setError(err instanceof Error ? err.message : "Failed to update flag"); } finally { @@ -143,6 +158,9 @@ export default function FeatureFlagsGrid() { f.key === key ? { ...f, effectiveValue: result.effectiveValue, source: result.source } : f ); }); + if (result.requiresRestart) { + setPendingRestartKeys((prev) => new Set(prev).add(key)); + } } catch (err) { setError(err instanceof Error ? err.message : "Failed to update flag"); } finally { @@ -154,6 +172,44 @@ export default function FeatureFlagsGrid() { } }, []); + const handleRestart = useCallback(async () => { + setRestarting(true); + try { + const res = await fetch("/api/restart", { method: "POST" }); + if (!res.ok) { + setError(`Restart failed: HTTP ${res.status}`); + setShowRestartConfirm(false); + setRestarting(false); + return; + } + // Server is going down — wait for it to come back, then reload. + const stillUp = async () => { + try { + const r = await fetch("/api/health", { cache: "no-store" }); + return r.ok; + } catch { + return false; + } + }; + const waitDown = setInterval(async () => { + if (!(await stillUp())) { + clearInterval(waitDown); + const waitUp = setInterval(async () => { + if (await stillUp()) { + clearInterval(waitUp); + setPendingRestartKeys(new Set()); + window.location.reload(); + } + }, 1000); + } + }, 500); + } catch (err) { + setError(err instanceof Error ? err.message : "Restart failed"); + setShowRestartConfirm(false); + setRestarting(false); + } + }, []); + const handleResetAll = useCallback(async () => { setResettingAll(true); try { @@ -221,6 +277,67 @@ export default function FeatureFlagsGrid() { + {/* Pending-restart banner — shown when at least one requiresRestart flag + was toggled in this session. */} + {pendingRestartKeys.size > 0 && ( +
+
+
+ restart_alt +
+

+ {pendingRestartKeys.size} change{pendingRestartKeys.size === 1 ? "" : "s"} require + a server restart to take effect. +

+

+ These flags only apply after the process reloads. Restart now or continue editing + — pending flags stay queued until you confirm. +

+
+
+ {!showRestartConfirm ? ( + + ) : ( +
+ + +
+ )} +
+
+ )} + + {/* Explanation banner for the synthetic "Requires Restart" view */} + {category === "__restart" && ( +
+
+ info +

+ These flags only take effect after the server restarts. Toggle them like any other + flag — the change is persisted immediately, but the new value is only read at process + startup. Use the Restart Server banner above to apply. +

+
+
+ )} + {/* Loading skeleton */} {loading && (
(); + +function checkIpRateLimit(tokenId: string, ip: string): { allowed: boolean; resetIn: number } { + if (!Number.isFinite(RELAY_IP_PER_MINUTE) || RELAY_IP_PER_MINUTE <= 0) { + return { allowed: true, resetIn: 0 }; + } + const now = Math.floor(Date.now() / 1000); + const windowStart = Math.floor(now / 60) * 60; + const key = tokenId + "|" + ip; + const bucket = ipBuckets.get(key); + if (!bucket || bucket.windowStart !== windowStart) { + ipBuckets.set(key, { count: 1, windowStart }); + if (ipBuckets.size > 10_000) { + // Bound memory: drop the oldest half when the table grows past 10k. + const cutoff = windowStart - 60; + for (const [k, b] of ipBuckets) { + if (b.windowStart < cutoff) ipBuckets.delete(k); + } + } + return { allowed: true, resetIn: 60 - (now % 60) }; + } + if (bucket.count >= RELAY_IP_PER_MINUTE) { + return { allowed: false, resetIn: 60 - (now % 60) }; + } + bucket.count++; + return { allowed: true, resetIn: 60 - (now % 60) }; +} + const injectionGuard = createInjectionGuard(); export async function OPTIONS() { @@ -86,7 +126,28 @@ export async function POST(request: Request) { }); } - // 2. Rate limit check + // 2a. Per-(token,IP) gate — bounds the blast radius of a leaked token. + const ipCheck = checkIpRateLimit(token.id, clientIp); + if (!ipCheck.allowed) { + recordRelayUsage(token.id, { + requestId: request.headers.get("x-request-id") || undefined, + status: "rate_limited", + statusCode: 429, + latencyMs: Date.now() - startTime, + clientIp, + userAgent, + }); + return new Response(JSON.stringify(buildErrorBody(429, "Per-IP rate limit exceeded")), { + status: 429, + headers: { + ...JSON_CORS_HEADERS, + "Retry-After": String(ipCheck.resetIn), + "X-RateLimit-Scope": "ip", + }, + }); + } + + // 2b. Per-token rate limit check const rateCheck = checkRateLimit(token.id); if (!rateCheck.allowed) { recordRelayUsage(token.id, { diff --git a/src/lib/db/migrations/071_services.sql b/src/lib/db/migrations/071_services.sql index 95fe6dcc9d..b37b01c907 100644 --- a/src/lib/db/migrations/071_services.sql +++ b/src/lib/db/migrations/071_services.sql @@ -1,4 +1,4 @@ --- Migration 068: Extend version_manager for embedded services (9router + CLIProxyAPI) +-- Migration 071: Extend version_manager for embedded services (9router + CLIProxyAPI) -- -- Adds 3 columns to support the embedded-services feature (v3.8.4): -- logs_buffer_path — path to ring-buffer log file on disk (optional) diff --git a/src/server/ws/liveServer.ts b/src/server/ws/liveServer.ts index 8cd461f807..017f931216 100644 --- a/src/server/ws/liveServer.ts +++ b/src/server/ws/liveServer.ts @@ -362,21 +362,29 @@ export async function startLiveDashboardServer( }); } -// ── Auto-start on import (if not in build/test) ─────────────────────────── +// ── Auto-start on import (opt-in) ──────────────────────────────────────── +// +// Default: OFF. The live dashboard WebSocket is an opt-in feature — operators +// who want it must set OMNIROUTE_ENABLE_LIVE_WS=1 (or "true"). This avoids +// silently opening a network listener on every Next.js boot. +// +// Build/test environments never auto-start regardless of the flag. function isBuildOrTest(): boolean { return ( process.env.NEXT_PHASE === "phase-production-build" || process.env.NODE_ENV === "test" || process.env.VITEST !== undefined || - process.argv.some((arg) => arg.includes("test")) || - process.env.OMNIROUTE_DISABLE_LIVE_WS === "1" || - process.env.OMNIROUTE_DISABLE_LIVE_WS === "true" + process.argv.some((arg) => arg.includes("test")) ); } -// Auto-start unless disabled -if (!isBuildOrTest()) { +function isLiveWsEnabled(): boolean { + const v = process.env.OMNIROUTE_ENABLE_LIVE_WS; + return v === "1" || v === "true"; +} + +if (!isBuildOrTest() && isLiveWsEnabled()) { const port = parseInt(process.env.LIVE_WS_PORT || String(DEFAULT_PORT), 10); const host = process.env.LIVE_WS_HOST || DEFAULT_HOST; startLiveDashboardServer(port, host).catch((err) => { diff --git a/src/shared/constants/featureFlagDefinitions.ts b/src/shared/constants/featureFlagDefinitions.ts index 763840fa45..a7d4aa8ef1 100644 --- a/src/shared/constants/featureFlagDefinitions.ts +++ b/src/shared/constants/featureFlagDefinitions.ts @@ -30,7 +30,7 @@ export const FEATURE_FLAG_DEFINITIONS: FeatureFlagDefinition[] = [ description: "Enable input sanitization for all requests", descriptionI18nKey: "featureFlagInputSanitizerEnabledDescription", category: "security", - defaultValue: "false", + defaultValue: "true", type: "boolean", requiresRestart: false, warningLevel: "info", @@ -75,7 +75,7 @@ export const FEATURE_FLAG_DEFINITIONS: FeatureFlagDefinition[] = [ description: "Block outbound requests to private/internal IP ranges", descriptionI18nKey: "featureFlagOutboundSsrfGuardEnabledDescription", category: "security", - defaultValue: "false", + defaultValue: "true", type: "boolean", requiresRestart: false, warningLevel: "info", @@ -181,7 +181,7 @@ export const FEATURE_FLAG_DEFINITIONS: FeatureFlagDefinition[] = [ description: "Enforce scope restrictions on MCP tool access", descriptionI18nKey: "featureFlagOmnirouteMcpEnforceScopesDescription", category: "runtime", - defaultValue: "false", + defaultValue: "true", type: "boolean", requiresRestart: false, warningLevel: "caution", @@ -230,6 +230,18 @@ export const FEATURE_FLAG_DEFINITIONS: FeatureFlagDefinition[] = [ requiresRestart: false, warningLevel: "caution", }, + { + key: "OMNIROUTE_ENABLE_LIVE_WS", + label: "Live Dashboard WebSocket", + description: + "Start the real-time dashboard WebSocket server on import (port 20129 by default).", + descriptionI18nKey: "featureFlagOmnirouteEnableLiveWsDescription", + category: "runtime", + defaultValue: "true", + type: "boolean", + requiresRestart: true, + warningLevel: "info", + }, // ──────────────── CLI (3) ──────────────── { diff --git a/tests/unit/feature-flags-settings.test.ts b/tests/unit/feature-flags-settings.test.ts index 1bf6cbf951..fe4b77367b 100644 --- a/tests/unit/feature-flags-settings.test.ts +++ b/tests/unit/feature-flags-settings.test.ts @@ -30,13 +30,13 @@ const { // Test group 1 — Flag definitions registry // ────────────────────────────────────────────────────── describe("featureFlagDefinitions", () => { - it("has exactly 25 flag definitions", () => { - assert.strictEqual(FEATURE_FLAG_DEFINITIONS.length, 25); + it("has exactly 26 flag definitions", () => { + assert.strictEqual(FEATURE_FLAG_DEFINITIONS.length, 26); }); it("has unique keys for all flags", () => { const keys = FEATURE_FLAG_DEFINITIONS.map((d) => d.key); - assert.strictEqual(new Set(keys).size, 25); + assert.strictEqual(new Set(keys).size, 26); }); it("has valid categories for all flags", () => { @@ -221,9 +221,9 @@ describe("resolveFeatureFlag", () => { }); describe("resolveAllFeatureFlags", () => { - it("returns all 25 flags", () => { + it("returns all 26 flags", () => { const all = resolveAllFeatureFlags(); - assert.strictEqual(all.length, 25); + assert.strictEqual(all.length, 26); }); it("marks DB-overridden flags with source 'db'", () => {