Files
OmniRoute/src/shared/constants/spawnCapablePrefixes.ts
CAPSLOCKB 813bea4184 feat(vnc-session): persistent noVNC browser login for web-cookie providers (#7892)
* chore(ci): add .mergify.yml to main — Mergify only reads config from the default branch (#7168)

* feat(vnc-session): persistent noVNC browser login for web cookie/token providers

## Why (the headless-install problem)

OmniRoute's web cookie/token providers (ChatGPT Web, Gemini Web, Claude
Web, DeepSeek Web, …) need a live browser session, but the gateway normally
runs **headless** — as a systemd service, inside Docker, or on a VPS with no
display. There is no desktop for the operator to log into the provider in.

Today the operator has to obtain the session cookie/token *out of band* (open a
real browser elsewhere, export cookies, paste them into the connection row). That
is fiddly, breaks on every provider UI change, and is a non-starter on a
headless box where you can't open a browser at all.

This PR adds an **on-demand interactive login**: OmniRoute boots a
containerized browser that exposes a noVNC web UI at the host. The operator
opens that URL in *their own* browser, logs in normally, and OmniRoute then
harvests the resulting cookies / localStorage back into the provider's
`provider_connections` row over the DevTools Protocol. No display required on
the host — the headless server renders the login into a container and the human
just drives it through a web page.

## How we ran into this

- The shipped `dist/` bundle has **no App Router source**, so the only visible
  seam was `dist/server-ws.mjs`'s `http.createServer` monkeypatch. That seam
  is **dead**: Next's standalone `startServer` creates its own http server in a
  way that bypasses the override, so a route registered there never fires
  (debug logs confirmed: zero requests reached it). The real seam is the Next
  **App Router** (`src/app/api/...`), which lives in the dev tree, not `dist/`.
- **Chromium ≥130 forces the remote-debugging port onto `127.0.0.1`** and
  ignores `--remote-debugging-address=0.0.0.0`. A plain published port can't
  reach it, so cookie harvest needs an in-container TCP bridge to republish the
  loopback CDP onto `0.0.0.0`. We shipped that bridge, but the cleaner
  default is **Firefox** (`jlesage/firefox`): its debugger binds `0.0.0.0` out
  of the box, so harvest works with no bridge at all.
- The CDP harvester **hung forever** on the first tries: the message handler was
  defined but never attached to the socket, so every `send()` promise stayed
  pending. We replaced Playwright's `connectOverCDP` (which stalls through the
  bridge) with a **raw `ws` client** and wired the handler — now resolves.

## What

New management API (scoped like the other admin endpoints via
`requireManagementAuth`):

| Method | Path | Purpose |
| --- | --- | --- |
| GET | `/api/vnc-session` | list active sessions + supported providers |
| GET | `/api/vnc-session/:provider` | session state |
| POST | `/api/vnc-session/:provider/start` | boot browser container → returns `vncUrl` |
| POST | `/api/vnc-session/:provider/harvest` | persist cookies into the provider row |
| POST | `/api/vnc-session/:provider/touch` | defer idle auto-stop |
| DELETE | `/api/vnc-session/:provider` | stop + remove the container |

## Implementation

- `src/lib/vncSession/manifest.ts` — provider → login URL + cookie/token map + config
- `src/lib/vncSession/harvest.ts` — raw-CDP cookie/localStorage harvester (`ws`)
- `src/lib/vncSession/service.ts` — docker lifecycle, port allocation, idle sweep, DB write
- `src/app/api/vnc-session/**` — App Router routes
- `src/lib/gracefulShutdown.ts` — tears down running login containers on exit

## Browser image choice

Default is **`jlesage/firefox`** (0.0.0.0-friendly CDP, no bridge). The
Chromium image + in-container bridge lives under `docker/vnc-browser/chromium`,
selectable via `OMNIROUTE_VNC_IMAGE`. See `docker/vnc-browser/README.md`.

## Config (env)

`OMNIROUTE_VNC_IMAGE`, `OMNIROUTE_VNC_CONTAINER_VNC_PORT`,
`OMNIROUTE_VNC_CONTAINER_CDP_PORT`, `OMNIROUTE_VNC_PROFILE_DIR`,
`OMNIROUTE_VNC_IDLE_MS`, `OMNIROUTE_VNC_MAX_MS`, `OMNIROUTE_VNC_MAX_SESSIONS`,
`OMNIROUTE_DOCKER_BIN` — all documented in the docker README.

## Tests

`tests/unit/vnc-session.test.ts` — manifest lookup + credential mapping
(cookie / token / whole-jar). All passing via the Node test runner.

## Notes

- Docker is the only external dependency; if the `docker` CLI is missing, `start`
  throws a clear error and shutdown is a no-op.
- No secrets are returned by any endpoint — only session metadata + ports.

