mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-01 12:52:11 +03:00
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:
@@ -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
72
ONBOARDING.md
Normal 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. -->
|
||||
@@ -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
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) ────────────────
|
||||
{
|
||||
|
||||
@@ -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'", () => {
|
||||
|
||||
Reference in New Issue
Block a user