# Security Guide — where to look > This is **not** the vulnerability-disclosure policy (that's [`SECURITY.md`](./SECURITY.md)). > This is a map for contributors and reviewers: **the security-sensitive surface of the app > lives at its entry points** — the places where untrusted input, credentials, or shell/SSH > execution cross a trust boundary. If you're auditing a change, touching one of these, or > reviewing a PR, start here. Everything else is downstream of these doors. The app is a monorepo: `apps/api` (Hono backend — the real trust boundary), `apps/dashboard` (Next.js UI), `apps/cli` + `apps/desktop` (self-host launchers), `packages/adapters` (SSH / runtime / remote execution), `packages/{core,db}`. **Almost all security-relevant code is in `apps/api` and `packages/adapters`.** The dashboard is a client — never trust it for enforcement; the API must re-check everything. --- ## The two golden invariants Real RCE / takeover bugs in this codebase came from violating these. Hold them sacred: 1. **Shell-quote every remote-exec argument.** Never interpolate a user/DB value into a command string or a heredoc. Use the single-quote escaper `sq()` (`packages/adapters/src/system/local-shell.ts`). Applies to every command sent over SSH. 2. **`zeroAuthAllowed()` is the single zero-auth gate.** Desktop can run without a login; every other target must not. There is exactly one function that decides this (`apps/api/src/middleware/auth.ts`, mirrored by `middleware/zero-auth-guard.ts`). Do not add a second "skip auth" path. Supporting invariants: SSH keys are `0600`; `known_hosts` is pinned with `StrictHostKeyChecking=yes` (**never** `accept-new`) and never logged; credentials are encrypted at rest and **never returned to the client**; auto-registered GitHub deploy keys are read-only and revocable; instance-global jobs must authorize the server ids they act on. --- ## 1. Public HTTP entry + auth — `apps/api/src/middleware/` The front door. Every request passes through here before it reaches a route. | File | What it guards | |---|---| | `auth.ts` | Session/token auth; **`zeroAuthAllowed()`** — the one zero-auth gate. | | `zero-auth-guard.ts` | Enforces the above per-route. | | `internal-auth.ts` | `INTERNAL_TOKEN` for machine-to-machine / bootstrap calls (CLI admin bootstrap). | | `mcp-consent.ts` | OAuth/consent for MCP clients (preserves `client_id`, `redirect_uri`, `code_challenge`, `state`). | | `origin-guard.ts`, `loopback-peer.ts`, `local-only.ts` | Origin/host/loopback restrictions (desktop-login redirect alignment lives near here). | | `client-ip.ts` | Trusted client-IP resolution (XFF is trusted only behind the OpenResty edge). | | `rate-limiter.ts` | Global rate limiting (policies in `apps/api/src/lib/rate-limit/`). | | `active-organization.ts`, `migration-guard.ts`, `better-auth-shield.ts` | Org scoping + Better-Auth hardening. | **Enforcement primitives** (`apps/api/src/lib/`): `secure-router.ts` (every route declares a permission `tag`), `permission.ts` + `route-permission.ts` (RBAC checks), `auth-mode.ts` (cloud vs self-hosted mode), `auth.ts` (Better-Auth config; `BETTER_AUTH_SECRET`). **Rule:** a route with no permission tag on `secure-router` is a bug. The dashboard hiding a button is not a control — the API tag is. ## 2. Webhooks — `apps/api/src/modules/{github,billing}/` Unauthenticated-by-URL entry points; they authenticate by **HMAC signature over the raw body** and dedupe by delivery id. Verify signature *before* parsing. - `github/github.webhook.ts` (+ `webhook-shared.ts`, `webhook-installation.ts`, `webhook-event-prune-schedule.ts`) — push → auto-deploy; delivery-id idempotency; cloud webhook forwarding. - `billing/oblien-webhook.controller.ts` + `oblien-webhook-crypto.ts` — Oblien billing webhook; `X-Webhook-Signature` = HMAC of the **raw** request body (not the parsed JSON). - `modules/webhooks/` — per-project backup/deploy trigger webhooks (token-scoped). **Rule:** never trust a webhook payload until the signature check passes; never dedupe on content when a delivery id exists. ## 3. Remote execution over SSH — `packages/adapters/src/system/` + `apps/api/src/lib/ssh-manager.ts` The highest-blast-radius surface: these run commands **as root on the user's servers**. - `system/executor.ts`, `ssh-executor.ts`, `system-ssh-executor.ts` — the SSH command channel. `local-shell.ts` holds `sq()` (see invariant #1). - `system/elevated-executor.ts` — `sudo -n` elevation for non-root SSH users (component installs). Wraps commands via `sq()`; never blanket-`sudo -E`. - `system/installer.ts`, `environment.ts`, `catalog.ts` — component install recipes (apt/ systemctl/writes under `/etc`). Every privileged command goes through elevation. - `system/edge-takeover.ts`, `edge-preflight.ts` — port 80/443 takeover; consent-gated, never blind-kills a foreign proxy. - `apps/api/src/lib/ssh-manager.ts` — builds the SSH config, resolves the key path (`ssh-key-path.ts` — traversal-checked allowlist), pins `known_hosts`. **Rule:** any new remote command must (a) be `sq()`-quoted, (b) go through an executor, not a hand-built `ssh` string, and (c) never echo a secret into the command line or logs. ## 4. Git clone + credential forwarding — `apps/api/src/lib/git-forwarding/` + `modules/deployments/` How private repos are cloned on a build host without persisting a token. - `lib/git-forwarding/relay.ts` — the SSH reverse-tunnel credential relay (desktop only, repo-pinned, streamed for the clone, **never written to disk**). - `modules/deployments/clone-plan.ts` — decides where the clone runs (api-host vs server) and which credential (relay / App / PAT / per-server); the single source both preflight and the build pipeline read. - `modules/deployments/build-pipeline.ts`, `build.service.ts` — the build; graceful degrade to an api-host clone when no credential reaches the server. - `modules/github/server-github.service.ts` + `packages/adapters/src/runtime/git-clone*.ts` — per-server GitHub credentials (encrypted; device-flow / PAT / SSH key / read-only deploy keys). Disconnect **hard-deletes** the stored token and revokes deploy keys. ## 5. Mail server (provisioning) — `apps/api/src/modules/mail/` Provisions a full mail stack (iRedMail) over SSH — heavy root-level remote work. - `mail.service.ts` — the provisioning flow (apt, systemctl, `/etc/hosts`, DKIM, certbot). The only lateral privilege drop is `sudo -u postgres` for the mail DB. - `mail-credentials.service.ts`, `admin/psql-runner.ts` — mailbox credential handling (Postgres via `sudo -u postgres psql`; shell-quote the SQL args). - `mail-state.ts` — provisioning state (`mail-state.json`); branding lives on disk, not SQLite. - `admin/` — the in-app mail admin (replaces iRedAdmin via SSH + psql). Treat every psql arg as untrusted input. ## 6. Mail client / webmail — `apps/api/src/modules/mail/webmail/` Deploys the Zero webmail UI (self-host stack, or pointed at an external IMAP/SMTP backend). - `webmail/` — deploy flow (behind the OpenResty edge; XFF trusted there). External-backend connect pastes SMTP/IMAP credentials → encrypted at rest (see §8), never re-shown. - Instance SMTP transport (system mail: password resets, invites) — `apps/api/src/modules/system/setup.controller.ts` (`/system/settings/email`); password encrypted server-side, blank on read. ## 7. Cloud / multi-tenant boundary — `apps/api/src/lib/cloud-auth-proxy.ts`, `cloud-route.service.ts` When `CLOUD_MODE` is set the API is a multi-tenant SaaS gateway. Several self-hosted-only paths (the `gh` CLI, device flow, local file reads) are **hard-floored off** here — see the `env.CLOUD_MODE` early-returns in `apps/api/src/modules/github/github.local-auth.ts`. **Rule:** anything that shells out locally or reads the host filesystem must check `env.CLOUD_MODE` first. Cloud projects are canonical on the SaaS; the local API proxies. ## 8. Secrets at rest — `apps/api/src/lib/encryption.ts` + `credential-encryption.ts` One envelope for every stored secret (SSH creds, GitHub tokens, S3/SFTP backup creds, project env vars, SMTP passwords). - AES-256-GCM, key = `SHA-256(BETTER_AUTH_SECRET)`. Ciphertext is tagged `enc1:` so plaintext vs encrypted is explicit (no silent decrypt-fallback). - `BETTER_AUTH_SECRET` defaults to a placeholder only on `target=local`; a deployable target **refuses to boot** on the placeholder (`apps/api/src/config/env.ts`). The CLI auto-generates a per-install secret (`~/.openship/auth-secret`, `0600`). **Rule:** serialized/API-returned objects expose only `hasX` flags, never ciphertext or plaintext. Losing the secret makes every stored credential undecryptable — treat it as such. ## 9. Domains, SSL & the edge — `apps/api/src/lib/{domain-ssl,routing-domains,cloud-route}.ts` + `packages/adapters/src/infra/` - Custom-domain verify → DNS-records → SSL (certbot) pipeline; hostname normalization is cross-tenant-safe; certbot gated on verification. - OpenResty routing writes + reloads (`packages/adapters/src/infra/nginx.ts`, `openresty-lua.ts`) and the per-route Lua rules engine (rate-limit / ban / country / UA) — DB is source of truth, pushed to a shared dict. ## 10. Tokens, PAT & MCP scopes — `apps/api/src/modules/{auth,permissions}/` - PAT / MCP token scopes and grants; the "projects it creates" scope is a `{project,"*",[create]}` grant (wildcard-project must be create-only, enforced at mint). - List endpoints must filter to the token's grants (isolation), not just check the action. --- ## Reviewer checklist - New route? → has a permission `tag` on `secure-router`, and re-checks org scope. - New remote command? → `sq()`-quoted, via an executor, no secret in argv/logs. - New webhook? → HMAC-verified against the **raw** body before parsing; delivery-id deduped. - New stored secret? → goes through `encryptSecretField`; only `hasX` leaves the API. - Touches auth skipping? → routes through `zeroAuthAllowed()`, nowhere else. - Runs anything locally / reads the host FS? → guarded by `env.CLOUD_MODE`. - Deletes/overwrites a credential? → confirm it's a hard delete + external revoke where relevant.