chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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` |
|
||||
@@ -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
|
||||
@@ -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`
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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_
|
||||
@@ -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`.
|
||||
@@ -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 `1–65535`,
|
||||
`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 (1–65535) | `443` | Destination TCP port to transparently intercept |
|
||||
| **mark** | int (≥1) | `0x2333` | Firewall mark set on `OUTPUT`, matched by the `ip rule` + `PREROUTING` |
|
||||
| **onPort** | int (1–65535) | `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`) |
|
||||
@@ -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_
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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). 30–60 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
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user