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.
This commit is contained in:
diegosouzapw
2026-05-26 11:05:40 -03:00
parent 3a5b02b66a
commit 77a44e26bc
8 changed files with 293 additions and 22 deletions

View File

@@ -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).

72
ONBOARDING.md Normal file
View File

@@ -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.
<!-- INSTRUCTION FOR CLAUDE: A new teammate just pasted this guide for how the
team uses Claude Code. You're their onboarding buddy — warm, conversational,
not lecture-y.
Open with a warm welcome — include the team name from the title. Then: "Your
teammate uses Claude Code for Build Feature, Plan Design, Improve Quality, and
Write Docs work. Let's get you started."
Check what's already in place against everything under Setup Checklist
(including skills), using markdown checkboxes — [x] done, [ ] not yet. Lead
with what they already have. One sentence per item, all in one message.
Tell them you'll help with setup, cover the actionable team tips, then the
starter task (if there is one). Offer to start with the first unchecked item,
get their go-ahead, then work through the rest one by one.
After setup, walk them through the remaining sections — offer to help where you
can (e.g. link to channels), and just surface the purely informational bits.
Don't invent sections or summaries that aren't in the guide. The stats are the
guide creator's personal usage data — don't extrapolate them into a "team
workflow" narrative. -->

View File

@@ -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<Set<string>>(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() {
</div>
</div>
{/* Pending-restart banner — shown when at least one requiresRestart flag
was toggled in this session. */}
{pendingRestartKeys.size > 0 && (
<div className="rounded-xl border border-amber-500/40 bg-amber-500/10 p-4">
<div className="flex items-center justify-between gap-4">
<div className="flex items-start gap-3">
<span className="material-symbols-outlined text-amber-400">restart_alt</span>
<div>
<p className="text-sm font-medium text-amber-300">
{pendingRestartKeys.size} change{pendingRestartKeys.size === 1 ? "" : "s"} require
a server restart to take effect.
</p>
<p className="mt-0.5 text-xs text-amber-200/80">
These flags only apply after the process reloads. Restart now or continue editing
pending flags stay queued until you confirm.
</p>
</div>
</div>
{!showRestartConfirm ? (
<button
onClick={() => setShowRestartConfirm(true)}
className="shrink-0 rounded-lg border border-amber-400/40 px-4 py-2 text-sm text-amber-300 hover:bg-amber-500/20 transition-colors"
>
Restart Server
</button>
) : (
<div className="flex items-center gap-3">
<button
onClick={() => setShowRestartConfirm(false)}
className="text-sm text-amber-300/80 hover:text-amber-200"
disabled={restarting}
>
Cancel
</button>
<button
onClick={handleRestart}
disabled={restarting}
className="rounded-lg bg-amber-500/30 px-4 py-2 text-sm text-amber-200 hover:bg-amber-500/40 disabled:opacity-50 transition-colors"
>
{restarting ? "Restarting…" : "Confirm Restart"}
</button>
</div>
)}
</div>
</div>
)}
{/* Explanation banner for the synthetic "Requires Restart" view */}
{category === "__restart" && (
<div className="rounded-xl border border-blue-500/30 bg-blue-500/10 p-3 text-sm text-blue-200">
<div className="flex items-start gap-2">
<span className="material-symbols-outlined text-blue-300">info</span>
<p>
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 <strong>Restart Server</strong> banner above to apply.
</p>
</div>
</div>
)}
{/* Loading skeleton */}
{loading && (
<div

View File

@@ -23,6 +23,46 @@ function sanitizeForensicHeader(value: string | null, max = 256): string {
return value.replace(/[\r\n]+/g, " ").slice(0, max);
}
// ── In-memory per-(token,IP) rate limit ─────────────────────────────────────
// Defence-in-depth on top of the DB-backed per-token limit: a leaked relay
// token redistributed across N IPs would otherwise consume the per-token
// quota in parallel. This second gate caps a *single* IP using a token to
// `RELAY_IP_PER_MINUTE` req/min — legit clients keep working, distributed
// abuse of one token hits the wall fast.
//
// In-memory by design: cheap, no DB round-trip, no extra migration. Per
// instance only — if you run multiple relay replicas behind a load balancer,
// the effective ceiling is `RELAY_IP_PER_MINUTE * replicas`, which is still
// orders of magnitude tighter than no gate.
const RELAY_IP_PER_MINUTE = Number(process.env.RELAY_IP_PER_MINUTE || "30");
const ipBuckets = new Map<string, { count: number; windowStart: number }>();
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, {

View File

@@ -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)

View File

@@ -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) => {

View File

@@ -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) ────────────────
{

View File

@@ -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'", () => {