Skip to content

Fix/security review round3 - #282

Merged
robdmac merged 26 commits into
mainfrom
fix/security-review-round3
Oct 16, 2026
Merged

robdmac merged 26 commits into
mainfrom
fix/security-review-round3

Conversation

@robdmac

@robdmac robdmac commented Oct 16, 2026

Copy link
Copy Markdown
Contributor

No description provided.

robdmac and others added 26 commits October 12, 2026 09:44
…et, egress default-deny

- Remove /internal/gateway/:provider/validate and /validate-token (returned raw OAuth access tokens; unused)
- createEdge requires both endpoints on the dashboard; delivery/executor/webhook queries scope edges to the dashboard
- CSRF: reject cookie-authenticated state-changing requests from origins outside ALLOWED_ORIGINS
- Sign PTY + dashboard tokens with control-plane-only PTY_TOKEN_SECRET (falls back to INTERNAL_API_TOKEN with warning); desktop generates one per boot
- Pool UIDs 2000-2099 are egress default-deny at the kernel (loopback, established, DNS, EGRESS_EXTRA_TCP_PORTS)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ernal routes

Sandbox VMs no longer need the shared INTERNAL_API_TOKEN (readable by root in
every VM) to call the control plane, so one VM can't reach another dashboard's
data.

- Sandbox calls /internal/* with its dashboard token (X-Dashboard-Token) via the
  new internal/cpauth package, which refreshes the 7-day token before expiry;
  callers holding an expired token wait for the in-flight refresh
- Control plane VM-reachable routes accept the dashboard token and check the
  resource's dashboard (drive, mirror, egress, integrations, mcp-tools,
  sessions, browser open, schedule callbacks); admin-only routes stay full trust
- POST /internal/sandbox/dashboard-token/refresh (30-day grace for suspended VMs)
- WITHHOLD_INTERNAL_TOKEN_FROM_SANDBOX=true stops new VMs receiving INTERNAL_API_TOKEN

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…quests

Replace the shared static SANDBOX_INTERNAL_TOKEN (held by every VM) with
Ed25519 signatures minted only by the control plane. Each X-Sandbox-Auth
token binds target machine, method and path+query, valid 60s; VMs get only
SANDBOX_AUTH_PUBLIC_KEY. Signed requests also carry fly-force-instance-id so
no other VM is the first hop. A bad signature never falls back to the static
token; the static token is accepted only while configured (desktop, rollout).

- controlplane: new sandbox/request-signing.ts; sandboxFetch/sandboxHeaders/
  SandboxClient sign every request; raw lost+found fetches moved to
  sandboxFetch; deletePty now sends the machine ID; new VMs get sandboxAuthEnv
- sandbox: auth middleware verifies signatures, constant-time static compare
- tests for signer and verifier

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…I_TOKEN in VM)

The desktop VM image and app hardcoded dev-sandbox-token and
dev-internal-token, so any local process or agent could drive the sandbox
or call the control plane's /internal routes.

- Each boot generates an Ed25519 key: the control-plane worker gets
  SANDBOX_AUTH_SIGNING_KEY; the VM gets only the public key on the kernel
  command line (orcabot.sandbox_auth_public_key=, read by rc.local)
- INTERNAL_API_TOKEN is per-boot and host-only; the VM uses its
  dashboard-scoped tokens. Explicit env overrides still work (dev.sh)
- rc.local no longer exports either token; fails closed without the key
- regression-smoke: traversal check goes through the signed file proxy;
  new check that the sandbox rejects unsigned and old-token requests

Requires a new VM image version (v5): a new app on the v4 image fails closed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The TCP->vsock bridge did one write() on a non-blocking fd and dropped
whatever didn't fit once the guest buffer filled (~8KB). Uploads and
orcabot push over ~8KB hung, and larger control-plane responses to the VM
were truncated (the sandbox silently lost the dashboard UI MCP tools).
Now loops until every byte is written, polling for space on EAGAIN.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…le checks, subscription scoping

OAuth
- Bind state to the starting browser: connect sets an HttpOnly per-state
  cookie (hash stored with the state); callbacks require it or a session for
  the same user. GitHub device poll requires the starting user.

Integration policy enforcement
- validateGatewayArgs (arg-safety.ts) before enforcePolicy: path-segment IDs
  reject / .. ? # %, no CR/LF in email headers, one address per recipient
- Messaging target derived from the field each client sends to; LLM-supplied
  channel_name ignored (resolved server-side); blocks/content count toward
  maxMessageLength
- WhatsApp phone_number_id and Matrix homeserver come from stored integration
- Calendar client reads calendarId ?? calendar_id like the check; eventId
  encoded; Outlook event ops scoped to the checked calendar
- Drive pre-check fails closed; telegram.get_chat_info channel-filtered;
  outlook.reply denied when the sender lookup fails; Gmail builder refuses CR/LF

Roles
- Only the attacher can change an integration policy; attacher or owner can detach
- Messaging send and subscription creation require editor
- Browser start/stop/open and the VNC websocket require editor

Subscriptions
- Slack teamId must be one of the caller's workspaces and the channel must
  exist there; Discord channel must be in the caller's recorded guild
- Discord callback only trusts the guild_id query param if the user's guilds include it

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Desktop opens OAuth connect on 127.0.0.1 but its callback is localhost, so
  the host-only state-binding cookie never reached the callback. Connect on a
  loopback alias of OAUTH_REDIRECT_BASE (same port/scheme) now bounces once to
  the callback host, keeping the query (desktop's dev-auth identity).
- GitHub path validation only rejects '.'/'..' segments and control chars; the
  client URL-encodes each path segment (and owner/repo), so names with spaces,
  '#', '?' or '%' work.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…oard secrets

Browser isolation (sandbox):
- Xvfb, x11vnc, websockify and Chromium run as the new orcabot-browser user
  (uid 1990), never root; Chromium's --no-sandbox no longer means root
- x11vnc and websockify bind 127.0.0.1 only; all browser ports come from
  6100-6299, which iptables rejects for pool UIDs 2000-2099
- X display requires an MIT-MAGIC-COOKIE-1 (Xvfb -auth), so agents can't
  inject input into the browser window
- /workspace/.browser is a 0700 browser-owned dir; root prepares it via an
  O_NOFOLLOW dir fd (symlinks moved aside, legacy root profiles handed over
  without following links)
- browser user also goes through the pool egress default-deny chain

Secrets (control plane):
- a dashboard VM only receives its owner's secrets and approved domains
  (dashboardSecretsOwner), at PTY start, on explicit apply and on auto-apply,
  so a collaborator's keys never land behind other terminals' broker URLs

Tests: browser isolation tests (profile symlink/migration/xauth/ports),
iptables integration test for the browser port block and browser egress,
owner-only secrets tests.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ofile handover, collaborator secret cleanup

Sandbox browser stack:
- Verified browser-port connections: every server connection to a browser
  port (waitForPort, DevTools HTTP + CDP websocket, the VNC reverse proxy)
  goes through DialBrowser, which checks in /proc/net/tcp that the accepting
  socket belongs to orcabot-browser, so a pool UID that binds the port first
  can't receive CDP commands or a viewer's VNC session. waitForDebugReady has
  an overall timeout; Status() only tears down on a refused or foreign-owned
  port, not on an inconclusive check.
- Xvfb -displayfd: Xvfb picks a free display itself (squatted displays are
  skipped), the X cookie is written atomically to a random path, and Start
  checks Xvfb is still running before launching x11vnc and Chromium.
- Trusted-profile handover: a root-owned legacy profile is handed over only if
  every entry is root-owned and nothing is group/other-writable; anything else
  (including other owners) is moved aside and recreated. Handover strips
  group/other bits and unlinks hard-linked and special files.

Egress:
- ip6tables block: pool UIDs are blocked from browser ports on ::1 as well as
  127.0.0.0/8 (idempotent, ahead of the pool egress jump).
- A missing orcabot-browser user skips only its egress jump (with a warning)
  instead of failing the chain and leaving the pool without default-deny.

Control plane:
- Collaborator secret cleanup: applyDashboardOwnerSecrets unsets previously
  applied names (and their _BROKER vars) before a terminal starts; cleanup
  fails closed and tracking only changes after the sandbox acknowledges it,
  including on the explicit apply endpoint. A stale/unreachable sandbox takes
  the reprovision path; failing to load the owner's secrets (e.g. no
  SECRETS_ENCRYPTION_KEY) unsets everything tracked and still starts the
  terminal.

Tests cover each fix (each verified to fail with the fix reverted); root-only
tests skip when not run as root.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… lists, tracking across reprovision

- arg-safety: key path-segment checks by the gateway's provider ids
  (google_drive, google_calendar) so they actually run; type the table by
  IntegrationProvider. Drive IDs must be [A-Za-z0-9_-]; the Drive client
  URL-encodes fileId and escapes folderId in parent queries. Gateway-level
  test covers fileId "ID?alt=media#".
- secrets: one pushDashboardOwnerSecrets helper for terminal start,
  auto-apply and explicit apply; always sends the full approval list ([]
  included) so a collaborator's approvals can't authorize the owner's
  same-named secret.
- sessions: recovery blanks the dashboard_sandboxes row instead of deleting
  it, keeping applied_secret_names so the replacement session (which may
  reuse the workspace .env) unsets them; inserts claim a blank row.
- tests/mocks/d1: support ON CONFLICT upserts and literal values.
- desktop drift allowlist: WITHHOLD_INTERNAL_TOKEN_FROM_SANDBOX is cloud-only.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…reenshot hardening, secret push targeting

- Desktop: host pushes its time into the VM over vsock (boot, wake, every 60s);
  new guest-timesync daemon (needs the v5 VM image)
- Browser: profile moved out of /workspace; screenshots written without
  following symlinks; pool-UID listeners in the browser port range can't answer;
  Status tears down when any browser process dies; ports cleared on stop
- Secrets: env-var name validation (E79732), invalid stored names skipped,
  pushes go to the dashboard's current sandbox session, unsetting a built-in key
  drops its *_BASE_URL, approvals-only updates accepted
- Gateway: non-string policy args rejected; Outlook event IDs segment-checked
- CSRF: allow the control plane's own origin (Drive picker)
- Dashboard token refresh bound to the sandbox it was minted for
- Signed CP->sandbox requests: 30s TTL + 30s skew (60s replay window max)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…neration

- Browser: one Chromium profile per workspace under the browser user's home
  (profiles/<hash of workspace root>), so desktop dashboards sharing a VM no
  longer share cookies/local storage; the briefly shared <home>/profile is
  deleted. Integration test proves dashboard B can't read A's local storage.
- cpauth: rank cached dashboard tokens by sbx_gen, then expiry. A same-second
  replacement for a new sandbox no longer loses to the old sandbox's token,
  which the control plane refuses to refresh (E79398).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
On desktop, concurrent provisioning requests each create a sandbox session;
the loser's session is deleted, but its newer-generation token stayed in the
cache and was refused on refresh (E79398). Tokens are now owned by the
session that brought them and released on session delete; the newest
generation a live session holds is used, and a refresh refused as replaced
drops that generation and falls back to the next.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… line

VMConfig.env never reaches the macOS/Linux guest, so EGRESS_PROXY_ENABLED=true
on the host was silently ignored and every terminal ran as root in the VM.
The host now appends orcabot.egress_proxy_enabled=1 to the cmdline and rc.local
(rclocal-v4-egress-flag) exports it. With egress on, rc.local waits for a
nameserver before starting the server so pool-UID DNS rules are installed, and
the VM image now ships the netfilter modules (stripped) iptables needs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… AI model

Browser profiles were pulling component updates and the on-device GenAI model
from gvt1/edgedl on launch (hundreds of MB per profile). A managed policy at
/etc/chromium/policies/managed/orcabot.json turns both off.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The replayed prompt arrives before the connect effect runs, so the banner
landed mid-line after it. The prompt already shows the connection is up.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
React Flow nodes keep their optimistic temp IDs, and edge creation sent them,
so the server rejected the edge (E79391: endpoints must belong to this
dashboard); before that check they were stored pointing at nothing and
vanished on reload. Edge creation now resolves temp IDs to the real item IDs,
waiting for a block still being saved (and failing if that save fails), and
maps the saved edge back to the on-screen node keys.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- E2E_SURFACE_TOKEN adds ?surface=<token> before page scripts run, mirroring
  the desktop loading-screen handoff (WS auth needs it).
- SHELL_PROMPT_RE accepts a prompt that wraps onto one more row (long pty-NNN
  user + /workspace/<uuid> cwd on desktop).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… isolation

Egress / auth:
- Egress proxy, transparent proxy and secrets broker refuse loopback targets on
  the browser range (6100-6299) and MCPLocal, checking the dialed address so
  names resolving to loopback are caught. Pool UIDs could reach any dashboard's
  DevTools through the root proxy.
- With EGRESS_PROXY_ENABLED=true, any kernel-layer setup failure (or missing
  CAP_NET_ADMIN) refuses PTY spawns (E79770) instead of running them as root;
  /health reports pty_spawn_refused.
- cpauth drops tokens refused with E79393/E79395 (30s refresh backoff); sessions
  no longer fall back to a dropped token; PTY create accepts a fresh mcp_token.

Browser:
- Navigation is http/https only and never to loopback browser ports
  (ValidateNavigationURL on every agent-reachable path).
- Xvfb/x11vnc/websockify run as new user orcabot-vnc; orcabot-browser is
  blocked from 6100-6299, so page JS cannot reach other dashboards VNC/CDP.
- Sessions on one workspace share a browser stack; SingletonLock cleared only
  for dead pids; process-wide port reservations; Preferences opened without
  following FIFOs/symlinks; profile dirs created via openat/O_NOFOLLOW walk.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…, token rollout

- Discord gateway always uses the stored guild, checks every channel belongs to
  it (E79624-E79629), denies unknown actions; subscription replies checked too
  (E79630/E79631). The bot token is shared across tenants.
- Discord connect binds a guild only with the bot scope, and a query guild_id
  only if the user owns or manages it.
- sandboxHeaders strips Cookie, Authorization, X-Dashboard-Token and dev-auth
  headers before proxying to the shared VM.
- Empty-string IDs are treated as omitted instead of rejected (E79610).
- Each new PTY gets a fresh dashboard token (mcp_token) so VMs recover after a
  PTY_TOKEN_SECRET change; rollout order documented.
- Secrets fallback inserts an unclaimed row (no made-up generation);
  applied_secret_names merges with concurrent pushes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…e, cancelled edges

- Desktop per-boot secrets use getrandom on every platform and abort startup if
  randomness is unavailable (was an all-zero key).
- vz-helper buffers host-to-guest writes with a write source instead of blocking
  the queue that drains guest reads; backpressure at 1MB.
- An edge deleted while its block is still saving is not created/re-added.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ejection

- Secrets: a live sandbox with no dashboard_sandboxes row is recorded with its
  real session again. The blank row from the previous fix read as retired, so
  every later push returned early and deleted secrets were never unset. Its
  tokens predate sbx_gen, so refresh binds them to the row; a racing
  provisioning claim reuses the session.
- cpauth: a refresh rejection only drops the generation if the rejected token
  is still the stored one, so a fresh token delivered meanwhile (e.g. by PTY
  creation after a PTY_TOKEN_SECRET change) is kept.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Error codes E79110-E79148 on egress proxy, privileged socket, debug
  exec, MCP, broker approval and the Gemini/Anthropic shims
- Copyright headers on guest-timesync
- Internal identifier renames in cpauth, egress, browser, auth, pty and
  cmd/server; no strings, routes or log text changed

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Error codes E79150-E79195 across index, sessions, mcp-ui, send, rate
  limit and dashboard DOs, releases, integrations and mcp-tools
- ASR handler errors coded E79240-E79259; gateway reason fields coded
  E79260-E79285 (machine codes in error unchanged)
- E40001-E40003 renumbered to E79281-E79283 to match the code family
- /init-db no longer returns a stack trace (still logged server-side)
- Copyright header on releases handler; internal identifier renames

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…emory

Browser teardown SIGKILLed every process group. x11vnc frees its SysV
shared-memory framebuffers only on a clean exit, so each browser start
leaked its segments (several MB each). After a few hundred starts in one
VM the kernel.shmmni limit (4096) was reached and every later x11vnc
failed with "shmget: No space left on device", so the browser never
started again ("browser vnc server failed to start").

- Signal all process groups with SIGTERM, wait up to 2s, then SIGKILL
  and reap; leaders stay unreaped until after the SIGKILL so the group
  pid cannot be reused
- Set kernel.shm_rmid_forced=1 at startup so segments of an x11vnc that
  crashes are freed too (read-only in Docker; logged)
- Tests for TERM-before-KILL and for processes that ignore TERM

Verified: 25 start/stop cycles in the sandbox container, shared memory
segments back to 0 after the stops (previously 4096 leaked).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@robdmac
robdmac merged commit 078fcc5 into main Oct 16, 2026
1 check passed
@robdmac
robdmac deleted the fix/security-review-round3 branch October 16, 2026 12:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant