chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:39:12 +08:00
commit d8dcd5f6d1
8604 changed files with 2479390 additions and 0 deletions
+137
View File
@@ -0,0 +1,137 @@
---
title: Account-Ban / Banned-Keyword Detection
---
# Account-Ban / Banned-Keyword Detection
OmniRoute scans upstream error responses for signals that indicate a provider
**account is permanently dead** (suspended / deactivated / ToS-banned) and, when
matched, moves that connection into a **terminal `banned` state** so it is no
longer selected for requests. This is what the **Security → Banned Keywords**
settings card configures ("Additional keywords that trigger permanent account
ban detection. Built-in keywords always apply.").
This page documents the built-in list, the detection flow, its scope, how to add
custom keywords safely, and how to recover a flagged connection. The terminal
state itself is part of the resilience model — see
[RESILIENCE_GUIDE](../architecture/RESILIENCE_GUIDE.md) ("Terminal states").
**Source of truth:** `open-sse/services/accountFallback.ts`
(`ACCOUNT_DEACTIVATED_SIGNALS`, `getMergedBannedSignals()`, `isAccountDeactivated()`).
## Built-in keywords
These 8 substrings always apply (case-insensitive), regardless of any custom list:
```
account_deactivated
account has been deactivated
account has been disabled
your account has been suspended
this account is deactivated
verify your account to continue (Antigravity / Google Cloud Code)
this service has been disabled in this account for violation (Antigravity)
this service has been disabled in this account (Antigravity)
```
> This list evolves as providers change their ban wording. The authoritative
> copy is `ACCOUNT_DEACTIVATED_SIGNALS` in `open-sse/services/accountFallback.ts`;
> treat the block above as a snapshot.
Two adjacent, **separate** signal tables live in the same file and are *not* part
of banned-keyword detection:
- `CREDITS_EXHAUSTED_SIGNALS` — billing/quota depleted (`insufficient_quota`,
`credit_balance_too_low`, `payment required`, …) → terminal `credits_exhausted`.
- `OAUTH_INVALID_TOKEN_SIGNALS`**non-terminal**; a token refresh can recover.
Note: common transient phrases like **`rate limit`** / `429` are handled by the
rate-limit / connection-cooldown path and are **not** ban signals.
## Detection flow
```
upstream error response
→ body stringified + lowercased
→ isAccountDeactivated(body): getMergedBannedSignals().some(sig => body.includes(sig)) [substring match]
→ match?
→ connection testStatus = "banned" (permanent — 1-year cooldown, never auto-recovers)
→ if setting `autoDisableBannedAccounts` is on → also isActive = false
→ connection is skipped during account selection (combo QUOTA_BLOCKING statuses)
```
- The match is a **case-insensitive substring** search on the response **body**
(`isAccountDeactivated`, `accountFallback.ts`).
- The permanent `banned` terminalization fires on a banned-signal body at **any
HTTP status** (via `markAccountUnavailable``checkFallbackError`). The
narrower **`deactivated`** label (`isActive=false` when the connection has no
spare API keys) is written by the inline `chatCore.ts` path on **HTTP 401 / 403**
(classified via `classifyProviderError``ACCOUNT_DEACTIVATED`). Note the
`markAccountUnavailable()` path writes a *different* terminal status —
**`expired`** — for the same `ACCOUNT_DEACTIVATED` signal (via
`resolveTerminalConnectionStatus`), so the same ban can surface as either
`deactivated` or `expired` depending on which path handled the response. (The
older code comment says "when a 401 body contains these strings" — that
understates the current behavior.)
- A `banned` connection is excluded from selection everywhere terminal statuses
are filtered (`isTerminalConnectionStatus`, combo `QUOTA_BLOCKING_CONNECTION_STATUSES`).
## Scope — which providers are scanned
**All providers.** The check runs in the generic error-handling pipeline that
every failed upstream request flows through — it is **not** gated to
OAuth/subscription scrapers. The resulting terminal state is per **connection**,
not per provider.
That said, the built-in *strings* are oriented toward subscription/OAuth
providers with real ban risk (ChatGPT Web, Claude Web, Codex, Muse Spark,
Antigravity). An API-key provider will only trip the detector if its error body
literally contains one of the substrings.
## Custom banned keywords
Add or remove keywords in **Security → Banned Keywords** (persisted as the global
`customBannedSignals` setting via `PATCH /api/settings`). They are **added to**
the built-in list — never a replacement — and hot-reload on save (and at startup)
via `setCustomBannedSignals()`. Each keyword is capped at 200 characters; there is
no array-length limit.
**⚠ False-positive risk — choose specific phrases.** Detection is a raw substring
match on the whole response body, and a match is **permanent** (1-year cooldown,
manual recovery). A broad keyword can ban a perfectly healthy connection:
- **Bad:** `quota`, `limit`, `error`, `denied` — appear in many transient errors.
- **Good:** full ban sentences, e.g. `your account has been suspended for`,
`account permanently banned`, `violation of our terms`.
Prefer the longest unambiguous phrase the provider returns on a real ban. When in
doubt, watch the connection's `lastError` first, then add the exact wording.
## Recovering a flagged connection
Terminal `banned` / `deactivated` states **never auto-recover** (they are excluded
from the proactive-recovery tick — only `unavailable` cooldowns recover on their
own). An operator must clear them explicitly:
1. **Re-test the connection** — the dashboard **Test** action
(`POST /api/providers/{id}/test`); a successful probe resets `testStatus` to
`active` and clears the error fields.
2. **Re-authenticate / edit credentials** — for OAuth providers, re-run the login
/ refresh flow; provider create/import routes set `isActive = true`.
3. **Re-enable the connection** — if `autoDisableBannedAccounts` set
`isActive = false`, toggle it back on after fixing the account.
There is no separate "clear ban flag" button — recovery is re-test, re-auth, or
re-enable, matching the general terminal-state rule in
[RESILIENCE_GUIDE](../architecture/RESILIENCE_GUIDE.md).
## Source files
| Concern | File |
| --- | --- |
| Signal tables + match | `open-sse/services/accountFallback.ts` |
| Terminalization / persistence | `src/sse/services/auth.ts` (`markAccountUnavailable`, `resolveTerminalConnectionStatus`, `clearAccountError`) |
| Inline classification | `open-sse/handlers/chatCore.ts`, `open-sse/services/errorClassifier.ts` |
| Terminal-state recovery exclusion | `src/lib/quota/connectionRecovery.ts` |
| Custom-keyword runtime load | `src/lib/config/runtimeSettings.ts` (`setCustomBannedSignals`) |
| Settings UI | `src/app/(dashboard)/dashboard/settings/components/SecurityTab.tsx` |
+86
View File
@@ -0,0 +1,86 @@
---
title: "CLI Machine-ID Token"
---
# CLI Machine-ID Token
## Overview
OmniRoute CLI commands authenticate against the local management API using a
`HMAC-SHA256(machine-id, salt)` token sent via the `x-omniroute-cli-token`
request header.
This allows CLI subcommands (`omniroute status`, `omniroute providers`, etc.)
to call management endpoints without requiring the user to supply a JWT or
password on every invocation.
## How it works
1. `getMachineTokenSync()` reads the hardware machine ID via `node-machine-id`
(falls back to an empty string on failure, disabling CLI auth).
2. It computes `HMAC-SHA256(machine_id, salt)` and returns the full 64-char
hex digest — a deterministic, non-reversible token tied to this machine.
3. The CLI sends the token as `x-omniroute-cli-token` on every request to
`http://localhost:<port>/api/...`.
4. The server (`src/server/authz/policies/management.ts`) recomputes the
expected token with the same salt and compares via `timingSafeEqual` to
prevent timing-based extraction.
## Security properties
| Property | Detail |
| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| **Loopback-only** | Accepted only when `Host` is `localhost`, `127.0.0.1`, or `::1`. |
| **Constant-time compare** | `crypto.timingSafeEqual` prevents timing attacks. |
| **Non-reversible** | HMAC output cannot recover the machine-id. |
| **No `always`-protected bypass** | `isAlwaysProtectedPath()` is evaluated before the CLI token check. `/api/shutdown` and `/api/settings/database` always require JWT. |
| **Non-exportable** | Token is never written to disk or logged. |
## Salt rotation
Set `OMNIROUTE_CLI_SALT` to rotate the derived token without code changes.
After rotation, all CLI processes on this machine will use the new token
automatically. Useful after a process-list leak that may have exposed the
previous derived value.
```bash
# Persistent rotation (add to shell profile)
export OMNIROUTE_CLI_SALT="my-secret-salt-2026"
# Verify new token is in use
omniroute status
```
Default salt: `omniroute-cli-auth-v1`
## Legacy format (SHA-256, 32-char) — still accepted
Before the HMAC format above, the CLI derived its token as
`SHA-256(machineId + salt).hex[0..32]` (a 32-char prefix) in
`bin/cli/utils/cliToken.mjs` (`getLegacyCliTokenSync` in `src/lib/machineToken.ts`).
For backwards compatibility the server accepts **both** formats: the verifier builds
`expectedTokens = [getMachineTokenSync(), getLegacyCliTokenSync()]` and compares the
incoming header against each with `timingSafeEqual`
(`src/server/authz/policies/management.ts` and `src/lib/middleware/cliTokenAuth.ts`).
So a token is valid if it matches **either** the 64-char HMAC digest or the 32-char
legacy SHA-256 prefix.
**Opt-out:** set `OMNIROUTE_DISABLE_CLI_TOKEN=true` (env or `.env`) to disable the CLI
token mechanism entirely; all access then requires an explicit API key. On multi-user
hosts this is recommended, since `machine-id` is per-device (not per-user) and another
user on the same host could compute the same token.
## Files
| File | Purpose |
| ----------------------------------------- | ---------------------------------------- |
| `src/lib/machineToken.ts` | Token derivation (`getMachineTokenSync`) |
| `src/server/authz/headers.ts` | `CLI_TOKEN_HEADER` constant |
| `src/server/authz/policies/management.ts` | Server-side verification |
| `src/server/authz/routeGuard.ts` | Loopback host check (`isLoopbackHost`) |
## See also
- `docs/security/ROUTE_GUARD_TIERS.md` — route protection tiers
- `docs/architecture/AUTHZ_GUIDE.md` — full authorization pipeline
+235
View File
@@ -0,0 +1,235 @@
---
title: "Compliance & Audit"
version: 3.8.40
lastUpdated: 2026-06-28
---
# Compliance & Audit
> **Source of truth:** `src/lib/compliance/`, `src/app/api/compliance/`
> **Last updated:** 2026-06-28 — v3.8.40
OmniRoute records administrative actions, authentication events, provider
credential lifecycle changes, and MCP tool invocations to SQLite-backed audit
tables. This page covers what gets logged, where it lives, how long it is
retained, how API keys can opt out, and how to query the data.
The implementation lives in `src/lib/compliance/index.ts` (T-43 — "Compliance
Controls") and `src/lib/compliance/providerAudit.ts`. Audit writes never throw:
on any failure the call is silently swallowed so audit logging cannot break the
main request flow.
## What Gets Logged
### Administrative audit events (`audit_log`)
Every call to `logAuditEvent({ action, actor, target, details, ... })` produces
one row. Action strings follow a `domain.verb` (or `domain.verb.outcome`)
pattern. Confirmed in-tree action types include:
| Action | Source |
| ------------------------------------ | --------------------------------------- |
| `auth.login.success` | `src/app/api/auth/login/route.ts` |
| `auth.login.failed` | `src/app/api/auth/login/route.ts` |
| `auth.login.locked` | `src/app/api/auth/login/route.ts` |
| `auth.login.error` | `src/app/api/auth/login/route.ts` |
| `auth.login.misconfigured` | `src/app/api/auth/login/route.ts` |
| `auth.login.setup_required` | `src/app/api/auth/login/route.ts` |
| `auth.logout.success` | `src/app/api/auth/logout/route.ts` |
| `provider.credentials.created` | `src/app/api/providers/route.ts` |
| `provider.credentials.updated` | `src/app/api/providers/[id]/route.ts` |
| `provider.credentials.revoked` | `src/app/api/providers/[id]/route.ts` |
| `provider.credentials.batch_revoked` | `src/app/api/providers/route.ts` |
| `sync.token.created` | `src/app/api/sync/tokens/route.ts` |
| `sync.token.revoked` | `src/app/api/sync/tokens/[id]/route.ts` |
| `compliance.cleanup` | `src/lib/compliance/index.ts` |
Each entry captures `action`, `actor` (defaults to `"system"`), `target`,
`details`/`metadata` (JSON), `ip_address`, `resource_type`, `status`,
`request_id`, and `timestamp`. Sensitive keys (`apiKey`, `accessToken`,
`refreshToken`, `password`, anything matching `*token`/`*secret`/`*apikey`,
etc.) are recursively redacted to `"[redacted]"` before the row is written.
### MCP tool calls (`mcp_tool_audit`)
Every MCP tool invocation writes a row through
`open-sse/mcp-server/audit.ts`. Schema (from
`src/lib/db/migrations/002_mcp_a2a_tables.sql`):
| Column | Notes |
| ---------------- | ----------------------------------- |
| `id` | autoincrement |
| `tool_name` | MCP tool identifier |
| `input_hash` | sha256 of input (no payload stored) |
| `output_summary` | short, truncated summary |
| `duration_ms` | wall time |
| `api_key_id` | caller (nullable) |
| `success` | `1` / `0` |
| `error_code` | terminal error code on failure |
| `created_at` | ISO timestamp |
### Request / usage logs
These are operational telemetry (not strictly admin audit) but share the same
retention pipeline:
- `usage_history` — per-request usage roll-up
- `call_logs` — full per-request log (subject to row-cap, see below)
- `proxy_logs` — proxy traffic log (subject to row-cap)
- `request_detail_logs` — legacy detailed request log (still pruned if present)
## Storage Schema
`audit_log` is created lazily by `ensureAuditLogSchema()` on first use:
```sql
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT NOT NULL DEFAULT (datetime('now')),
action TEXT NOT NULL,
actor TEXT NOT NULL DEFAULT 'system',
target TEXT,
details TEXT,
ip_address TEXT,
resource_type TEXT,
status TEXT,
request_id TEXT,
metadata TEXT
);
```
Indexes are created on `timestamp`, `action`, `actor`, `resource_type`,
`status`, and `request_id`. Missing columns on legacy DBs are added via
`ALTER TABLE` on demand.
## Retention & Cleanup
Two separate retention windows are honoured:
| Env var | Default | Applies to |
| --------------------------- | -------- | ----------------------------------------------------------------- |
| `APP_LOG_RETENTION_DAYS` | `7` | `audit_log`, `mcp_tool_audit` |
| `CALL_LOG_RETENTION_DAYS` | `7` | `usage_history`, `call_logs`, `proxy_logs`, `request_detail_logs` |
| `CALL_LOGS_TABLE_MAX_ROWS` | `100000` | Row-cap trim for `call_logs` |
| `PROXY_LOGS_TABLE_MAX_ROWS` | `100000` | Row-cap trim for `proxy_logs` |
`cleanupExpiredLogs()` runs the retention pass. It is invoked on server startup
from `src/server-init.ts` and `src/instrumentation-node.ts`. Each run logs a
`compliance.cleanup` audit event with the per-table delete counts. Proxy/call
log trimming is batched (`BATCH_SIZE = 5000`) to avoid long write locks.
Manual request-history cleanup is separate from retention. The Request Logs
page calls `POST /api/settings/purge-request-history`, which deletes `call_logs`,
legacy `request_detail_logs`, and local request artifacts under
`${DATA_DIR}/call_logs/`.
Defaults are defined in `src/lib/logEnv.ts`
(`DEFAULT_APP_LOG_RETENTION_DAYS = 7`, `DEFAULT_CALL_LOG_RETENTION_DAYS = 7`).
## `noLog` Opt-Out (per API key)
API keys can be flagged so their downstream call traffic is not logged. The
flag lives on the `api_keys` table (`no_log INTEGER DEFAULT 0`) and is mirrored
into an in-memory set for hot-path lookups.
```bash
# Create a no-log key (management auth required)
curl -X POST http://localhost:20128/api/keys \
-H "Cookie: auth_token=..." \
-H "Content-Type: application/json" \
-d '{"name": "Privacy key", "noLog": true}'
```
Helpers (`src/lib/compliance/index.ts`):
- `setNoLog(apiKeyId, true|false)` — toggle the in-memory entry
- `isNoLog(apiKeyId)` — checked on the request path; falls back to a 30 s
cached read from `api_keys.no_log`
- `NO_LOG_API_KEY_IDS` (env, comma-separated) — preloaded into the in-memory
set on boot; useful when you cannot toggle the column directly
Administrative audit events (login, provider changes, MCP tool calls, etc.)
are **not** affected by `noLog` — only per-request traffic logging is opted
out.
## REST API
| Endpoint | Method | Description | Auth |
| --------------------------- | ------ | ------------------------------------------ | ---------- |
| `/api/compliance/audit-log` | `GET` | Paginated admin audit entries with filters | management |
| `/api/mcp/audit` | `GET` | Paginated MCP tool audit entries | (open-sse) |
| `/api/mcp/audit/stats` | `GET` | Aggregated MCP audit stats | (open-sse) |
No CSV export endpoint is shipped today — export from the dashboard or query
the SQLite database directly.
### Querying `/api/compliance/audit-log`
Supported query params (all optional, all use `LIKE %value%` matching for
text filters):
- `action`, `actor`, `target`, `resourceType` (or `resource_type`),
`status`, `requestId` (or `request_id`)
- `from` / `since`, `to` / `until` — ISO timestamps
- `limit` (default `50`, min `1`, max `500`)
- `offset` (default `0`, max `10_000`)
The response is a JSON array. Pagination metadata is returned in headers:
`x-total-count`, `x-page-limit`, `x-page-offset`.
```bash
curl "http://localhost:20128/api/compliance/audit-log?action=provider.credentials&from=2026-05-01" \
-H "Cookie: auth_token=..."
```
## Dashboard
The dashboard exposes audit data at **`/dashboard/audit`**
(`src/app/(dashboard)/dashboard/audit/page.tsx`). The page has two tabs:
- **Compliance** (`ComplianceTab.tsx`) — admin audit events from
`/api/compliance/audit-log`. Filters by event type, severity (info / warning
/ critical, derived from action + status), and date range. Severity is
computed client-side from the action/status strings.
- **MCP** (`McpAuditTab.tsx`) — MCP tool audit from `/api/mcp/audit`, with
filters by tool name and success/failure.
Both tabs paginate with page sizes of `50` (compliance) and `25` (MCP).
## Provider Credential Helpers
`src/lib/compliance/providerAudit.ts` provides shaping helpers used by the
provider-management routes when they emit credential events:
- `summarizeProviderConnectionForAudit(connection)` — strips `apiKey`,
`accessToken`, `refreshToken`, `idToken`, and
`providerSpecificData.consoleApiKey` before the connection snapshot is
written to `details`.
- `getProviderAuditTarget(connection)` — composes a stable
`"<provider>:<name|id>"` string for the `target` field.
- `extractProviderWarnings(...payloads)` — scans provider responses for
policy/safety warnings (`[sanitizer]`, `prompt injection detected`,
`content has been filtered`, `safety filter`, `policy violation`) and
surfaces up to 5 hits, each truncated to 400 chars.
## Best Practices
- Flag API keys handling PII (legal, medical, etc.) with `noLog: true`.
- Tune `APP_LOG_RETENTION_DAYS` / `CALL_LOG_RETENTION_DAYS` to meet your
retention policy. The 7-day defaults are conservative.
- Export the audit table off-platform (`sqlite3 dump`) on whatever cadence
your compliance program requires — no built-in archival exists.
- Track `auth.login.failed` and `auth.login.locked` counts for brute-force
detection.
- When adding new admin endpoints, call `logAuditEvent({ ... })` with a stable
`domain.verb.outcome` action string and pass the request context via
`getAuditRequestContext(request)` so IP and `requestId` are captured
automatically.
## See Also
- [`docs/security/GUARDRAILS.md`](./GUARDRAILS.md) — PII masking, prompt injection
- [`docs/frameworks/MCP-SERVER.md`](../frameworks/MCP-SERVER.md) — MCP tool catalog and scopes
- [`docs/reference/ENVIRONMENT.md`](../reference/ENVIRONMENT.md) — full env var reference
- Source: `src/lib/compliance/`, `src/app/api/compliance/`,
`src/app/api/mcp/audit/`, `src/lib/logEnv.ts`
+162
View File
@@ -0,0 +1,162 @@
---
title: CORS Configuration & Security
---
# CORS Configuration & Security
OmniRoute controls which **browser origins** may read cross-origin responses
from a single, centralized allowlist. The model is **fail-closed by default**:
no origin is allowed until you opt one in. This page documents how the allowlist
resolves, what `CORS_ALLOW_ALL=true` actually exposes (and, importantly, what it
does **not**), how to configure dev vs production safely, and the runtime warning
the dashboard shows when a wildcard is live.
**Source of truth:** `src/server/cors/origins.ts` (`resolveAllowedOrigin`,
`applyCorsHeaders`, `getCorsStatus`). The allowlist is applied once, in the
middleware (`src/server/authz/pipeline.ts`) — per-route handlers do not set
`Access-Control-Allow-Origin` themselves.
## How an origin is resolved
For each request the middleware computes the `Access-Control-Allow-Origin` value
in this order:
1. **`CORS_ALLOW_ALL=true`** (or the legacy `CORS_ORIGIN=*`) → echo the caller's
`Origin` back (or `*` when there is no `Origin` header), with `Vary: Origin`
so caches stay correct.
2. Otherwise, the request `Origin` is normalized (lower-cased, trailing slash
stripped) and matched against the **merged allowlist**:
- env **`CORS_ALLOWED_ORIGINS`** — comma-separated list, and
- the runtime **`corsOrigins`** setting (Dashboard → Security → _CORS Allowed
Origins_), injected via `setRuntimeAllowedOrigins()` from
`src/lib/config/runtimeSettings.ts`.
3. No match → **no `Access-Control-Allow-Origin` header is emitted**. The browser
blocks the cross-origin read. This is the intended fail-closed default.
| Env var | Meaning |
| ---------------------- | ------------------------------------------------------------------------------------ |
| `CORS_ALLOWED_ORIGINS` | CSV of exact origins to allow (recommended). |
| `CORS_ALLOW_ALL` | `true`/`1` → echo any origin (wildcard). Dev only. |
| `CORS_ORIGIN` | Legacy. `*` behaves like `CORS_ALLOW_ALL`; a single value is added to the allowlist. |
## Threat model — what `CORS_ALLOW_ALL=true` really exposes
The generic OWASP warning ("wildcard CORS = any site can call your API") is worth
taking seriously, but OmniRoute's exposure is **narrower than the generic case**,
because of one concrete implementation fact:
> **The central `applyCorsHeaders()` never emits
> `Access-Control-Allow-Credentials`.** A browser will not expose a _credentialed_
> (cookie-bearing) cross-origin response unless the server sends
> `Access-Control-Allow-Credentials: true`. OmniRoute's shared CORS path never
> does.
What that means per surface, even with `CORS_ALLOW_ALL=true`:
| Surface | Auth mechanism | Effect of wildcard CORS |
| ----------------------------------- | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Dashboard / MANAGEMENT `/api/*` | Cookie session | Origin is echoed, but **without `Allow-Credentials`** the browser **blocks** the credentialed read. A malicious cross-origin site **cannot read** your authenticated dashboard responses, and the session cookie is not exposed. |
| Client API `/v1/*`, `/v1beta/*` | Bearer / `x-api-key` header | Already permissive **by design** (`relaxForTokenAuth`): browsers never auto-attach `Authorization`/`x-api-key`, so an attacker's page cannot supply your key. `CORS_ALLOW_ALL` does not widen this. |
| Public read-only (`/api/health`, …) | None | Non-sensitive; wildcard is harmless. |
So the **residual** exposure of `CORS_ALLOW_ALL=true` is limited to: (a)
non-credentialed cross-origin **reads** of already-unauthenticated data, and (b)
letting CORS **preflight pass** on management routes — which still require auth
that a cross-origin page cannot provide. It is **not** a session-hijack or
credential-theft vector on the shared CORS path.
### One genuine exception — `/api/v1/agents/`
The Cloud-Agent routes (`/api/v1/agents/{health,credentials,tasks,tasks/[id]}`) set
their **own** CORS headers
(`src/lib/cloudAgent/api.ts`, `getCloudAgentCorsHeaders`) and **do** emit
`Access-Control-Allow-Origin: <origin>|*` together with
`Access-Control-Allow-Credentials: true`. This is the single surface where
origin-echo and credentials coexist, and it is **independent of
`CORS_ALLOW_ALL`**. These routes are management-authenticated
(`requireManagementAuth`); operators who expose the dashboard off-host should be
aware that this is the one place a cross-origin credentialed read is permitted by
the response headers. Tightening it to an explicit allowlist is tracked
separately from this CORS guidance.
## Production checklist
- **Never set `CORS_ALLOW_ALL=true` in production.** Leave it unset.
- Set an **explicit** origin list — either the env var or the Security-tab field:
```bash
CORS_ALLOWED_ORIGINS="https://app.example.com, https://admin.example.com"
```
- If OmniRoute runs behind a reverse proxy / tunnel (nginx, Caddy, Cloudflare
Tunnel, Tailscale), CORS is **not** your only control — the loopback route
guard still protects spawn-capable routes (see
[ROUTE_GUARD_TIERS](./ROUTE_GUARD_TIERS.md)). Do not forge
`X-Forwarded-For: 127.0.0.1` to "fix" a 403; that re-opens the RCE class the
route guard closes.
- Confirm the runtime state: the dashboard shows a **persistent amber banner**
under Dashboard → Security → Authorization Inventory whenever
`CORS_ALLOW_ALL=true` is live, and `/api/settings/authz-inventory` returns a
`cors: { allowAll, allowedOrigins }` envelope monitoring tools can poll.
## Development convenience — allow specific local origins
You rarely need the wildcard even in dev. Allow just the dev servers you use:
```bash
# Vite (5173) + Next.js (3000) dev servers calling a local OmniRoute
CORS_ALLOWED_ORIGINS="http://localhost:5173, http://localhost:3000"
```
Origins are matched case-insensitively with the trailing slash ignored, so
`http://localhost:3000` and `http://localhost:3000/` are equivalent. The same CSV
can be set at runtime in **Dashboard → Security → CORS Allowed Origins** without a
restart.
## API keys vs cookie sessions
- **Bearer / `x-api-key` (the `/v1/*` inference surface):** browsers never attach
these automatically. CORS is not a meaningful barrier here — the API key is the
barrier — which is why that surface is intentionally permissive so browser and
Electron clients can read responses they are already entitled to.
- **Cookie session (the dashboard):** protected by the fail-closed default **and**
by the absence of `Access-Control-Allow-Credentials` on the shared path. Keep
management/dashboard origins out of any permissive config; they must stay exactly
fail-closed.
## Example: reverse proxy in front of OmniRoute
CORS is enforced by OmniRoute itself, so the proxy generally should **not** add or
rewrite `Access-Control-*` headers (double headers break browsers). Terminate TLS
and forward — let OmniRoute answer preflight:
```nginx
# nginx — forward to OmniRoute; do NOT inject Access-Control-* here
location / {
proxy_pass http://127.0.0.1:20128;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
# Do NOT set X-Forwarded-For to 127.0.0.1 — it defeats the loopback route guard.
}
```
Set the allowed browser origins in OmniRoute (`CORS_ALLOWED_ORIGINS` or the
Security tab), not in the proxy.
## Source files
| Concern | File |
| ----------------------------------------------- | -------------------------------------------------------------------- |
| Allowlist resolution + `getCorsStatus()` | `src/server/cors/origins.ts` |
| Middleware application (single source of truth) | `src/server/authz/pipeline.ts` |
| Settings → runtime origin injection | `src/lib/config/runtimeSettings.ts` |
| Runtime status for the dashboard | `src/app/api/settings/authz-inventory/route.ts` |
| Dashboard warning banner | `src/app/(dashboard)/dashboard/settings/components/AuthzSection.tsx` |
| CORS Allowed Origins field | `src/app/(dashboard)/dashboard/settings/components/SecurityTab.tsx` |
| Cloud-Agent per-route CORS (the exception) | `src/lib/cloudAgent/api.ts` |
## See also
- [Route Guard Tiers](./ROUTE_GUARD_TIERS.md) — loopback enforcement for
spawn-capable routes (a separate, complementary control).
- [Authorization Guide](../architecture/AUTHZ_GUIDE.md) — the full auth pipeline.
+250
View File
@@ -0,0 +1,250 @@
---
title: "Egress IP Family Policy (IPv4/IPv6)"
version: 3.8.40
lastUpdated: 2026-06-28
---
# Egress IP Family Policy (IPv4/IPv6)
> **Pin outbound traffic to a single IP family — `auto`, `ipv4`, or `ipv6` — per proxy, so an IPv6-only egress never silently leaks back to IPv4.**
> **Source of truth:** `open-sse/utils/proxyFamily.ts`, `open-sse/utils/proxyDispatcher.ts`, `open-sse/utils/proxyFetch.ts`, `open-sse/utils/socksConnectorWithFamily.ts`, `open-sse/utils/proxyFamilyResolve.ts`, `src/shared/validation/schemas.ts`, `src/lib/db/proxies.ts`, `src/lib/db/upstreamProxy.ts`, `src/lib/db/migrations/099_proxy_family.sql`
OmniRoute lets each proxy carry an **address-family egress directive**. By default the OS picks IPv4 or IPv6 (dual-stack, "Happy Eyeballs"). When you set the directive to `ipv4` or `ipv6`, OmniRoute pins every connection through that proxy to the chosen family and **fails closed** rather than falling back to the other family.
This page documents what the directive is, why it exists, where you configure it, and how the runtime resolves it.
---
## Table of Contents
- [What It Is](#what-it-is)
- [Why It Exists](#why-it-exists)
- [The Three Values](#the-three-values)
- [How to Configure It](#how-to-configure-it)
- [How `auto` Resolves](#how-auto-resolves)
- [How `ipv4` / `ipv6` Are Enforced](#how-ipv4--ipv6-are-enforced)
- [SOCKS5 Compatibility](#socks5-compatibility)
- [Fail-Closed Behavior](#fail-closed-behavior)
- [Data Model](#data-model)
- [Related Documentation](#related-documentation)
---
## What It Is
Every proxy in the registry has a `family` field with three possible values, validated by a Zod enum:
```ts
// src/shared/validation/schemas.ts
family: z.enum(["auto", "ipv4", "ipv6"]).optional().default("auto"),
```
The field defaults to `"auto"`, which preserves the prior dual-stack behavior. Setting it to `ipv4` or `ipv6` pins the connect family for that proxy.
The directive is normalized everywhere through a single helper so any unknown value collapses to `auto`:
```ts
// open-sse/utils/proxyFamily.ts
export type ProxyFamily = "auto" | "ipv4" | "ipv6";
export function parseProxyFamily(value: unknown): ProxyFamily {
return value === "ipv4" || value === "ipv6" ? value : "auto";
}
```
---
## Why It Exists
Introduced in PR [#3777](https://github.com/diegosouzapw/OmniRoute/pull/3777). The motivating problems:
| Problem | What the directive fixes |
| ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **IPv6-only egress leaking to IPv4** | When a proxy host has both A and AAAA records (or the OS prefers IPv4), Happy Eyeballs can dial out over IPv4 even when you intend an IPv6-only path. Pinning `ipv6` removes that leak. |
| **Shared-egress anomaly revocation** | Rotating providers (codex/openai) revoke tokens when many accounts egress through the **same** IP at high volume. Controlling the egress family is part of keeping accounts on distinct, predictable egress paths (see [`src/lib/proxyEgress.ts`](../../src/lib/proxyEgress.ts) for the egress-IP diagnostics that pair with this). |
| **Deterministic egress for compliance/testing** | When you must guarantee traffic leaves over a specific family, `auto` is not enough. |
The directive is intentionally **per-proxy**, not global — different proxies in your pool can have different policies.
---
## The Three Values
| Value | UI label | Behavior |
| ------ | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `auto` | `Auto (dual-stack)` | OS picks the family. For an IP-literal proxy host, the family is intrinsic to the literal; for a hostname, both families are eligible. This is the default. |
| `ipv4` | `IPv4 only` | Pins the connection to IPv4. Fails closed if the proxy host has no IPv4 (A) record. |
| `ipv6` | `IPv6 only` | Pins the connection to IPv6. Fails closed if the proxy host has no IPv6 (AAAA) record. |
UI strings live in `src/i18n/messages/en.json` (`labelFamily`, `familyAuto`, `familyIpv4`, `familyIpv6`, `familyHint`).
---
## How to Configure It
### Dashboard
The selector is in the proxy form of the **Proxy Pool** tab:
1. Open **Dashboard → Settings → Proxy → Proxy Pool**
2. Add or edit a proxy
3. Set the **IP family** dropdown to `Auto (dual-stack)`, `IPv4 only`, or `IPv6 only`
4. Save
The control is rendered by `ProxyRegistryManager.tsx` (mounted in `proxy/ProxyPoolTab.tsx`).
### API
The `family` field is part of the proxy registry create/update payloads, validated by `createProxyRegistrySchema` / `updateProxyRegistrySchema` (`src/shared/validation/schemas.ts`) and handled by `POST` / `PATCH /api/v1/management/proxies`:
```bash
# Create an IPv6-only proxy
curl -X POST http://localhost:20128/api/v1/management/proxies \
-H "Content-Type: application/json" \
-d '{
"name": "IPv6 egress",
"type": "socks5",
"host": "proxy.example.com",
"port": 1080,
"family": "ipv6"
}'
# Change an existing proxy to IPv4-only
curl -X PATCH http://localhost:20128/api/v1/management/proxies \
-H "Content-Type: application/json" \
-d '{ "id": "proxy-uuid-here", "family": "ipv4" }'
```
The same field is also accepted by the inline proxy config object used for upstream-proxy entries (`upstream_proxy_config.family`, see [Data Model](#data-model)).
For the rest of the proxy CRUD/assignment API, see [PROXY_GUIDE.md](../ops/PROXY_GUIDE.md).
---
## How `auto` Resolves
When `family` is `auto`, OmniRoute does **not** append any directive — the proxy URL is used as-is and the connect family is determined intrinsically.
At URL-build time (`proxyConfigToUrl` / `normalizeProxyUrl` in `open-sse/utils/proxyDispatcher.ts`), an `auto` proxy yields a plain URL with no marker:
```ts
// open-sse/utils/proxyDispatcher.ts
const fam = parseProxyFamily(config.family);
const normalized = normalizeProxyUrl(proxyUrlStr, "context proxy", { allowSocks5 });
return fam === "auto" ? normalized : `${normalized}?family=${fam}`;
```
At dispatch time (`resolveDispatcherFamily`), `auto` resolves to the intrinsic family of an IP-literal host, or `null` (let the OS decide) for a hostname:
```ts
// open-sse/utils/proxyDispatcher.ts
function resolveDispatcherFamily(parsed: URL): 4 | 6 | null {
const directive = parseProxyFamily(parsed.searchParams.get("family") ?? undefined);
const literal = detectIpLiteralFamily(parsed.hostname);
if (directive === "auto") return literal; // null for a hostname → OS picks
// ...
}
```
So:
- `auto` + IP-literal host (`192.0.2.1` / `[2001:db8::1]`) → family of that literal.
- `auto` + hostname → `null` → standard dual-stack OS resolution.
---
## How `ipv4` / `ipv6` Are Enforced
A non-`auto` directive travels as a single synthetic query marker — `?family=ipv4` or `?family=ipv6` — appended once to the normalized proxy URL. `normalizeProxyUrl` is careful to strip and re-append this marker exactly once so it never corrupts port parsing.
When the dispatcher is built, the marker is read and converted to a concrete connect family. If the host is an IP literal of the **opposite** family, OmniRoute throws (contradiction is fail-closed):
```ts
// open-sse/utils/proxyDispatcher.ts
const want = directive === "ipv6" ? 6 : 4;
if (literal !== null && literal !== want) {
throw new Error(
`[ProxyDispatcher] Proxy family directive ${directive} contradicts ${literal === 6 ? "IPv6" : "IPv4"} literal host`
);
}
```
The concrete family is then pinned on the connector:
- **HTTP/HTTPS proxies** (`ProxyAgent`): `proxyTls: { family, autoSelectFamily: false }` — disables Happy Eyeballs so the chosen family is the only one dialed.
- **SOCKS5 proxies**: a custom connector threads `socket_options: { family, autoSelectFamily: false }` into the SOCKS client (see [SOCKS5 Compatibility](#socks5-compatibility)).
---
## SOCKS5 Compatibility
The family pin works with SOCKS5 proxies, but stock `fetch-socks` does not expose the socket options needed to pin the family of the proxy hop. OmniRoute ships its own connector for that:
```ts
// open-sse/utils/socksConnectorWithFamily.ts
export function buildSocksFamilySocketOptions(family: 4 | 6 | null): Record<string, unknown> {
if (family === 6) return { family: 6, autoSelectFamily: false };
if (family === 4) return { family: 4, autoSelectFamily: false };
return {};
}
```
`createProxyDispatcher` chooses the connector based on whether a family is pinned:
- `family === null` (i.e. `auto` over a hostname) → stock `socksDispatcher` from `fetch-socks`.
- `family === 4 | 6``createSocksDispatcherWithFamily`, which threads `socket_options` into `SocksClient.createConnection` so Happy Eyeballs cannot pick IPv4 for an IPv6-only egress policy.
SOCKS5 support itself is on by default (opt-out via `ENABLE_SOCKS5_PROXY=false`); see [PROXY_GUIDE.md → Environment Variables](../ops/PROXY_GUIDE.md#environment-variables).
---
## Fail-Closed Behavior
The whole point of the directive is to **refuse** rather than silently fall back to the wrong family. Two guards enforce this:
1. **Literal contradiction** — a directive that contradicts an IP-literal host throws at dispatcher build time (`resolveDispatcherFamily`, shown above).
2. **Hostname pre-flight DNS check** — for a hostname proxy with a pinned family, `proxyFetch.ts` verifies the hostname actually has a record in the required family **before** egressing, via `assertHostnameSupportsFamily`:
```ts
// open-sse/utils/proxyFamilyResolve.ts
const hasFamily = records.some((r) => r.family === family);
if (!hasFamily) {
throw new Error(
`[ProxyFamily] Proxy host ${host} has no ${family === 6 ? "IPv6 (AAAA)" : "IPv4 (A)"} record; ` +
`refusing ${family === 6 ? "IPv6" : "IPv4"}-only egress (fail-closed)`
);
}
```
On failure, `proxyFetch.ts` tags the error with `code = "PROXY_FAMILY_UNAVAILABLE"` and `statusCode = 503`. A DNS resolution failure is likewise treated as fail-closed (refuse to egress).
IP-literal hosts are a no-op for the DNS pre-flight — their family is intrinsic and needs no lookup.
---
## Data Model
The `family` column was added by migration `099_proxy_family.sql` to **two** tables:
```sql
-- src/lib/db/migrations/099_proxy_family.sql
ALTER TABLE proxy_registry ADD COLUMN family TEXT NOT NULL DEFAULT 'auto';
ALTER TABLE upstream_proxy_config ADD COLUMN family TEXT NOT NULL DEFAULT 'auto';
```
- `proxy_registry.family` — the per-proxy directive for registry entries (`src/lib/db/proxies.ts`). Resolution queries select `family` alongside the other proxy columns, and a missing/non-string value is coerced to `"auto"`.
- `upstream_proxy_config.family` — the directive for upstream-proxy entries (`src/lib/db/upstreamProxy.ts`), with the same `"auto"` default.
When a resolved proxy object carries a non-`auto` `family`, `proxyConfigToUrl` appends the `?family=` marker so the pin survives all the way to the dispatcher.
---
## Related Documentation
> 📖 **Related documentation:**
>
> - [Proxy Guide](../ops/PROXY_GUIDE.md) — full proxy system: registry CRUD, 4-level resolution, rotation, health checking, API reference
> - [Stealth Guide](./STEALTH_GUIDE.md) — TLS fingerprint and CLI fingerprint layers that ride on top of the proxy
> - [Route Guard Tiers](./ROUTE_GUARD_TIERS.md) — loopback enforcement for local-only routes
+177
View File
@@ -0,0 +1,177 @@
---
title: "Error Message Sanitization"
version: 3.8.40
lastUpdated: 2026-06-28
---
# Error Message Sanitization
> **Source of truth:** `open-sse/utils/error.ts` — `sanitizeErrorMessage`, `buildErrorBody`, `createErrorResult`
> **Tests:** `tests/unit/error-message-sanitization.test.ts`
> **Last updated:** 2026-06-28 — v3.8.40
> **Audience:** Any engineer touching error responses (HTTP routes, SSE streams, executors, MCP handlers).
> **Status:** **MANDATORY** for every code path that returns an error message to a client.
## Why this exists
CodeQL rule `js/stack-trace-exposure` (CWE-209) flags any code path where an error message originating from a runtime exception reaches an HTTP / SSE response without being sanitized. Stack traces and absolute file paths in production responses give attackers:
- Internal directory layout (`/srv/app/src/lib/...`) → reconnaissance for further attacks.
- Library / framework versions inferred from stack frames → targeted exploit selection.
- Sensitive runtime values that may be string-interpolated into errors (DB queries, config values).
The `sanitizeErrorMessage` helper in `open-sse/utils/error.ts` strips both classes of leakage:
1. Multi-line stack traces — only the first line (the actual error message) is kept.
2. Absolute paths (`/...*.{ts,js,tsx,jsx,mjs,cjs}[:line[:col]]` and `C:\...`) — replaced with `<path>`.
## The mandatory pattern
### 1. Building an error response (HTTP / API routes)
Use `buildErrorBody()` — sanitization is built-in:
```ts
import { buildErrorBody } from "@omniroute/open-sse/utils/error.ts";
export async function POST(req: Request) {
try {
// ... handler logic ...
} catch (err) {
return new Response(JSON.stringify(buildErrorBody(500, String(err))), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
}
```
Or, for the convenience wrappers in the same module:
```ts
import {
errorResponse, // one-shot Response object
writeStreamError, // SSE writer
createErrorResult, // { success: false, status, response, ... } shape
unavailableResponse, // adds Retry-After
providerCircuitOpenResponse,
modelCooldownResponse,
} from "@omniroute/open-sse/utils/error.ts";
```
All of these route through `buildErrorBody` and therefore through `sanitizeErrorMessage`. **You never need to call `sanitizeErrorMessage` manually** when using these helpers.
### 2. Custom error envelopes (rare)
When you can't use the helpers above (e.g. the response shape is dictated by an upstream protocol like Connect-RPC), import `sanitizeErrorMessage` directly:
```ts
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error.ts";
const body = JSON.stringify({
error: {
message: sanitizeErrorMessage(rawMessage),
type: "invalid_request_error",
code: "",
},
});
```
This is the only sanctioned way to assemble a custom error body. See `open-sse/executors/cursor.ts::buildErrorResponse` for the reference implementation.
### 3. Logging vs. responding
`sanitizeErrorMessage` should **only** wrap the value that crosses the network boundary. Internal logs (`pino`, `console`) should keep the full message, including stack, so operators can debug. Pattern:
```ts
try {
// ...
} catch (err) {
log.error({ err }, "handler failed"); // full err with stack — internal log
return errorResponse(500, getErrorMessage(err)); // sanitized — sent to client
}
```
### 4. Forbidden patterns
**Never** put raw exception output in a Response body:
```ts
// BAD: stack trace + file paths reach the client
return new Response(JSON.stringify({ error: { message: err.stack || err.message } }), {
status: 500,
});
```
**Never** roll your own first-line splitter:
```ts
// BAD: forgets to strip absolute paths, may drift from the canonical helper
const safe = String(err).split("\n")[0];
```
**Never** sanitize in the route and forget the SSE path. Anything that writes to a stream goes through `writeStreamError` (or its underlying `buildErrorBody`).
**Never** include `process.cwd()`, `__filename`, `__dirname`, env-derived paths in error messages — they bypass the path regex and reveal the deployment topology.
## Coverage in CI
`tests/unit/error-message-sanitization.test.ts` enforces:
- Every route under `/api/model-combo-mappings/*` returns sanitized bodies on 4xx/5xx.
- `sanitizeErrorMessage` strips multi-line stack traces.
- `sanitizeErrorMessage` replaces POSIX and Windows absolute paths with `<path>`.
- `sanitizeErrorMessage` handles `null`/`undefined`/`Error` instance inputs safely.
- `buildErrorBody` never exposes stack traces in its `message` field.
When adding a new route or executor, copy the assertion pattern from this file. The coverage gate (`npm run test:coverage`) enforces ≥60% statements/lines/functions/branches — error paths must be covered.
## Related controls
- `js/stack-trace-exposure` CodeQL alerts in `.github/security` should always be **either** fixed via these helpers **or** dismissed with a comment citing this doc.
- The `pino` redaction config (`src/shared/utils/logRedaction.ts`) handles structured log redaction separately. This doc covers only the response-message surface.
- Upstream-header denylist (`src/shared/constants/upstreamHeaders.ts`) covers header leakage — keep both files aligned when adding a new exfiltration concern.
## Upstream details passthrough
`buildErrorBody` accepts an optional third argument `upstreamDetails` (raw
parsed body from the upstream provider). When provided, it is sanitized by
`sanitizeUpstreamDetails` before inclusion in the response as `upstream_details`.
Sanitization rules applied to `upstreamDetails`:
1. String leaves: run through `sanitizeErrorMessage` (strips stacks + absolute paths).
2. Key blocklist: keys matching `/stack|trace|path|file|cwd|dir|password|secret|token|key/i`
are removed.
3. Depth cap: nesting beyond 4 levels is replaced with the string `"[truncated]"`.
4. Arrays are capped at 32 elements.
Only the seven upstream-error `createErrorResult` call sites in `chatCore.ts` pass
`upstreamErrorBody`. Internal OmniRoute errors (SSE parse failures, empty content,
guardrail blocks) do not include `upstream_details`.
Do NOT pass raw `err.stack`, `err.message`, or any string from a runtime exception to
`upstreamDetails`. Those must still go through `errorResponse` / `buildErrorBody(code, msg)`
without an upstream body.
## Known CodeQL limitation: custom sanitizers not recognized
The CodeQL query [`js/stack-trace-exposure`](https://codeql.github.com/codeql-query-help/javascript/js-stack-trace-exposure/) uses a fixed allowlist of sanitizer patterns (e.g. inline `.split("\n")[0]`, `String#replace` with specific regex shapes, access to `.message` on `Error`). It does **not** recognize indirection through a custom helper like our `sanitizeErrorMessage()`.
This means callsites that demonstrably sanitize via this module — for example `open-sse/utils/error.ts::errorResponse` and `open-sse/executors/cursor.ts::buildErrorResponse` — may continue to raise the alert even though the code is functionally safe. Precedent dismissals: `#224`, `#231` (May 2026), both marked `false positive` with technical justification.
**How to handle a new occurrence:**
1. Confirm the callsite actually routes the message through `sanitizeErrorMessage` / `buildErrorBody` / one of the wrappers documented above (read the call chain end-to-end — don't trust a comment).
2. Confirm `tests/unit/error-message-sanitization.test.ts` exercises the path (or add coverage).
3. Dismiss the alert via `gh api ... -X PATCH state=dismissed -f 'dismissed_reason=false positive'` referencing this doc.
4. Do **not** "fix" by inlining `.split("\n")[0]` everywhere — the helper is the single source of truth; duplicating the pattern weakens the sanitizer (loses path scrubbing, length cap, type coercion) for the appearance of placating the scanner.
Adopting opt-in features like CodeQL's [`@codeql/javascript-models` custom sanitizer config](https://codeql.github.com/docs/codeql-language-guides/customizing-library-models-for-javascript/) is the long-term fix; it lives outside this doc.
## References
- [CWE-209: Information Exposure Through an Error Message](https://cwe.mitre.org/data/definitions/209.html)
- [CodeQL `js/stack-trace-exposure`](https://codeql.github.com/codeql-query-help/javascript/js-stack-trace-exposure/)
- [OWASP: Error Handling Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Error_Handling_Cheat_Sheet.html)
- Commit centralizing the helper: `1a39c31f`_fix(security): mask public upstream creds + centralize error sanitization_
+321
View File
@@ -0,0 +1,321 @@
---
title: "Guardrails"
version: 3.8.40
lastUpdated: 2026-06-28
---
# Guardrails
> **Source of truth:** `src/lib/guardrails/`
> **Last updated:** 2026-06-28 — v3.8.40 (injection-guard coverage + 16 KB scan bound + red-team)
Guardrails enforce safety, policy, and content transformations at the boundary
between OmniRoute and upstream providers. Each guardrail can inspect (and
optionally reject, transform, or annotate) request payloads (`preCall`) and
upstream responses (`postCall`).
The system is **fail-open**: if a guardrail throws while executing, the registry
records the error and continues with the next guardrail rather than failing the
request. Blocking is an explicit decision (`block: true`), never an accident.
## Built-in Guardrails
The registry auto-loads three guardrails in priority order on import
(see `registry.ts``registerDefaultGuardrails()`):
| Priority | Name | Stage(s) | File |
| -------- | ------------------ | -------------- | -------------------- |
| `5` | `vision-bridge` | `preCall` | `visionBridge.ts` |
| `10` | `pii-masker` | `pre` + `post` | `piiMasker.ts` |
| `20` | `prompt-injection` | `preCall` | `promptInjection.ts` |
Lower priority numbers run **first**.
### Vision Bridge (`visionBridge.ts`)
Intercepts image-bearing requests aimed at **non-vision models** and replaces
the image parts with text descriptions produced by a configurable vision model
before the upstream call. This lets text-only providers transparently handle
multimodal payloads.
Flow:
1. Skip if the target model already supports vision (unless it appears in the
forced-bridge list `isVisionBridgeForcedModel`).
2. Extract image parts via `extractImageParts(messages)`. Skip if none.
3. Load runtime config from `getSettings()` (`visionBridgeEnabled`,
`visionBridgeModel`, `visionBridgePrompt`, `visionBridgeTimeout`,
`visionBridgeMaxImages`).
4. Cap images at `maxImages`, call the vision model **in parallel**
(`Promise.allSettled`), and inject `[Image N]: <description>` text parts
in their place — failed images become `[Image N]: (unavailable)`.
5. Return `modifiedPayload` + meta (`imagesProcessed`, `processingTimeMs`,
`visionModel`).
Defaults live in `src/shared/constants/visionBridgeDefaults.ts`. The guardrail
exposes a `deps` constructor option so tests can inject fake `getSettings` and
`callVisionModel` implementations.
### PII Masker (`piiMasker.ts`)
Runs on **both** stages.
- **`preCall`** clones the payload, walks `system`, `messages`, and `input`
arrays, and applies `processPII()` (from `@/shared/utils/inputSanitizer`) to
string `content`/`text` fields. When `PII_REDACTION_ENABLED=true` **and**
`INPUT_SANITIZER_MODE=redact`, detected PII is stripped/redacted in the
outbound payload. Otherwise the call records detection counts without
rewriting content.
- **`postCall`** deep-clones the response, runs `sanitizePIIResponse()` plus
the Responses-API-shape masker (`maskResponsesOutput` — covers
`output_text` and `output[].content[].text`). If any redaction occurs, the
modified response replaces the original.
The guardrail never blocks; it only annotates (`meta.detections`,
`meta.redacted`) or rewrites.
### Prompt Injection (`promptInjection.ts`)
Detects adversarial structures in user-supplied content and enforces the
configured policy. Behavior is driven by environment variables and constructor
options:
| Setting | Env var | Default | Effect |
| --------------- | ----------------------------------------------- | ------- | --------------------------------------- |
| Enabled | `INPUT_SANITIZER_ENABLED` | `true` | When `false`, guardrail short-circuits. |
| Mode | `INJECTION_GUARD_MODE` / `INPUT_SANITIZER_MODE` | `warn` | `block`, `warn`, or `log`. |
| Block threshold | `blockThreshold` option | `high` | Minimum severity required to block. |
**Mode precedence** (`getMode`): caller `options.mode`
`INJECTION_GUARD_MODE` **DB feature-flag override** (Dashboard → Settings →
Feature Flags) → `INJECTION_GUARD_MODE` env → `INPUT_SANITIZER_MODE` env →
`warn`. A dashboard override therefore wins over the env vars, so the Feature
Flags UI controls the running guard live (no restart). The DB read is fail-safe:
if it errors, the guard falls back to the env-based behavior, and when no
override is set behavior is identical to env-only resolution.
Detection sources:
1. `sanitizeRequest()` from `@/shared/utils/inputSanitizer` (shared detector
set used elsewhere in the pipeline).
2. Built-in `DEFAULT_GUARD_PATTERNS` (currently `system_override_inline` and
`markdown_system_block`, both `high` severity).
3. Optional `customPatterns` passed via constructor options (strings, regex,
or `{ name, pattern, severity }` records).
When `mode === "block"` **and** at least one detection meets the severity
threshold, `preCall` returns `{ block: true, message: "Request rejected:
suspicious content detected" }`. In `warn`/`log` modes the guardrail logs but
allows the call. The shared helper `evaluatePromptInjection()` is also exported
for callers that need to evaluate prompts without going through the registry.
**Scan bound (v3.8.20):** the detector only inspects the **first 16 KB** of
joined prompt text — `MAX_INJECTION_SCAN_BYTES = 16 * 1024` (16 384 bytes) in
`src/shared/utils/inputSanitizer.ts`. Both `detectInjection()` and
`evaluatePromptInjection()` `slice(0, MAX_INJECTION_SCAN_BYTES)` before running
the pattern loop. Injection directives sit near the top of an input, so this
caps regex CPU/GC on multi-hundred-KB payloads without weakening detection (cf.
#3932, #4041).
## Base Contract (`base.ts`)
```typescript
class BaseGuardrail {
enabled: boolean;
name: string;
priority: number;
constructor(name: string, options?: { enabled?: boolean; priority?: number });
async preCall(payload: unknown, context: GuardrailContext): Promise<GuardrailResult | void>;
async postCall(response: unknown, context: GuardrailContext): Promise<GuardrailResult | void>;
}
interface GuardrailResult<TValue = unknown> {
block?: boolean; // true short-circuits the chain
message?: string; // surfaced when blocking
meta?: Record<string, unknown> | null;
modifiedPayload?: TValue; // returned by preCall to rewrite the request
modifiedResponse?: TValue; // returned by postCall to rewrite the response
}
interface GuardrailContext {
apiKeyInfo?: Record<string, unknown> | null;
disabledGuardrails?: string[] | null;
endpoint?: string | null;
headers?: Headers | Record<string, unknown> | null;
log?: GuardrailLog | Console | null;
method?: string | null;
model?: string | null;
provider?: string | null;
sourceFormat?: string | null;
stream?: boolean;
targetFormat?: string | null;
}
```
A guardrail signals "no change" by returning either `void`, `{}`, or
`{ block: false }`. Returning a `modifiedPayload`/`modifiedResponse` replaces
the value flowing through the chain for downstream guardrails.
## Registry (`registry.ts`)
The singleton `guardrailRegistry` exposes:
- `register(guardrail)` — adds (or replaces by normalized name) a guardrail and
re-sorts by ascending `priority`.
- `clear()` / `list()` — administrative helpers.
- `runPreCallHooks(payload, context)` — iterates active guardrails, threads the
payload through `modifiedPayload`, and stops on the first `block: true`.
- `runPostCallHooks(response, context)` — same flow on the response side.
- `resetGuardrailsForTests({ registerDefaults })` — clears state and optionally
re-registers the defaults for clean test isolation.
Both runners return `{ blocked, payload|response, results, guardrail?, message? }`
where `results` is an array of `GuardrailExecutionResult` records that include
per-guardrail `blocked`, `skipped`, `modified`, `error`, and `meta` fields,
useful for tracing.
### Disabling Guardrails Per-Request
`resolveDisabledGuardrails({ apiKeyInfo, body, headers })` aggregates a
de-duplicated list of guardrail names that should be skipped for the current
request. Sources (all optional, all merged):
- `apiKeyInfo.disabledGuardrails`
- Request body `disabledGuardrails` (top-level)
- Request body `metadata.disabledGuardrails`
- Header `x-omniroute-disabled-guardrails` (or legacy
`x-disabled-guardrails`)
Values may be arrays of strings or a comma-separated string; names are
normalized to lowercase kebab-case (`pii_masker``pii-masker`). The result
is passed through `context.disabledGuardrails` to the registry, which skips
matching guardrails (`skipped: true` in `results`).
## Execution Order
For each request flowing through `src/sse/handlers/chat.ts` and
`open-sse/handlers/chatCore.ts`:
1. `resolveDisabledGuardrails(...)` builds the skip list from API key, body,
and headers.
2. `guardrailRegistry.runPreCallHooks(body, ctx)` runs guardrails in ascending
priority order:
- Disabled guardrails are recorded as `skipped`.
- Each guardrail's `preCall` may rewrite the payload via `modifiedPayload`.
- The first `block: true` short-circuits the chain and the handler returns
a guardrail rejection response.
3. The (potentially rewritten) payload flows into combo routing and upstream
dispatch.
4. After the response is assembled, `guardrailRegistry.runPostCallHooks(...)`
runs the same chain on the response. `block: true` here drops the upstream
response.
Guardrails that throw are recorded with `error: <message>` and logged via
`logger.warn`, but the chain continues — fail-open by design.
## Configuration
Environment variables read by the built-in guardrails:
| Variable | Used by | Effect |
| ------------------------------------- | -------------------------------- | ------------------------------------------------------------------------------------------------ |
| `INPUT_SANITIZER_ENABLED` | `prompt-injection` | Set `false` to disable detection entirely. |
| `INPUT_SANITIZER_MODE` | `prompt-injection`, `pii-masker` | Shared mode: `warn`, `block`, `log`, or `redact`. |
| `INJECTION_GUARD_MODE` | `prompt-injection` | Mode for the injection guard; also a DB feature flag that **overrides** the env vars (DB > ENV). |
| `PII_REDACTION_ENABLED` | `pii-masker` | When `true` + mode `redact`, request PII is stripped. |
| `PII_RESPONSE_SANITIZATION` / `_MODE` | `pii-masker` (downstream) | Controls response-side masker behavior. |
The Vision Bridge reads runtime config from the DB-backed settings store
(`getSettings()`), not env vars: `visionBridgeEnabled`, `visionBridgeModel`,
`visionBridgePrompt`, `visionBridgeTimeout`, `visionBridgeMaxImages`. Defaults
live in `src/shared/constants/visionBridgeDefaults.ts`.
## Custom Guardrails
```typescript
import { BaseGuardrail, guardrailRegistry } from "@/lib/guardrails";
class BudgetGuardrail extends BaseGuardrail {
constructor() {
super("budget", { priority: 50 });
}
async preCall(payload, ctx) {
if (ctx.apiKeyInfo?.budgetExceeded) {
return { block: true, message: "Daily budget exceeded" };
}
return { block: false };
}
}
guardrailRegistry.register(new BudgetGuardrail());
```
Steps:
1. Create `src/lib/guardrails/myGuardrail.ts` extending `BaseGuardrail`.
2. Implement `preCall` and/or `postCall`.
3. Either register at import time (push from `registerDefaultGuardrails`) or
call `guardrailRegistry.register(...)` at runtime — the registry replaces
any prior guardrail with the same normalized name.
4. Add tests under `tests/unit/` (existing examples:
`tests/unit/guardrails-registry.test.ts`,
`tests/unit/prompt-injection-guard.test.ts`,
`tests/unit/guardrails/visionBridge.test.ts`).
## Testing
Use `resetGuardrailsForTests()` between tests to start from a known state.
Pass `{ registerDefaults: false }` to start with an empty registry and
register only the guardrails under test. The Vision Bridge guardrail accepts
dependency injection (`deps.getSettings`, `deps.callVisionModel`) so tests can
exercise the full flow without DB or network access.
## See Also
- `src/lib/guardrails/` — implementation
- `src/shared/utils/inputSanitizer.ts` — shared detector that powers
prompt-injection and PII masking
- `src/shared/constants/visionBridgeDefaults.ts` — Vision Bridge defaults and
forced-bridge model list
- `docs/architecture/RESILIENCE_GUIDE.md` — orthogonal layer (circuit breaker, cooldowns)
- `docs/reference/ENVIRONMENT.md` — full env var reference
## Injection-guard route coverage & red-team (Phase 8 · Block D)
The injection-guard (`createInjectionGuard` / `withInjectionGuard`) covers all routes
that accept user prompts. It respects `INJECTION_GUARD_MODE` (default `warn` = log only;
`block` = returns HTTP 400 `SECURITY_001`).
| Type | Routes | Default mode |
| --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | ------------ |
| Text (existing) | `/v1/chat/completions`, `/v1/completions`, `/v1/relay/chat/completions` | warn |
| Generative | `/v1/messages`, `/v1/responses`, `/v1/images/generations`, `/v1/images/edits`, `/v1/videos/generations`, `/v1/music/generations`, `/v1/audio/speech` | warn |
| Data | `/v1/embeddings`, `/v1/rerank`, `/v1/search`, `/v1/moderations` | warn |
Text extraction (`extractMessageContents`) covers `messages`/`input`/`prompt`/`query`+`documents`/`instructions`/`system`.
**Red-team (nightly, `nightly-llm-security.yml`):** promptfoo validates that each route blocks
the OWASP-LLM corpus in `INJECTION_GUARD_MODE=block`; garak runs probes (skips without secret).
`moderations` is included for consistency — operators in block-mode can exempt it via
`resolveDisabledGuardrails`.
The nightly workflow (`.github/workflows/nightly-llm-security.yml`, cron + manual
dispatch) has two jobs:
- **`promptfoo-guard` (blocking)** — runs `promptfoo eval -c promptfooconfig.yaml`
with `INJECTION_GUARD_MODE=block`. Each adversarial case (e.g. "ignore all
previous instructions…", DAN-style jailbreaks) asserts the response carries
`error.code === "SECURITY_001"`, i.e. the guard actually rejected the request.
- **`garak` (advisory)** — runs garak `--probes promptinject,dan,leakreplay`
against a local OmniRoute instance (`http://localhost:20128/v1`). Gated on a
provider secret (`PROMPTFOO_PROVIDER_KEY`); skips gracefully and is suffixed
`|| true`, so it reports without failing CI.
Coverage of the guard helper (`createInjectionGuard` / `withInjectionGuard`)
spans every prompt-bearing `/v1` route; prompt text is pulled from
`messages`/`input`/`prompt`/`query`+`documents`/`instructions`/`system` by
`extractMessageContents()` in `src/shared/utils/inputSanitizer.ts`.
+381
View File
@@ -0,0 +1,381 @@
---
title: "MITM TPROXY Transparent Decrypt"
version: 3.8.40
lastUpdated: 2026-06-28
---
# MITM TPROXY Transparent Decrypt
TPROXY transparent decrypt is OmniRoute's **5th capture mode** for the
[Traffic Inspector](../frameworks/TRAFFIC_INSPECTOR.md) / [AgentBridge](../frameworks/AGENTBRIDGE.md)
MITM stack. It intercepts and **decrypts** local outbound HTTPS traffic on Linux
using kernel TPROXY + policy routing — **without** spoofing `/etc/hosts` and
**without** mutating OS-wide system-proxy settings. It is headless-friendly
(no DNS edits to clean up) and the firewall rules auto-flush on reboot.
Unlike the other capture modes, TPROXY needs no per-host setup: it transparently
intercepts **arbitrary** destination hosts on a target port, terminates TLS with
a leaf certificate it issues on the fly per SNI hostname, captures the decrypted
exchange, and re-encrypts the request to the original destination.
> **Linux-only, root-only, opt-in.** This mode requires Linux, a native addon
> built with a C toolchain, and the **CAP_NET_ADMIN** capability (typically root). It is gated
> behind the loopback-only AgentBridge API and disabled by default. A trusted
> MITM CA that can sign any host is a powerful capability — see [§6 Security](#6-security).
**Source:** `src/mitm/tproxy/`
**API route:** `GET / POST / DELETE /api/tools/agent-bridge/tproxy`
**Dashboard toggle:** Traffic Inspector → capture-modes toolbar → **"TPROXY Decrypt"** ⚠
**See also:** [`docs/frameworks/TRAFFIC_INSPECTOR.md`](../frameworks/TRAFFIC_INSPECTOR.md),
[`docs/frameworks/AGENTBRIDGE.md`](../frameworks/AGENTBRIDGE.md)
---
## §1 What it is and when to use it
The other four capture modes each have a limitation:
| Mode | How traffic is steered | Limitation |
| ----------------- | ------------------------------------------ | -------------------------------------- |
| AgentBridge | `/etc/hosts` DNS spoof of a fixed host set | only the registered IDE-agent hosts |
| Custom Hosts | `/etc/hosts` DNS spoof per host | one entry per host; sudo to edit hosts |
| HTTP_PROXY | `HTTP_PROXY`/`HTTPS_PROXY` env | only apps that honor the env var |
| System-wide proxy | OS proxy settings | mutates global state; needs revert |
TPROXY transparent decrypt steers traffic at the **kernel** layer instead. It
marks new local outbound TCP connections to a target port (default `443`) in the
`mangle OUTPUT` chain, an `ip rule` reroutes the marked packets to local delivery,
and on re-entry the `mangle PREROUTING` `TPROXY` target hands them to an
**IP_TRANSPARENT** listener — which then terminates TLS and captures the plaintext.
Use it when you want to capture and decrypt traffic from a process that:
- talks to a host AgentBridge does not register, and
- does not honor `HTTP_PROXY`, and
- you do not want to disturb with a system-wide proxy change.
Because interception happens in the kernel, the originating process needs **no
configuration change** — but the process must trust the dynamic CA OmniRoute
installs (see [§4](#4-the-per-sni-dynamic-ca-and-trust-store-installer)).
---
## §2 Requirements
| Requirement | Detail |
| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| **OS** | Linux only — **IP_TRANSPARENT** is a Linux-only socket option. The loader returns "unavailable" on every other platform. |
| **Privilege** | The **CAP_NET_ADMIN** capability to create the transparent socket and apply `iptables`/`ip` rules — in practice, run as root. |
| **Native addon** | A tiny N-API addon (`src/mitm/tproxy/native/transparent.c`) must be built or shipped as a prebuild. See [§3](#3-the-native-ip_transparent-addon). |
| **Kernel modules** | `iptables` with the `TPROXY`, `mangle`, and `mark` match support (validated against kernel 6.8.0). |
**Graceful degradation:** if any requirement is missing (non-Linux, no toolchain,
addon not built), the addon loader (`src/mitm/tproxy/transparentSocket.ts::loadTransparentAddon`)
returns `null` rather than throwing. The capture-mode status then reports
`available: false`, the dashboard toggle is **disabled** with the tooltip
"TPROXY decrypt requires Linux + root + the native addon", and the rest of
OmniRoute keeps working.
---
## §3 The native IP_TRANSPARENT addon
Node's `net` module cannot `setsockopt(IP_TRANSPARENT)` _before_ `bind()`, which
TPROXY requires (otherwise the kernel drops the redirected packets). The addon
(`src/mitm/tproxy/native/transparent.c`, built via `binding.gyp`) is a small N-API
module exposing three functions, consumed through `transparentSocket.ts`:
| Addon function | Socket work | Used for |
| ------------------------------------- | ---------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- |
| `createTransparentListener(ip, port)` | `socket()` + **SO_REUSEADDR** + **IP_TRANSPARENT** + `bind()` + `listen()`, returns the raw fd | the transparent capture listener (Node adopts the fd via `server.listen({ fd })`) |
| `setSocketMark(fd, mark)` | `setsockopt` **SO_MARK** on an existing fd | anti-loop (mark the proxy's own sockets) |
| `connectMarked(ip, port, mark)` | `socket()` + **SO_MARK** **before** a non-blocking `connect()`, returns fd | the re-encrypted upstream forward (the SYN carries the mark) |
The original destination is read from `socket.localAddress`/`localPort` — TPROXY
preserves it, so there is no **SO_ORIGINAL_DST**/NAT lookup.
### Building the addon
```bash
npm run build:native:tproxy # cd src/mitm/tproxy/native && node-gyp rebuild
# -> native/build/Release/transparent.node
```
- During `npm run build`, `scripts/build/build-tproxy-native.mjs` runs `node-gyp
rebuild`. It is **Linux-only and non-fatal** — a missing toolchain just leaves
the capture mode unavailable.
- `assembleStandalone.mjs` copies `build/Release/transparent.node` into the
standalone bundle; `transparentSocket.ts` resolves it both module-relative and
cwd-relative (`<cwd>/src/mitm/tproxy/native/...`).
- `build/` and `prebuilds/` are git-ignored — the binary is **built, never
committed**.
The loader probes, in priority order:
`native/build/Release/transparent.node`, then `native/prebuilds/transparent.node`
(both module-relative and under `<cwd>/src/mitm/tproxy/`).
---
## §4 The per-SNI dynamic CA and trust-store installer
The static AgentBridge MITM cert works only because AgentBridge DNS-spoofs a
**fixed** host set. TPROXY intercepts **arbitrary** hosts, so the listener must
present a valid leaf for whatever SNI the client requests.
### Dynamic CA (`src/mitm/tproxy/dynamicCert.ts`)
`DynamicCertStore` runs a local CA (built on the `selfsigned` dependency) that:
- Generates a long-lived CA via `generateMitmCa()` (CN `"OmniRoute MITM CA"`,
10-year validity, `basicConstraints CA=true` + `keyUsage keyCertSign,cRLSign`,
2048-bit RSA / SHA-256).
- Issues a **leaf per SNI hostname on demand** via `issueLeafCert()` (1-year
validity, `subjectAltName` = the SNI host) and caches one `tls.SecureContext`
per hostname.
- Exposes `createSNICallback()` for the TLS-terminating server (see [§5](#5-how-decrypt-and-capture-work)).
- Can be constructed with an `existingCa` to keep the CA stable across restarts
(so the trust store does not need re-installing).
The CA private key **never leaves the machine**.
### Trust-store installer (`src/mitm/tproxy/caTrust.ts`)
The intercepted client must trust the dynamic CA, so starting the capture mode
installs the CA cert into the OS trust store under a **dedicated slot** —
`omniroute-tproxy-ca.crt` (constant `TPROXY_CA_CERT_NAME`) — kept separate from
the static MITM cert's slot (`omniroute-mitm.crt`) so the two never clobber each
other.
`installTproxyCa(caPem, sudoPassword?)` detects the distro's anchor directory
(in order: Debian-style first) and runs the matching refresh command:
| Anchor directory | Refresh command |
| ------------------------------------------- | ------------------------ |
| `/usr/local/share/ca-certificates` | `update-ca-certificates` |
| `/etc/ca-certificates/trust-source/anchors` | `update-ca-trust` |
| `/etc/pki/ca-trust/source/anchors` | `update-ca-trust` |
| `/etc/pki/trust/anchors` | `update-ca-certificates` |
Install stages the PEM to a temp file, then (privileged) `mkdir -p` the anchor
dir, `cp` the staged file into it, and runs the refresh command. `uninstallTproxyCa()`
removes the dedicated slot only (leaving the static MITM cert untouched) and
refreshes — a no-op on non-Linux.
All privileged commands run via `execFileWithPassword` (`src/mitm/systemCommands.ts`)
— `spawn` with **arg arrays, no shell, no string interpolation** (Hard Rule #13).
When the process is root (e.g. the VPS) the target runs directly and no password
is needed; on a non-root desktop the `sudoPassword` is passed via `sudo -S` on stdin.
> The desktop's `sudoPassword` is supplied in the POST body to authorize the
> trust-store install; it is ignored entirely when the process is root.
---
## §5 How decrypt and capture work
The pipeline (all under `src/mitm/tproxy/`):
```
local app ──TCP/443──▶ mangle OUTPUT marks the conn (fwmark)
ip rule → local route table → lo
mangle PREROUTING TPROXY → IP_TRANSPARENT listener (port 8443)
│ captureMode.ts: reads orig dest from socket.localAddress
tlsCapture.ts:
1. TLS-terminate the CLIENT with a per-SNI leaf (dynamicCert)
2. internal http.Server parses the decrypted plaintext
3. capture → globalTrafficBuffer.push() with source: "tproxy"
(sanitizeHeaders + maskSecret applied)
4. forward RE-encrypted to the original destination
over a bypass-marked socket (connectMarked, anti-loop)
original upstream (api.example.com)
```
- **TLS termination** (`createTlsCaptureServer`): wraps the raw intercepted
socket in a server-side `tls.TLSSocket` using the dynamic CA's SNI callback,
then hands the decrypted stream to an internal `http.Server` (the standard MITM
termination trick). Socket lifetimes are bounded by `MITM_IDLE_TIMEOUT_MS` so a
hung tunnel cannot exhaust file descriptors.
- **Capture** (`handleDecryptedRequest`): pushes an `InterceptedRequest` with
`source: "tproxy"`, status starting `"in-flight"`, headers run through
`sanitizeHeaders()` and bodies through `maskSecret()` before they enter the
buffer. The entry is then updated with the response, sizes, and latency.
- **Re-encrypted forward** (`createForward` / `realForward`): re-encrypts to the
original destination. `rejectUnauthorized` defaults to **`true`** (secure by
default) — the upstream cert is verified against the SNI/Host the client
requested, so the proxy rejects exactly what the original client would.
### Anti-loop (SO_MARK)
Because the rules mark new local outbound connections, the proxy's **own**
re-encrypted forward would normally be re-intercepted — an infinite loop. The
forward path defends against this with a bypass socket mark (**SO_MARK**):
- `realForward` opens its upstream socket via `connectMarked(ip, port, DEFAULT_BYPASS_MARK)`
— `DEFAULT_BYPASS_MARK = 0x539` — which sets the **SO_MARK** **before** `connect()`,
so the forward's SYN carries the bypass mark.
- The `mangle OUTPUT` rule excludes connections already carrying the bypass mark
(`-m mark ! --mark <bypassMark>`), so the proxy's forward is **not** re-marked
and does not re-enter TPROXY.
> Implementation note: the bypass-marked socket must be installed on the agent's
> `createConnection` (`https.request({ createConnection })` is silently ignored
> when an agent is present), or the forward would open an unmarked socket and the
> loop would return. This was the e2e-validated anti-loop fix.
---
## §6 Security
| Control | Detail |
| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **Loopback-only API** | `/api/tools/agent-bridge/tproxy` is covered by the `/api/tools/agent-bridge/` prefix in `LOCAL_ONLY_API_PREFIXES` (`src/server/authz/routeGuard.ts`). Loopback enforcement runs **before** auth (Hard Rules #15 + #17) — a leaked JWT over a tunnel cannot start TPROXY capture, which applies `iptables` rules and installs a trust-store CA via child processes. |
| **Dedicated CA slot** | The dynamic CA installs to `omniroute-tproxy-ca.crt`, never clobbering the static MITM cert. |
| **CA key never leaves the host** | `DynamicCertStore` holds the CA key in memory; it is not exported. |
| **Secret masking** | `maskSecret()` on request/response bodies and `sanitizeHeaders()` on headers run **before** `globalTrafficBuffer.push()`. |
| **No shell interpolation** | All `iptables`/`ip`/trust-store commands run via `execFile`/`execFileWithPassword` with arg arrays (Hard Rule #13). |
| **Upstream cert verification** | The re-encrypted forward verifies the upstream cert by default (`rejectUnauthorized: true`). |
| **Error sanitization** | The route's error responses go through `sanitizeErrorMessage()` (Hard Rule #12). |
**The MITM CA is a powerful capability.** A CA trusted by the OS that can sign any
host means anything OmniRoute intercepts can be decrypted. It is gated behind the
explicit, local-only TPROXY capture mode, off by default, and the trust-store
entry is removed when you stop the mode.
---
## §7 Transactional firewall apply / revert
A crash must never leave a `mangle` rule or stale route behind. The command builder
(`src/mitm/tproxy/commands.ts`) and runner (`src/mitm/tproxy/setup.ts`) guarantee
**revert is the exact inverse of apply, in reverse order**.
`applyTproxy(cfg)` runs the apply commands in order; on **any** failure it runs a
best-effort full `revertTproxy(cfg)` and rethrows — so the firewall is either
fully applied or fully reverted, never half-applied. `revertTproxy(cfg)` runs the
inverse commands in reverse order and swallows failures (idempotent — safe to call
unconditionally, e.g. from the AgentBridge `repairMitm()` cleanup).
`validateTproxyConfig(cfg)` runs before any command: ports must be `165535`,
`mark`/`routeTable`/`bypassMark` must be positive integers, and `bypassMark` must
differ from `mark` (anti-loop).
### Apply commands (in order)
```bash
ip rule add fwmark <mark> lookup <routeTable>
ip route add local 0.0.0.0/0 dev lo table <routeTable>
iptables -t mangle -A OUTPUT -p tcp --dport <dport> -m mark ! --mark <bypassMark> -j MARK --set-mark <mark>
iptables -t mangle -A PREROUTING -p tcp --dport <dport> -m mark --mark <mark> -j TPROXY --on-port <onPort> --tproxy-mark <mark>
```
Revert deletes them in reverse: `PREROUTING -D`, `OUTPUT -D`, `ip route del`, `ip rule del`.
> The recipe is **OUTPUT-based** because the MITM use case is _local_ outbound
> traffic (apps on the same host), which TPROXY in `PREROUTING` alone does not
> see — `PREROUTING` only sees forwarded traffic. The `OUTPUT` chain marks new
> local connections, the `ip rule` reroutes them to local delivery (`lo`), and
> `PREROUTING` then assigns them to the transparent listener.
---
## §8 Configuration
The start request (`POST /api/tools/agent-bridge/tproxy`) accepts the following
fields, validated by `StartTproxyBodySchema` (`tproxy/route.ts`). All are optional
and fall back to their defaults:
| Field | Type | Default | Notes |
| ---------------- | ------------------ | -------- | --------------------------------------------------------------------------------------------------------------- |
| **dport** | int (165535) | `443` | Destination TCP port to transparently intercept |
| **mark** | int (≥1) | `0x2333` | Firewall mark set on `OUTPUT`, matched by the `ip rule` + `PREROUTING` |
| **onPort** | int (165535) | `8443` | Port the transparent (**IP_TRANSPARENT**) listener binds |
| **routeTable** | int (≥1) | `233` | Policy-routing table id holding the `local 0.0.0.0/0` route |
| **bypassMark** | int (≥1, ≠ `mark`) | `0x539` | The bypass socket mark (**SO_MARK**) the proxy sets on its own upstream conns; excluded in `OUTPUT` (anti-loop) |
| **sudoPassword** | string | — | Non-root desktops only: authorizes the trust-store install; ignored when root |
There are **no environment variables** for TPROXY — all configuration is via the
POST body or the defaults above.
---
## §9 Enabling from the Traffic Inspector
1. Open the **Traffic Inspector** (`/dashboard/tools/traffic-inspector`).
2. In the capture-modes toolbar, find the **"TPROXY Decrypt"** ⚠ button
(`src/app/(dashboard)/dashboard/tools/traffic-inspector/components/CaptureModesToolbar.tsx`).
- If it is **disabled** with the tooltip "TPROXY decrypt requires Linux + root +
the native addon", the native addon is unavailable on this host (non-Linux,
no toolchain, or addon not built). See [§2](#2-requirements) and [§3](#3-the-native-ip_transparent-addon).
3. Click the button. It calls `POST /api/tools/agent-bridge/tproxy` via
`startTproxyCaptureMode()` (`src/lib/inspector/tproxyCaptureApi.ts`), which:
builds the dynamic CA, opens the transparent listener, applies the firewall
rules, and installs the CA in the OS trust store.
4. When running, the toggle turns amber and shows the live intercept count
(`· <interceptCount>`). Intercepted requests appear in the request list with
`source: "tproxy"`.
5. Click again to stop — `DELETE /api/tools/agent-bridge/tproxy` via
`stopTproxyCaptureMode()` closes the listener, uninstalls the CA, and reverts
the firewall rules.
The capture-mode status (running / available / intercept count / listener port) comes
from `GET /api/tools/agent-bridge/tproxy` (`getCaptureStatus()` in
`src/mitm/tproxy/captureManager.ts`). Only **one** TPROXY session runs at a time —
starting a second rejects with "TPROXY capture mode is already running".
---
## §10 Troubleshooting
### Toggle is disabled
The native addon is not loadable. Confirm: you are on Linux, you built the addon
(`npm run build:native:tproxy`), and the process can load `transparent.node`.
`isTransparentSocketAvailable()` gates the toggle; `GET /api/tools/agent-bridge/tproxy`
returns `available: false` when the addon is missing.
### Nothing is captured
- Confirm the intercepted process actually connects to the configured `dport`
(default `443`).
- Confirm the process trusts the dynamic CA. The CA is installed under
`omniroute-tproxy-ca.crt`; apps with their own trust store (Firefox/Chrome NSS)
may need the cert added there too.
- Run the AgentBridge **Diagnose** self-test (see
[`AGENTBRIDGE.md`](../frameworks/AGENTBRIDGE.md)) for cert-trusted / server
health checks.
### Stale firewall rules after a crash
`revertTproxy()` is the exact inverse of apply and is idempotent. Stopping the
mode reverts the rules; if OmniRoute was killed mid-session, use the AgentBridge
**Repair** action (`POST /api/tools/agent-bridge/repair`) to undo orphaned system
state (DNS spoof, root CA, system proxy). The TPROXY `mangle` rules and route also
flush automatically on reboot.
### Infinite loop / the proxy intercepts its own forward
This is the anti-loop case. Confirm `bypassMark` differs from `mark` (validation
enforces this) and that the forward uses `connectMarked` (it does in `realForward`).
See [§5 Anti-loop](#anti-loop-so_mark).
---
## §11 Source map
| File | Responsibility |
| ------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `src/mitm/tproxy/commands.ts` | Pure `iptables`/`ip` apply + revert command builder; `validateTproxyConfig` |
| `src/mitm/tproxy/setup.ts` | Transactional `applyTproxy` / `revertTproxy` runner (rollback on failure) |
| `src/mitm/tproxy/transparentSocket.ts` | Native-addon loader (`loadTransparentAddon`), `createTransparentListenerFd`, `connectMarked`, `setSocketMark`, `isTransparentSocketAvailable` |
| `src/mitm/tproxy/native/transparent.c` | N-API addon: `createTransparentListener` (IP_TRANSPARENT), `setSocketMark`, `connectMarked` |
| `src/mitm/tproxy/native/binding.gyp` | node-gyp build manifest |
| `src/mitm/tproxy/dynamicCert.ts` | `DynamicCertStore` — per-SNI dynamic CA + leaf cache |
| `src/mitm/tproxy/caTrust.ts` | OS trust-store install/uninstall (`installTproxyCa` / `uninstallTproxyCa`, dedicated slot) |
| `src/mitm/tproxy/tlsCapture.ts` | TLS-terminating decrypt engine + re-encrypted anti-loop forward |
| `src/mitm/tproxy/captureMode.ts` | Transparent-listener orchestration; reads orig dest from `socket.localAddress` |
| `src/mitm/tproxy/captureManager.ts` | Singleton lifecycle: `startCaptureMode` / `stopCaptureMode` / `getCaptureStatus` |
| `src/app/api/tools/agent-bridge/tproxy/route.ts` | `GET` / `POST` / `DELETE` route (LOCAL_ONLY) |
| `src/lib/inspector/tproxyCaptureApi.ts` | Client fetch helpers (`fetchTproxyStatus` / `startTproxyCaptureMode` / `stopTproxyCaptureMode`) |
+141
View File
@@ -0,0 +1,141 @@
---
title: "Public Credentials Handling"
version: 3.8.40
lastUpdated: 2026-06-28
---
# Public Credentials Handling
> **Source of truth:** `open-sse/utils/publicCreds.ts`
> **Tests:** `tests/unit/publicCreds.test.ts`
> **Last updated:** 2026-06-28 — v3.8.40
> **Audience:** Engineers integrating providers that ship public OAuth client_id / client_secret / Firebase Web API keys in their public CLIs.
> **Status:** **MANDATORY** for all new code that embeds upstream identifiers.
## Why this exists
- [OAuth 2.0 for native apps (PKCE)](https://developers.google.com/identity/protocols/oauth2/native-app) — OAuth client_id / client_secret for installed apps are public; PKCE provides the actual security.
- [Firebase API keys](https://firebase.google.com/docs/projects/api-keys) — Web client identifiers are public by design.
OmniRoute must embed these values so users who do not configure `.env` still get a working OAuth flow out of the box. Without an embedded fallback, the Gemini / Antigravity / Windsurf providers stop working for any user who follows the "just clone and run" path.
However, literal values like `AIzaSy…`, `GOCSPX-…`, `…apps.googleusercontent.com` are matched by **GitHub Secret Scanning**, **Semgrep**, and similar pattern scanners. Every release becomes a noisy stream of false positives, push protection blocks legitimate commits, and operators stop trusting the alert feed.
The `open-sse/utils/publicCreds.ts` helper solves both constraints at once:
- Embeds the public identifier as a **XOR-masked byte sequence** (no scanner pattern in source).
- Decodes at runtime via `decodePublicCred` / `resolvePublicCred`.
- Detects raw values that already follow well-known prefixes (`AIza`, `GOCSPX-`, `<digits>-<32hex>.apps.googleusercontent.com`, `Iv1.<hex>`) and passes them through unchanged, so users with raw values in their existing `.env` keep working with **zero migration**.
This is **obfuscation, not encryption.** Anyone reading the source can recover the value — which is fine because the value is public by design. The only goal is to avoid scanner regex matches.
## The mandatory pattern
### 1. Adding a new public credential
When you need to embed a new upstream-provided value that:
- comes from a public CLI / desktop app / browser bundle, **and**
- the upstream provider documents (or treats) it as a public client identifier, **and**
- a pattern scanner would otherwise match it (`AIza…`, `GOCSPX-…`, `<digits>-…apps.googleusercontent.com`, etc.),
…follow this checklist:
1. Generate the masked byte sequence:
```bash
node --import tsx/esm -e \
'import("./open-sse/utils/publicCreds.ts").then(m =>
console.log(JSON.stringify(Array.from(
Buffer.from(m.encodePublicCred("THE_PUBLIC_VALUE"), "base64")
))))'
```
2. Add a new entry to `EMBEDDED_DEFAULTS` in `open-sse/utils/publicCreds.ts` with a **neutral key name** (`<provider>_id`, `<provider>_alt`, `<provider>_fb`, etc.). Do **not** use names like `client_secret` or `api_key` in the helper — those words trigger Semgrep generic-secret rules.
3. Add a `keyof typeof EMBEDDED_DEFAULTS` to the public type union (it is inferred automatically).
4. In the consumer code, replace the hardcoded literal with:
```ts
// single env override
clientSecret: resolvePublicCred("provider_alt", "PROVIDER_OAUTH_CLIENT_SECRET"),
// multiple env aliases (first non-empty wins)
clientId: resolvePublicCredMulti("provider_id", [
"PROVIDER_CLI_OAUTH_CLIENT_ID",
"PROVIDER_OAUTH_CLIENT_ID",
]),
// no env override (always embedded default)
firebaseApiKey: resolvePublicCred("provider_fb"),
```
5. Remove the literal from `.env.example` (replace with comment-only documentation pointing readers here):
```dotenv
# ── Provider (Google / Firebase / etc.) ──
# Public OAuth credentials are baked into the code via
# open-sse/utils/publicCreds.ts. Set these vars only to use your own.
# PROVIDER_OAUTH_CLIENT_ID=
# PROVIDER_OAUTH_CLIENT_SECRET=
```
6. Update `tests/unit/publicCreds.test.ts` to add a shape assertion for the new key (verify format, not literal value — see existing tests for the pattern).
7. **Never** add `AIza…` / `GOCSPX-…` / `…apps.googleusercontent.com` literals to test files. Use the `FAKE_*` constants built from `.join("")` fragments (see existing tests).
### 2. Consumers
- **Read from `resolvePublicCred()` / `resolvePublicCredMulti()` only** — never call `decodePublicCredBytes()` directly outside the helper.
- The helper is intentionally cheap (linear byte XOR) and safe to call at module-load time; defaults are computed once.
- The env override always wins. If a user sets `PROVIDER_OAUTH_CLIENT_SECRET=GOCSPX-myown`, the helper passes that raw value straight through.
### 3. Forbidden patterns
❌ **Never** do any of the following in production code (`src/`, `open-sse/`, `electron/`, `bin/`):
```ts
// BAD: literal value triggers Secret Scanning + Semgrep
clientSecret: process.env.PROVIDER_OAUTH_CLIENT_SECRET || "GOCSPX-realvalue",
// BAD: base64 of the literal — GitHub still detects since Feb/2025
clientSecret: process.env.PROVIDER_OAUTH_CLIENT_SECRET ||
Buffer.from("R09DU1BYLXJlYWx2YWx1ZQ==", "base64").toString(),
// BAD: string concatenation that re-assembles the pattern at runtime
clientSecret: "GO" + "CS" + "PX-" + "realvalue",
// BAD: hex/ROT13 encoding — different obfuscation, same risk of detection
clientSecret: hexDecode("474f4353..."),
```
These all eventually trip a scanner. Use `resolvePublicCred()`.
❌ **Never** add literal credentials to `.env.example`. Users who need real upstream values can extract them from the public CLI themselves, or use their own OAuth registration.
❌ **Never** dismiss a new secret-scanning alert without first checking whether the credential should be moved to this helper.
## Related controls
- `RAW_VALUE_PATTERN` in `publicCreds.ts` enumerates the prefixes that trigger passthrough (retrocompat). Extend it only for documented public credential formats, never for proprietary secrets.
- `.env.example` lives in CI's `check-env-doc-sync` script — when you remove a var here, make sure the docs match.
- The `npm run test:vitest` and `node --import tsx/esm --test tests/unit/publicCreds.test.ts` suites must both stay green.
## When NOT to use this helper
This helper is **only** for credentials that are:
1. Distributed publicly by the upstream provider (CLI binary, browser bundle, official docs).
2. Documented or strongly implied to be non-confidential (PKCE-protected, Firebase Web key, similar).
For everything else — operator-issued tokens, per-tenant secrets, your own OAuth app's client_secret, encryption keys, JWT secrets, database passwords — use **env vars only** (`process.env.FOO`, `||` fallback to empty / explicit error). These belong in `.env` and the [encrypted credentials store](./COMPLIANCE.md), not in source.
## References
- [Google: OAuth 2.0 for native apps](https://developers.google.com/identity/protocols/oauth2/native-app)
- [Firebase: API keys for client identification](https://firebase.google.com/docs/projects/api-keys)
- [GitHub Secret Scanning supported secrets](https://docs.github.com/en/code-security/secret-scanning/introduction/supported-secret-scanning-patterns)
- [GitHub: base64 detection for tokens (Feb 2025)](https://github.blog/changelog/2025-02-14-secret-scanning-detects-base64-encoded-github-tokens/)
- Commit introducing this helper: `1a39c31f` — _fix(security): mask public upstream creds + centralize error sanitization_
+227
View File
@@ -0,0 +1,227 @@
---
title: "Route Guard Tiers"
---
# Route Guard Tiers
## Overview
All OmniRoute management API routes are classified into one of three protection
tiers. Classification is static, defined in `src/server/authz/routeGuard.ts`,
and evaluated before any other auth branch runs.
## Tiers
### Tier 1 — LOCAL_ONLY
**Enforced by:** `isLocalOnlyPath(path)` → loopback host check
**Bypass:** None by default. Narrow carve-out for paths in
`LOCAL_ONLY_MANAGE_SCOPE_BYPASS_PREFIXES` when the request carries a valid
API key with the `manage` scope (see [Manage-scope carve-out](#manage-scope-carve-out)).
These routes spawn child processes or execute runtime code. Exposing them to
non-loopback traffic would allow an attacker who obtained a valid JWT (e.g.,
via a Cloudflared/Ngrok tunnel) to trigger process spawning — a known CVE
class ([GHSA-fhh6-4qxv-rpqj](https://github.com/advisories/GHSA-fhh6-4qxv-rpqj)).
**What GHSA-fhh6-4qxv-rpqj is (the attack class):** a management/agent server
exposes an endpoint that launches a subprocess (`npm install`, `node`, a browser,
a proxy, `git`, `tar`, …). If that endpoint is reachable from off-host — because
the operator put OmniRoute behind an nginx/Cloudflare/Tailscale tunnel and a JWT
leaked, or auth was misconfigured — the attacker turns "call an API" into "run a
command on the host" (remote code execution). OmniRoute closes this by enforcing a
**loopback host check unconditionally, before any auth check**, on every
spawn-capable route: a leaked token over a tunnel still can't reach the spawn.
**The full LOCAL_ONLY set.** The authoritative source is
`LOCAL_ONLY_API_PREFIXES` / `LOCAL_ONLY_API_PATTERNS` in
`src/server/authz/routeGuard.ts`; the table below mirrors the current state. The
`check-route-guard-membership` gate enumerates every `route.ts` under the
spawn-capable prefixes and fails CI if any is not classified local-only.
| Prefix / pattern | Why it's local-only | Manage-scope bypassable? |
| ----------------------------------- | ---------------------------------------------------------------------------------------- | ----------------------------- |
| `/api/mcp/` | MCP server — spawns stdio bridges + SSE handlers | **Yes** (only one) |
| `/api/cli-tools/runtime/` | CLI tool runtime — executes arbitrary plugin code | No — spawn-capable |
| `/api/services/` | Embedded services (9router/CLIProxy) — `npm install` + spawn | No — spawn-capable |
| `/dashboard/providers/services/` | Reverse proxy to embedded-service UIs | No |
| `/api/copilot/` | Unauthenticated LLM driver — CLI-only by default | Operator opt-in: manage/admin |
| `/api/tools/agent-bridge/` | AgentBridge — spawns MITM server + DNS edits | No — spawn-capable |
| `/api/tools/traffic-inspector/` | Traffic Inspector — http-proxy listener + system proxy | No — spawn-capable |
| `/api/plugins/`, `/api/plugins` | Plugins — load/execute via `worker_threads` + `child_process` | No — spawn-capable |
| `/api/system/version` | Auto-update (POST only; GET/HEAD/OPTIONS exempt) — spawns `git checkout` + `npm install` | No |
| `/api/db-backups/exportAll` | Spawns `tar` for the export archive | No |
| `/api/local/` | 1-click local launchers (Redis today) — spawns podman/docker | No — spawn-capable |
| `/api/headroom/start`, `/stop` | Headroom proxy lifecycle — spawns python CLI / signals PID | No — spawn-capable |
| `/api/oauth/cursor/auto-import` | `execFile("which", ["cursor"])` before importing creds | No |
| `/api/providers/{id}/login` (regex) | Launches a headful Playwright Chromium for web-cookie login | No |
**Response on violation:** `403 LOCAL_ONLY`
#### Manage-scope carve-out
A subset of LOCAL_ONLY paths MAY also be accessed from non-loopback if and
only if the request carries an `Authorization: Bearer <api-key>` whose
metadata includes the `manage` scope (or `admin`). The carve-out is gated
explicitly per-path via `LOCAL_ONLY_MANAGE_SCOPE_BYPASS_PREFIXES` so the
default for any new LOCAL_ONLY path remains strict-loopback. Unauthenticated
requests and requests with non-manage keys are still rejected with
`403 LOCAL_ONLY`.
Today the only bypassable prefix is `/api/mcp/`. `/api/cli-tools/runtime/` and
`/api/services/` are intentionally excluded because they can spawn arbitrary
subprocesses (`npm install`, `node`), which is the exact CVE class the
LOCAL_ONLY tier exists to prevent.
| Request | Path | Result |
| ------------------------------------------- | -------------------------- | ------------------- |
| Non-loopback, no Bearer | `/api/mcp/*` | 403 LOCAL_ONLY |
| Non-loopback, Bearer with `manage` scope | `/api/mcp/*` | Allow |
| Non-loopback, Bearer without `manage` scope | `/api/mcp/*` | 403 LOCAL_ONLY |
| Non-loopback, Bearer with `manage` scope | `/api/cli-tools/runtime/*` | 403 LOCAL_ONLY |
| Loopback, any/no Bearer | any LOCAL_ONLY | Allow (gate passes) |
#### Operator guidance & auditing
If you run OmniRoute behind a reverse proxy or tunnel (nginx, Caddy, Cloudflare
Tunnel, Tailscale, Ngrok), the loopback check still protects the spawn-capable
routes above — a request whose client address is non-loopback is rejected with
`403 LOCAL_ONLY` **before auth runs**, so a leaked JWT can't reach a spawn. Two
operator responsibilities remain:
- **Do not "fix" a 403 by forging the client IP as loopback.** Setting
`X-Forwarded-For: 127.0.0.1`, or a proxy that rewrites the source address to
loopback, re-opens exactly the RCE class this tier closes. Expose the
dashboard/API through the proxy — never the spawn-capable routes.
- **Keep the manage-scope bypass minimal.** Only `/api/mcp/` is bypassable, and
only with a `manage`-scoped API key. The `SPAWN_CAPABLE_PREFIXES` can never be
added to the bypass list — the zod schema rejects them and
`isLocalOnlyBypassableByManageScope` denies them at runtime (defence-in-depth),
which is what the dashboard means by "cannot be made bypassable".
**Auditing access** — to verify nothing off-host is reaching these routes:
- Open the **Authorization Inventory** on `/dashboard/settings/security`: it renders the
live LOCAL_ONLY prefix list, which prefixes are bypassable, and the compile-time
spawn-capable ("cannot be made bypassable") set.
- Grep your reverse-proxy / access logs for the prefixes above paired with a
non-loopback client address. Any such hit that returned `200` instead of
`403 LOCAL_ONLY` means the proxy is masking the real client IP — fix the proxy.
- A `403 LOCAL_ONLY` in OmniRoute's logs for one of these paths is the guard
working as intended, not an error to suppress.
### Tier 2 — ALWAYS_PROTECTED
**Enforced by:** `isAlwaysProtectedPath(path)` → skip `requireLogin=false` bypass
**Bypass:** None when `requireLogin=false`; JWT always required
These routes are destructive or irreversible. Allowing them in a "no-password"
install would mean anyone on the same LAN could wipe the database or kill the
server process.
| Path | Reason |
| ------------------------ | --------------------------------- |
| `/api/shutdown` | Terminates the server process |
| `/api/settings/database` | Database export, import, and wipe |
**Response on violation:** `401 Authentication required`
### Tier 3 — MANAGEMENT (default)
All other management routes. Auth required unless `requireLogin=false` is
configured. CLI tokens can authenticate these routes (loopback + valid HMAC).
## Evaluation order
```
managementPolicy.evaluate(ctx)
1. isLocalOnlyPath(path)?
→ loopback → fall through
→ non-loopback, manage-scope Bearer
AND isLocalOnlyBypassableByManageScope → allow (management_key)
→ otherwise → reject 403 LOCAL_ONLY
2. isInternalModelSyncRequest(ctx)?
→ allow (system)
3. hasValidCliToken(headers)?
→ allow (cli) [loopback + timingSafeEqual HMAC check]
4. isAlwaysProtectedPath(path) or requireLogin=true?
→ isDashboardSessionAuthenticated?
→ allow (dashboard_session)
→ manage-scope Bearer on a non-bypassable path?
→ allow (management_key)
→ reject 401/403
5. requireLogin=false?
→ allow (anonymous)
```
Step 1's manage-scope branch is the only authenticated path that can satisfy a
LOCAL_ONLY route; the auth-backend failure mode returns 503 (not 403) so an
expired DB doesn't silently downgrade to "deny".
## Adding a new spawn-capable route
1. Add the path prefix to `LOCAL_ONLY_API_PREFIXES` in
`src/server/authz/routeGuard.ts`
2. Add a test in `tests/unit/authz/routeGuard.test.ts` asserting that
`isLocalOnlyPath()` returns true for the new prefix
3. **Never skip this step** — see Hard Rule #15 in `CLAUDE.md`
4. Decide: does this route ALSO belong in `LOCAL_ONLY_MANAGE_SCOPE_BYPASS_PREFIXES`?
Default answer is **no**. Only opt-in when the route is safe to expose to a
manage-scope holder (i.e. does NOT spawn arbitrary user-controlled code).
## Adding a manage-scope-bypassable path
1. Confirm the route does not execute user-supplied code or commands. If it
does, stop — this carve-out is the wrong tool.
2. Append the prefix to `LOCAL_ONLY_MANAGE_SCOPE_BYPASS_PREFIXES` in
`src/server/authz/routeGuard.ts`
3. Add coverage in `tests/unit/authz/management-policy.test.ts` for all four
request shapes: no Bearer (403), manage Bearer (allow), non-manage Bearer
(403), and the per-prefix regression that `/api/cli-tools/runtime/*` stays
strict-loopback even with a manage Bearer.
## Files
| File | Purpose |
| -------------------------------------------- | ------------------------------ |
| `src/server/authz/routeGuard.ts` | Constants and helper functions |
| `src/server/authz/policies/management.ts` | Evaluation logic |
| `tests/unit/authz/routeGuard.test.ts` | Unit tests for tier helpers |
| `tests/unit/authz/management-policy.test.ts` | Unit tests for evaluate() |
## Documenting Security Tiers in OpenAPI
When adding a new route to `docs/openapi.yaml`, apply the corresponding
vendor extension if the route is classified by `routeGuard.ts`:
| routeGuard.ts classification | YAML annotation | Enforcement |
| ----------------------------- | -------------------------- | ----------------------------------------------- |
| `LOCAL_ONLY_API_PREFIXES` | `x-loopback-only: true` | Blocked from non-loopback unconditionally |
| `ALWAYS_PROTECTED_API_PATHS` | `x-always-protected: true` | Auth required even with `requireLogin=false` |
| Internal admin/debug route | `x-internal: true` | Hidden from /dashboard/api-endpoints by default |
| None (public / standard auth) | (no annotation needed) | Standard `requireLogin`-controlled access |
### Validation
Two scripts enforce consistency between YAML annotations and `routeGuard.ts`:
- `scripts/check/check-openapi-coverage.mjs` — fails if coverage < 99%
- `scripts/check/check-openapi-security-tiers.mjs` — fails if `x-loopback-only` or
`x-always-protected` annotations diverge from the compile-time constants
Both scripts run in the pre-commit hook and in CI.
### False Positive Rule
If `x-always-protected` or `x-loopback-only` is annotated on a route that is NOT in
the `routeGuard.ts` constant, the coverage script fails. The fix is always to align the
YAML to what `routeGuard.ts` actually enforces — not to add routes to `routeGuard.ts`
without also implementing the enforcement logic.
---
## See also
- `docs/security/CLI_TOKEN.md` — CLI machine-ID token
- `docs/architecture/AUTHZ_GUIDE.md` — full authorization pipeline
- `docs/frameworks/MCP-SERVER.md` — MCP server transports and scopes
+242
View File
@@ -0,0 +1,242 @@
---
title: "Socket.dev Supply-Chain Finding Attestation"
description: "Maintainer attestation for the AI-detected potential-malware findings raised against omniroute and the v3.8.6 mitigations applied at each flagged call site."
---
# Socket.dev / supply-chain finding attestation
This document is the maintainer-authored attestation for the six
`AI-detected potential malware` findings raised against `omniroute@3.8.5` and
the mitigations applied in `omniroute@3.8.6`. It exists so:
1. Security-pipeline operators have a single reference to cite when they need
to evaluate the findings against the actual source.
2. Future AI scanners can pick up the maintainer-signed claim that each
flagged path is intentional, opt-in, and documented.
3. We have a written record of *why* each call site is shaped the way it is —
so a future refactor doesn't accidentally reintroduce a fingerprint that
was deliberately removed.
If you operate a scanner that re-flags any of the call sites below after the
v3.8.6 mitigations have shipped, please open an issue with the scan trace and
we will extend the attestation here.
---
## §1 — MITM root-CA install (`77484.js`)
**Source files**:
- `src/mitm/cert/install.ts` — public `installCert()` / `uninstallCert()`,
per-platform `installCertWindows/Mac/Linux`.
- `src/mitm/systemCommands.ts` — shared `execFile` / `spawn` / PowerShell
helpers used by the install paths.
**Trigger**: user clicks "Enable MITM proxy" in the local dashboard at
`/dashboard/cli-tools/mitm`. The route is loopback-only — see hard rule #17 in
`CLAUDE.md` and `src/server/authz/routeGuard.ts::isLocalOnlyPath()`. A leaked
JWT exposed via a tunnel **cannot** trigger this code path.
**Privileged operations performed (per platform)**:
| OS | Command(s) |
| ------- | ---------------------------------------------------------------------------------------------- |
| Windows | `certutil -addstore Root <cert>` via UAC |
| macOS | `sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain <cert>` |
| Linux | `sudo cp <cert> <distro-trust-dir>` + `sudo update-ca-certificates` (Debian) / `sudo update-ca-trust` (RHEL/SUSE) |
| Linux+Firefox/Chromium | per-profile NSS DB update via `certutil -d sql:<profile>` |
These are the same commands used by `mitmproxy`, Charles Proxy, Fiddler, and
Caddy. The fact that they exist in OmniRoute is documented at
`docs/security/STEALTH_GUIDE.md`.
**v3.8.6 mitigation**:
- `runElevatedPowerShell()` no longer uses `-EncodedCommand <base64utf16le>`.
The elevated payload is written to a per-call temp `.ps1` file (mode 0o600,
inside a private `mkdtempSync` directory) and referenced via `-File`. The
file is unlinked in `finally`. This removes the textbook
base64-elevation-via-PowerShell fingerprint flagged by Socket.dev's AI
classifier.
- `installCertWindows` carries an inline `SECURITY-AUDITOR-NOTE:` block
pointing here.
**Why we keep it**: the MITM proxy is a documented feature used by
`docs/security/STEALTH_GUIDE.md` and `docs/frameworks/MITM-PROXY.md`. Removing
it would break the agent-bridge feature set.
---
## §2 — Zed credential import (`app/api/providers/zed/import/route.js`)
**Source files**:
- `src/app/api/providers/zed/discover/route.ts` *(new in v3.8.6)*
- `src/app/api/providers/zed/import/route.ts`
- `src/lib/zed-oauth/keychain-reader.ts`
- `src/lib/zed-oauth/credentialFingerprint.ts` *(new in v3.8.6)*
**Trigger**: user clicks "Import from Zed" in the local dashboard Providers
page. Endpoint is gated by `requireManagementAuth`. The Zed editor itself
writes its provider API keys to the OS keychain under documented service
names — see https://zed.dev/docs/ai/llm-providers.
**v3.8.5 behaviour (the one Socket.dev flagged)**:
`POST /import` discovered the credentials and auto-saved them to the local
SQLite store in a single round-trip. No per-account confirmation, no
fingerprint, just "found N tokens, all imported."
**v3.8.6 mitigation — 2-step confirmation**:
1. **`POST /api/providers/zed/discover`** returns
`{ candidates: [{ provider, service, account, fingerprint }] }`. The raw
token is **never** transmitted. The fingerprint is
`sha256(service|account|token).slice(0,16)`.
2. The dashboard renders the candidate list, the operator selects which to
import, and posts `{ confirmedAccounts: [{ service, account, fingerprint }] }`
to **`POST /api/providers/zed/import`**.
3. The import endpoint **re-reads the keychain on the server** and filters by
`(service, account, fingerprint)`. A tampered or replayed discover
response cannot trick the import endpoint into saving an unrelated token —
if the live token has changed since discover, the fingerprint no longer
matches and the credential is skipped.
A `OMNIROUTE_ZED_IMPORT_LEGACY_ONE_STEP=true` env flag preserves the v3.8.5
behaviour for operators who haven't yet updated their automation. It will be
removed in v3.9.
**Why we keep it**: Zed import is the friendliest onboarding path for users
who already use Zed and want to mirror their provider keys into OmniRoute
without re-pasting.
---
## §3 — `execFile` / `spawn` / elevated PowerShell (`21843.js`)
**Source files**: `src/mitm/systemCommands.ts`.
**Why flagged**: the chunk re-exports `execFileWithPassword`,
`runElevatedPowerShell`, and the shared `quotePowerShell` helper. Socket.dev's
AI classifier sees them as a generic "host execution + privilege elevation
toolkit." Within OmniRoute they are only used by the MITM cert install path
(§1) and by `execFileWithPassword` for `sudo` command execution.
**v3.8.6 mitigation**:
- `runElevatedPowerShell` refactor (see §1).
- Inline `SECURITY-AUDITOR-NOTE:` block at both
`runElevatedPowerShell` and `execFileWithPassword` documents the allowlisted
callers and pinned executable list.
- The `execFileWithPassword` `spawn()` call carries a `nosemgrep` marker with
the allowlist of executables that the helper is allowed to receive — there
is **no path from user input to `finalCommand`/`finalArgs`**.
---
## §4 / §6 — 9router service supervisor (`api/services/9router/{start,restart}/route.js`)
**Source files**:
- `src/app/api/services/9router/_lib.ts` — supervisor factory.
- `src/app/api/services/9router/{start,stop,restart,status,install,update,auto-start}/route.ts`.
- `src/lib/services/ServiceSupervisor.ts` — generic spawn / health-poll / log-buffer.
**Trigger**: user clicks "Install" / "Start" on the embedded services page in
the local dashboard.
**Already-in-place protections**:
- All `/api/services/*` routes are LOCAL_ONLY per
`src/server/authz/routeGuard.ts` (hard rule #17). Loopback enforcement
happens before any auth check — a leaked JWT cannot reach them.
- The 9router DB row is seeded as `status='not_installed', auto_start=0` (see
`src/lib/db/migrations/071_services.sql:19`). The service does **not** start
on first launch.
- `spawn()` is called with the binary path returned by
`resolveSpawnArgs(apiKey, PORT)` in `src/lib/services/installers/ninerouter.ts`,
which is a fixed allowlist of supported binaries.
- Stdout/stderr is buffered in memory (5 MB cap, see `_lib.ts`) — no on-disk
write unless the user enables logging from the dashboard.
**v3.8.6 mitigation**: no functional change. The minimal build profile
(`OMNIROUTE_BUILD_PROFILE=minimal`) replaces
`src/lib/services/installers/ninerouter.ts` with a stub for users who want
the privileged paths physically removed from the bundle.
**Why we keep it**: 9router is an optional locally-installable companion
service (think: WordPress-style plugin) — strict opt-in.
---
## §5 — OmniRoute Cloud Sync credential write-back (`api/keys/[id]/route.js`)
**Source files**:
- `src/lib/cloudSync.ts``syncToCloud()` / `updateLocalTokens()`.
- `src/app/api/keys/[id]/route.ts` — invokes `syncKeysToCloudIfEnabled()`.
**Trigger**: `isCloudEnabled()` returns `true` (set from the dashboard) **and**
`CLOUD_URL` is configured. With both off, no outbound network call to the
Cloud endpoint is made.
**v3.8.5 behaviour (the bug Socket.dev caught the right way)**:
`updateLocalTokens()` overwrote `accessToken`, `refreshToken`, and
`providerSpecificData` from the Cloud response when
`cloudUpdatedAt > localUpdatedAt`. No HMAC, no signature, no checksum. A
misconfigured or hostile `CLOUD_URL` (or a MITM on the channel) could swap
provider OAuth tokens silently.
**v3.8.6 mitigation**:
1. **HMAC verification**: `verifyCloudSignature(rawBody, sigHeader)` checks
the `X-Cloud-Sig` header (`HMAC-SHA256(OMNIROUTE_CLOUD_SYNC_SECRET,
rawBody)`) before parsing the JSON. If the secret is set, the signature is
required. If not (legacy mode), a warning is logged and the response is
accepted — the secret will be required in v3.9.
2. **Secret-field opt-in**: `accessToken` / `refreshToken` /
`providerSpecificData` are **only** overwritten when
`OMNIROUTE_CLOUD_SYNC_SECRETS=true`. The default mode syncs only
non-credential metadata (`expiresAt`, `status`, `lastError*`,
`rateLimitedUntil`, `updatedAt`). This is a **breaking change** for users
who relied on remote token sync — they must explicitly opt in.
**Why we keep it**: Cloud Sync is the only way for an OmniRoute Cloud tenant
to centralise team credentials. The fix makes the threat model honest:
"server signs, client verifies, operator opts in."
---
## Build profile: `minimal`
For users who need a Socket-friendly artifact, build with:
```bash
OMNIROUTE_BUILD_PROFILE=minimal npm run build
```
The webpack `NormalModuleReplacementPlugin` aliases four modules to stubs:
| Module | Stub |
| --------------------------------------------------- | ------------------------------------------------------------ |
| `src/mitm/cert/install.ts` | `src/mitm/cert/install.stub.ts` |
| `src/lib/zed-oauth/keychain-reader.ts` | `src/lib/zed-oauth/keychain-reader.stub.ts` |
| `src/lib/cloudSync.ts` | `src/lib/cloudSync.stub.ts` |
| `src/lib/services/installers/ninerouter.ts` | `src/lib/services/installers/ninerouter.stub.ts` |
Each stub exports the same surface but every function throws a
`featureDisabledError(name)` at runtime. Routes that depend on the disabled
module return HTTP 503 with a clear message instead of activating the
sensitive code path.
The resulting bundle is intended to be published as `omniroute-secure`. See
`docs/ops/PUBLISHING_SECURE.md` for the publishing recipe.
---
## Plugin split (tracked for v4)
Long-term, we intend to split the npm package into separately auditable
modules. See the v4 milestone in the GitHub issue tracker for the tracking
issue.
+279
View File
@@ -0,0 +1,279 @@
---
title: "Stealth Guide"
version: 3.8.40
lastUpdated: 2026-06-28
---
# Stealth Guide
> **Source of truth:** `open-sse/utils/tlsClient.ts`, `open-sse/services/{chatgptTlsClient,claudeCodeCCH,claudeCodeFingerprint,claudeCodeObfuscation,claudeCodeCompatible,antigravityObfuscation}.ts`, `open-sse/config/cliFingerprints.ts`, `src/mitm/`
> **Last updated:** 2026-06-28 — v3.8.40
> **Audience:** Engineers maintaining provider-specific stealth integrations.
OmniRoute integrates with providers whose edges actively fingerprint non-official clients (TLS JA3/JA4, header ordering, JSON body shape, integrity tokens). This page documents the stealth surfaces OmniRoute exposes and where they are implemented.
## Legal and Ethical Notice
Stealth features exist so OmniRoute can act as a compatibility layer between user-owned official accounts (Claude Code CLI, ChatGPT Desktop/Web, Antigravity, Cursor, etc.) and OmniRoute's unified API. They are **not** for evading fraud detection, sharing credentials, or violating provider Terms of Service. The maintainers expect operators to comply with the upstream ToS they signed when creating accounts.
---
## TLS Fingerprinting Layer
### `open-sse/utils/tlsClient.ts` — wreq-js (Chrome 124)
Lazy-loaded `wreq-js` session that impersonates **Chrome 124 on macOS**. Used as a generic JA3/JA4 wrapper for upstreams behind Cloudflare. Falls back to native fetch when `wreq-js` is not installed (`available = false`).
- Singleton session: `browser: "chrome_124", os: "macos"`
- Proxy resolution (priority): `HTTPS_PROXY``HTTP_PROXY``ALL_PROXY` (also lower-case)
- Timeout: `TLS_CLIENT_TIMEOUT_MS` (inherits from `FETCH_TIMEOUT_MS`, default 600000)
- `wreq-js` Response is fetch-compatible (`headers`, `text()`, `json()`, `clone()`, `body`).
### `open-sse/services/chatgptTlsClient.ts` — tls-client-node (Firefox 148)
Dedicated TLS impersonator for `chatgpt.com`. ChatGPT's Cloudflare config pins `cf_clearance` to JA3/JA4 + HTTP/2 SETTINGS frame ordering — undici's handshake gets `cf-mitigated: challenge` even with valid cookies.
- Profile: `firefox_148` (must match the Firefox 148 `User-Agent` sent)
- Mode: `runtimeMode: "native"` (koffi-loaded shared library; avoids managed sidecar HTTP)
- `withRandomTLSExtensionOrder: true`
- `tlsFetchChatGpt(url, options)` supports streaming (writes body to temp file, tailed as `ReadableStream`)
- Hang detection: `raceWithTimeout` + `TlsClientHangError` triggers `resetClientCache()` so the next call respawns the binding
- Proxy resolution (priority): per-call `proxyUrl``OMNIROUTE_TLS_PROXY_URL``HTTPS_PROXY`/`HTTP_PROXY`/`ALL_PROXY` (the native binding does **not** read these envs itself; it must be threaded through)
- Errors: `TlsClientUnavailableError` (binary missing), `TlsClientHangError` (binding deadlocked)
---
## Claude Code Stealth Bundle
When `cliCompatMode` is on, OmniRoute reshapes outgoing Claude requests so they are indistinguishable from `claude-cli` traffic. Three modules collaborate:
### `claudeCodeFingerprint.ts`
Computes the 3-char `cc_version` fingerprint embedded in the billing header:
```
SHA256(SALT + msg[4] + msg[7] + msg[20] + version)[:3]
```
- `FINGERPRINT_SALT = "59cf53e54c78"` (hardcoded; matches official client)
- Inputs: chars at index 4, 7, 20 of the first user message text + version string
- Output: 3-char hex prefix
### `claudeCodeCCH.ts` (Client Content Hash)
Server-side integrity check the official Claude Code CLI computes via Bun/Zig. OmniRoute reimplements with `xxhash-wasm`:
1. Serialize body with `cch=00000;` placeholder
2. `xxhash64(bytes, seed) & 0xFFFFF`
3. Zero-padded 5-char lowercase hex
4. Replace `cch=00000;` with the computed token
Constants:
- Seed: `0x6e52736ac806831e`
- Pattern: `/\bcch=([0-9a-f]{5});/`
### `claudeCodeObfuscation.ts`
Inserts a Unicode **zero-width joiner** (`U+200D`) after the first character of "sensitive" client names so upstream filters cannot grep them. Default word list:
```
opencode, open-code, cline, roo-cline, roo_cline, cursor, windsurf,
aider, continue.dev, copilot, avante, codecompanion
```
Applied to: `system` blocks, all `messages[].content`, and `tools[].description` / `tools[].function.description`. Operator-overridable via `setSensitiveWords()`.
### `claudeCodeCompatible.ts` — `anthropic-compatible-cc-*` providers
For third-party Anthropic relays that only accept "real Claude Code" traffic:
- `CLAUDE_CODE_COMPATIBLE_USER_AGENT = "claude-cli/2.1.195 (external, sdk-cli)"`
- `CLAUDE_CODE_COMPATIBLE_STAINLESS_PACKAGE_VERSION = "0.94.0"`
- `CLAUDE_CODE_COMPATIBLE_STAINLESS_RUNTIME_VERSION = "v24.3.0"`
- `anthropic-beta = "claude-code-20250219,interleaved-thinking-2025-05-14,effort-2025-11-24"` by default
- The per-connection "Enable redact-thinking beta" toggle adds `redact-thinking-2026-02-12` when a CC Compatible upstream specifically requires redacted thinking streams
- The per-connection "Enable summarized thinking display" toggle stores `providerSpecificData.requestDefaults.summarizeThinking` and adds `display: "summarized"` to CC Compatible thinking requests that did not already set a display mode
- `CONTEXT_1M_BETA_HEADER = "context-1m-2025-08-07"` (Opus/Sonnet 4.x family)
- Default path: `/v1/messages?beta=true`
Sister modules in the same bundle:
- `claudeCodeConstraints.ts` — temperature + cache-control rules
- `claudeCodeToolRemapper.ts` — tool-name remapping
- `claudeCodeExtraRemap.ts` — extra payload normalization
---
## Antigravity Stealth
### `antigravityObfuscation.ts`
Same zero-width-joiner trick as Claude Code, but with an expanded word list that also masks: `claude code`, `claude-code`, `kilo code`, `kilocode`, **`omniroute`**. Mirrors ZeroGravity's `ZEROGRAVITY_SENSITIVE_WORDS` and CLIProxyAPI's cloak system.
### `antigravityHeaderScrub.ts`
Strips Stainless SDK markers (`x-stainless-lang`, `x-stainless-package-version`, `x-stainless-os`, `x-stainless-arch`, `x-stainless-runtime`, `x-stainless-runtime-version`, `x-stainless-timeout`, `x-stainless-retry-count`, `x-stainless-helper-method`) before forwarding.
### ⚠️ Risk: `ANTIGRAVITY_CREDITS=always` (account-ban hot spot)
`ANTIGRAVITY_CREDITS=always` (consumed by `open-sse/executors/antigravity.ts`) routes **every** request through Antigravity AI Credit Overages (paid Google credits) instead of letting Google's free-tier quota gate things. This is documented as a feature, but it is **the single most common ToS-violation report we see** — multiple Google Ultra accounts have been banned with `403 / "service disabled for ToS violation" / insufficient_quota` after running for a few hours with `=always`.
The upstream enforcement is on **Google's side**, not anything OmniRoute can prevent. The env var name and the existing docs make it sound like a safe knob to flip; it isn't.
**Why this draws abuse detection more aggressively than free-tier-only usage:**
- Sustained automated spend on a single Google account flags differently than free-tier hits-quota-and-stops.
- Credit overages have no rate ceiling, so a misconfigured client can burn through several hundred USD in minutes and look like API-key resale or bot traffic.
- Multiple OmniRoute users hitting overage credits in parallel from the same external IP compounds the signal.
**Recommended posture:**
1. **Default to `ANTIGRAVITY_CREDITS=retry`** — overages are used only when free-tier returns 429, not on every request. This is the safer of the two non-zero modes.
2. **Spread load across providers via Auto-Combo** (`model: "auto"` or `kr/glm/etc`-combo) instead of saturating a single Antigravity account.
3. **Set per-connection RPM limits** in the Antigravity provider's edit page (Dashboard → Providers → Antigravity → connection → rate limit). 3060 RPM is a defensible upper bound for sustained use.
4. **Use distinct upstream IPs** per Antigravity account when possible (residential proxies aimed at the same account from many users compounds the abuse signal).
5. **If banned**: appeal via `support.google.com` → "Restore Workspace/Account access" with the exact `quota_exceeded` / `service disabled` response body Google sent. Restoration is not guaranteed.
This warning is also surfaced inline in the dashboard near the Antigravity provider edit screen when `ANTIGRAVITY_CREDITS` is set to `always` (or will be in v3.8.0; tracked separately).
Touch points:
- `open-sse/executors/antigravity.ts` — reads `process.env.ANTIGRAVITY_CREDITS`
- `src/lib/oauth/providers/antigravity.ts` — credential plumbing
- Original incident report: Discussion [#1183](https://github.com/diegosouzapw/OmniRoute/discussions/1183)
---
## CLI Fingerprint Registry — `open-sse/config/cliFingerprints.ts`
Per-provider table that pins **exact** header ordering and JSON body field ordering captured from mitmproxy traces of the official CLIs. Currently registered: `codex`, `claude`, plus runtime-derived profiles in `providerHeaderProfiles.ts` for `antigravity`, `qwen`, `github`.
```ts
interface CliFingerprint {
headerOrder: string[]; // case-sensitive
bodyFieldOrder: string[]; // top-level JSON keys
userAgent?: string | (() => string);
extraHeaders?: Record<string, string>;
}
```
Toggle per provider via env (see below). When disabled, headers/body keys appear in whatever order Node/JSON gave them — easy to fingerprint.
---
## MITM Proxy (Antigravity, Linux/macOS/Windows)
For CLIs whose binaries cannot be redirected via `OPENAI_BASE_URL`, OmniRoute runs a local TLS-terminating proxy. Endpoints live under `src/app/api/cli-tools/antigravity-mitm/`.
| Method | Endpoint | Purpose |
| ------ | --------------------------------------- | ------------------------------------------------ |
| GET | `/api/cli-tools/antigravity-mitm` | Status — running, pid, dnsConfigured, certExists |
| POST | `/api/cli-tools/antigravity-mitm` | Start MITM (requires `apiKey` + `sudoPassword`) |
| DELETE | `/api/cli-tools/antigravity-mitm` | Stop MITM |
| GET | `/api/cli-tools/antigravity-mitm/alias` | List model aliases |
| PUT | `/api/cli-tools/antigravity-mitm/alias` | Save model aliases for a tool |
Target intercepted host: **`daily-cloudcode-pa.googleapis.com`** (Antigravity's upstream).
### Start sequence (`src/mitm/manager.ts::startMitm`)
1. Generate self-signed cert via `selfsigned` (RSA-2048, SHA-256, 1y) — `cert/generate.ts`
2. Install cert to system trust store — `cert/install.ts`
3. Add hosts entry `127.0.0.1 daily-cloudcode-pa.googleapis.com``dns/dnsConfig.ts`
4. Spawn `src/mitm/server.cjs` with `ROUTER_API_KEY` + `MITM_LOCAL_PORT` (default `443`)
5. Persist PID to `<DATA_DIR>/mitm/.mitm.pid`
### Linux dynamic trust-store detection — `cert/install.ts`
`getLinuxCertConfig()` walks a priority list and picks the first existing directory:
| Distro family | Directory | Update command |
| ------------------------ | ------------------------------------------- | ------------------------ |
| Debian / Ubuntu | `/usr/local/share/ca-certificates` | `update-ca-certificates` |
| Arch / CachyOS / Manjaro | `/etc/ca-certificates/trust-source/anchors` | `update-ca-trust` |
| Fedora / RHEL / CentOS | `/etc/pki/ca-trust/source/anchors` | `update-ca-trust` |
| openSUSE | `/etc/pki/trust/anchors` | `update-ca-certificates` |
Cert filename: `omniroute-mitm.crt`. Fingerprint match via `getCertFingerprint()` (SHA-1 of DER).
Additionally, `updateNssDatabases()` installs into per-user NSS DBs when `certutil` is available: `~/.pki/nssdb`, `~/snap/chromium/.../nssdb`, all Firefox profiles (including snap), under the nickname **`OmniRoute MITM Root CA`**.
### macOS / Windows
- **macOS:** `security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain`
- **Windows:** elevated PowerShell → `certutil -addstore Root`
### Auth
All MITM endpoints require management auth (`requireCliToolsAuth`). The sudo password is cached in module scope (never `globalThis`) and cleared on `stopMitm()`.
---
## User-Agent Overrides — env vars (`.env.example` section 12)
| Variable | Default |
| ------------------------ | --------------------------------------------------------------- |
| `CLAUDE_USER_AGENT` | `claude-cli/2.1.195 (external, cli)` |
| `CODEX_USER_AGENT` | `codex-cli/0.142.0 (Windows 10.0.26200; x64)` |
| `GITHUB_USER_AGENT` | `GitHubCopilotChat/0.54.0` |
| `ANTIGRAVITY_USER_AGENT` | `antigravity/2.0.1 linux/arm64 google-api-nodejs-client/10.3.0` |
| `KIRO_USER_AGENT` | `AWS-SDK-JS/3.0.0 kiro-ide/1.0.0` |
| `QODER_USER_AGENT` | `Qoder-Cli` |
| `QWEN_USER_AGENT` | `QwenCode/0.19.3 (linux; x64)` |
| `CURSOR_USER_AGENT` | `Cursor/3.4` |
Consumed by `open-sse/executors/base.ts::buildHeaders()` via dynamic lookup. **Bump these when providers release new CLI versions** — stale UA strings start getting rejected as outdated clients.
## CLI Compatibility Mode Toggles (`.env.example` section 13)
| Variable | Effect |
| -------------------------- | ------------------------------- |
| `CLI_COMPAT_CODEX=1` | Codex fingerprint |
| `CLI_COMPAT_CLAUDE=1` | claude-cli fingerprint |
| `CLI_COMPAT_GITHUB=1` | GitHub Copilot Chat fingerprint |
| `CLI_COMPAT_ANTIGRAVITY=1` | Antigravity fingerprint |
| `CLI_COMPAT_KIRO=1` | Kiro |
| `CLI_COMPAT_CURSOR=1` | Cursor |
| `CLI_COMPAT_KIMI_CODING=1` | Kimi Coding |
| `CLI_COMPAT_KILOCODE=1` | KiloCode |
| `CLI_COMPAT_CLINE=1` | Cline |
| `CLI_COMPAT_QWEN=1` | Qwen Code |
| `CLI_COMPAT_ALL=1` | Enable all of the above |
The provider IP is **always preserved** — the toggle only reshapes the request wire image, it does not switch IP egress.
---
## Inbound Header Sanitization
OmniRoute scrubs inbound client headers before forwarding so a request that arrives from Cursor doesn't leak `User-Agent: Cursor/X.Y.Z` to a Claude upstream. See `src/shared/constants/upstreamHeaders.ts` for the denylist, kept in lockstep with the Zod schemas and unit tests.
---
## Updating Fingerprints When a Provider Rotates
1. Capture official CLI traffic with `mitmproxy` (TLS interception + dump)
2. Extract JA3/JA4 and the literal header order
3. Update the relevant `CLI_FINGERPRINTS[...]` entry
4. Bump matching `*_USER_AGENT` default in `.env.example`
5. If TLS handshake itself changed: update `chatgptTlsClient.ts::CHATGPT_PROFILE` or wreq-js `browser:` option
6. Run `chatgptTlsClient.test.ts` and a manual canary against the live provider
7. Ship in a patch release; document in `CHANGELOG.md`
---
## Tests
- `open-sse/services/__tests__/chatgptTlsClient.test.ts` — proxy resolution priority, abort handling, hang recovery
- `tests/unit/anthropic-cache-fingerprint.test.ts` — fingerprint determinism
- `tests/unit/chatgpt-web.test.ts` — end-to-end stealth path for ChatGPT
---
## See Also
- [RESILIENCE_GUIDE.md](../architecture/RESILIENCE_GUIDE.md) — what happens when a stealth path gets a `403`
- [TROUBLESHOOTING.md](../guides/TROUBLESHOOTING.md)
- [ENVIRONMENT.md](../reference/ENVIRONMENT.md) — full env reference
- [CLI-TOOLS.md](../reference/CLI-TOOLS.md) — operator view of the MITM workflow
+59
View File
@@ -0,0 +1,59 @@
---
title: "Supply-Chain Gates"
---
# Supply-Chain Gates (Phase 8 · Block A)
OmniRoute publishes npm + Docker artifacts. These gates provide provenance,
inventory (SBOM) and CVE scanning, all OSS, plugged into release workflows.
**Advisory-first** posture — they report now, promote to blocking after the 1st
green release.
| Gate | Tool | Where | Blocks? | Output |
| --------------------- | ---------------------------------------------- | ----------------------------- | ------------------------ | --------------------------------------------- |
| SLSA provenance (npm) | `npm --provenance` (OIDC) | `npm-publish.yml` | only if publish fails | badge npmjs / `npm audit signatures` |
| SBOM npm | `@cyclonedx/cyclonedx-npm` | `npm-publish.yml` | only if generation fails | Release asset + artifact |
| SBOM image | `anchore/sbom-action` (syft) | `docker-publish.yml` (merge) | advisory | CycloneDX artifact |
| Trivy CVE (SARIF) | `aquasecurity/trivy-action` | `docker-publish.yml` (merge) | advisory | SARIF (HIGH+CRITICAL) → Security tab |
| Trivy CRITICAL gate | `aquasecurity/trivy-action` | `docker-publish.yml` (merge) | **blocking** | `exit-code: '1'` on fixable CRITICAL |
| osv vulnCount | `osv-scanner` (`check:vuln-ratchet --ratchet`) | `ci.yml` (`quality-extended`) | **blocking** | ratchets `metrics.vulnCount` (direction:down) |
| OpenSSF Scorecard | `ossf/scorecard-action` | `scorecard.yml` (cron) | advisory | SARIF → Security + badge |
The image CVE ratchet uses **two steps** in `docker-publish.yml`: the SARIF step
(`HIGH,CRITICAL`, `exit-code: 0`) keeps HIGH+CRITICAL visible in the Security tab
without blocking; the _CRITICAL gate_ step (`severity: CRITICAL`, `ignore-unfixed: true`,
`exit-code: 1`) fails the release on a CRITICAL CVE **with a fix available**. `ignore-unfixed`
prevents blocking the release for a base-image CVE without an upstream patch.
## ⚠️ CVE Variance (blocking osv/Trivy gates)
osv and Trivy compare deps against CVE databases that **continuously grow**. A PR
that **touches no dependencies** can suddenly turn red because a new CVE was
disclosed in an existing dep (osv: measured `vulnCount` > baseline; Trivy: a new
fixable CRITICAL in the image). **This is EXPECTED operational behavior of a blocking
CVE gate, not a product regression.**
When osv or Trivy go red due to a newly disclosed CVE, the remedy is:
1. **Bump the affected dep** (preferred) — upgrade to the patched version via `package.json`
`overrides` (transitive deps) or rebuild the image on a patched base.
2. **If there is no upstream fix:**
- **osv:** re-baseline `metrics.vulnCount` in `config/quality/quality-baseline.json`
(`npm run quality:ratchet -- --update` does not cover dedicated gates — edit the value by
hand, `direction:down`) with a justification note + tracking issue.
- **Trivy:** add an entry in `.trivyignore` (CVE-ID per line) with a justification
comment + tracking issue. `ignore-unfixed: true` already covers CVEs without
patches automatically.
Both gates **gracefully SKIP** (exit 0) when the tool is absent or the measurement
fails (osv-scanner not in PATH, osv.dev/network unreachable, invalid JSON) — a
**measurement** failure never blocks, only a **measured** regression blocks.
## Backlog: Scorecard advisory → blocking
After the 1st green release with Scorecard reporting:
- Scorecard: score ratchet (freezes the measured score; cannot decrease).
Complements the Phase 7 gates (osv-scanner, gitleaks, actionlint+zizmor): zizmor
audits the workflows themselves; Scorecard measures the repo posture in aggregate.
+15
View File
@@ -0,0 +1,15 @@
{
"title": "Security",
"pages": [
"GUARDRAILS",
"PUBLIC_CREDS",
"ERROR_SANITIZATION",
"ROUTE_GUARD_TIERS",
"BAN_DETECTION",
"STEALTH_GUIDE",
"EGRESS_POLICY",
"COMPLIANCE",
"SOCKET_DEV_FINDINGS",
"CLI_TOKEN"
]
}