Co-authored-by: Sora <138304505+Capslockb@users.noreply.github.com>
Co-authored-by: Bernardo <138304505+Capslockb@users.noreply.github.com>

* refactor(vnc-session): derive provider credentials from shared contract

* fix(vnc-session): harden CDP harvesting and credential filtering

* refactor(vnc-session): scope lifecycle to provider connections

* fix(vnc-session): sanitize and scope management routes

* fix(vnc-session): use canonical provider list in API

* test(vnc-session): align coverage with canonical manifest

* docs(vnc-session): align browser setup with current implementation

* fix(security): loopback-gate /api/vnc-session (Hard Rule #15/#17)

The new /api/vnc-session/* routes spawn Docker containers via
child_process.spawn (src/lib/vncSession/service.ts) but were never
registered in LOCAL_ONLY_API_PREFIXES or SPAWN_CAPABLE_PREFIXES, so
they were reachable from non-loopback callers (any manage-scope API
key or dashboard session over a tunnel) - the same CVE class
(GHSA-fhh6-4qxv-rpqj) those constants exist to close.

Register VNC_ROUTE_PREFIX (already exported but unused in
manifest.ts) in both prefix lists, and add a regression test
asserting isLocalOnlyPath()/isLocalOnlyBypassableByManageScope()
correctly classify the new prefix.

Co-authored-by: CAPSLOCKB <138304505+Capslockb@users.noreply.github.com>
Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouzapw@users.noreply.github.com>

---------

Co-authored-by: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com>
Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouza.pw@gmail.com>
Co-authored-by: Diego Rodrigues de Sa e Souza <diegosouzapw@users.noreply.github.com>
2026-07-22 10:46:04 -03:00

39 lines
2.8 KiB
TypeScript

/**
* Compile-time deny-list: route prefixes whose handlers can spawn arbitrary local
* subprocesses (npm install, node, MITM server, python CLIs) on behalf of the
* caller. These MUST NEVER appear in the manage-scope bypass list — regardless of
* DB state — because reaching them from non-loopback would re-introduce the
* GHSA-fhh6-4qxv-rpqj surface that the LOCAL_ONLY tier exists to close.
*
* Enforced at two layers:
* 1. zod schema (`settingsSchemas.ts`): rejects `PATCH /api/settings` with error
* code `BYPASS_PREFIX_NOT_ALLOWED` if any entry in
* `localOnlyManageScopeBypassPrefixes` falls inside this set.
* 2. runtime (`isLocalOnlyBypassableByManageScope` in `routeGuard.ts`): even if a
* malformed DB row claims a spawn-capable path is bypassable, the policy refuses.
*
* 🔒 This constant lives in `@/shared/constants` — a server-free leaf module — and
* NOT in `@/server/authz/routeGuard`, on purpose. `settingsSchemas.ts` is reachable
* from client components (dashboard onboarding wizard → validation barrel), and
* importing it from `routeGuard.ts` dragged routeGuard's server runtime
* (runtimeSettings → localDb → apiKeys → rateLimiter → ioredis) into the browser
* bundle, breaking the Next CLI/client webpack build with
* `Module not found: Can't resolve 'dns'/'net'`. Keeping the value here lets both the
* client-safe schema and the server routeGuard import it with no server coupling.
* Regression guard: `tests/unit/authz/spawn-capable-prefixes-client-safe.test.ts`.
* Hard Rules #15 + #17.
*/
export const SPAWN_CAPABLE_PREFIXES: ReadonlyArray<string> = [
"/api/cli-tools/runtime/",
"/api/cli-tools/qwen-settings", // GET probes the Qwen Code binary; the route also mutates local ~/.qwen files
"/api/services/", // T-10: can run npm install + spawn node processes
"/api/tools/agent-bridge/", // start/stop MITM server + DNS edits (Hard Rules #15 + #17)
"/api/tools/traffic-inspector/", // http-proxy listener + system proxy (Hard Rules #15 + #17)
"/api/plugins/", // plugins: load/execute via worker_threads + child_process (Hard Rules #15 + #17)
"/api/local/", // T-12: 1-click local service launchers (Redis today) — must never be whitelistable via manage-scope bypass (Hard Rules #15 + #17)
"/api/skills/collect/", // Skill Collector CLI detection: GET .../detect spawns a child process per CLI_TOOL_IDS entry — must never be whitelistable via manage-scope bypass (Hard Rules #15 + #17, PR #6294 review)
"/api/headroom/start", // spawns headroom-ai python CLI — must never be bypassable (Hard Rules #15 + #17)
"/api/headroom/stop", // kills tracked PID — must never be bypassable (Hard Rules #15 + #17)
"/api/vnc-session", // #7892: spawns Docker containers via child_process.spawn (src/lib/vncSession/service.ts) — must never be whitelistable via manage-scope bypass (Hard Rules #15 + #17)
];