# Security model ## Threat surface The pulsar service accepts fetch requests from authenticated SGApps users and forwards them to Android devices on the user's own fleet. The server itself does NOT make outbound HTTP requests on the user's behalf — all outbound traffic flows from the user's own agents. This removes a whole class of SSRF-via-sgapps.io risks: a malicious payload cannot pivot through sgapps.io into Anthropic's infrastructure, only through the user's own devices. Residual attack surface: 1. **SSRF via the agent** — a user crafting `url: http://192.168.1.1/` expects us to proxy them into their own LAN. We allow it only with the explicit `allowPrivateIP: true` flag AND an agent that opted into executing such jobs (`capabilities.privateIP === 'accept'`). 2. **Cert-override abuse** — `allowInsecureTLS: true` lets a user force-trust self-signed certs. Same two-flag gate. 3. **Tenant leakage** — a request from user X landing on user Y's device would expose Y's LAN to X. Prevented by the ownership check: job is dispatched only to devices with X in `owners[].userId`. 4. **Instance metadata** — `169.254.169.254` and friends are always blocked, no override. 5. **Agent impersonation** — someone with a BID that is not on the owner's approved list cannot run jobs. The agent is identified by its `bid` alone (no tokens — see "Approval lifecycle" below); the `pulsarApprovedDevices` row acts as the single source of truth for which BIDs may claim jobs on behalf of which owners. ## SSRF classifier Server-side (`services/pulsar/lib/ssrf.js`) classifies the request URL BEFORE inserting a job row: | Verdict | Example | Behavior | |---|---|---| | `ok` | `https://example.com/data.json` | dispatched | | `private-ip` | `http://192.168.1.42/api`, `http://localhost/`, `http://10.0.0.1/` | **rejected 400** unless `allowPrivateIP: true` | | `bad-url` | `file:///etc/passwd`, `http://169.254.169.254/`, malformed | **rejected 400, no override possible** | Private IPv4 ranges checked: - `10.0.0.0/8` - `127.0.0.0/8` (loopback) - `169.254.0.0/16` (link-local; includes cloud instance metadata) - `172.16.0.0/12` - `192.168.0.0/16` - `100.64.0.0/10` (CGNAT) - `0.0.0.0/8` and `224.0.0.0/3` (multicast + reserved) Private IPv6 ranges checked: - `::1` (loopback) - `::` (unspecified) - `fc00::/7` (ULA) - `fe80::/10` (link-local) - `::ffff:x.x.x.x` v4-mapped — re-classified as IPv4 Hostname `localhost` / `localhost.localdomain` are treated as `private-ip` as a defense-in-depth measure (DNS could still resolve them to a public IP, but the intent is clearly local). ### Not yet enforced (future work) - DNS pinning: the agent resolves the hostname once and reuses the IP for the lifetime of the request (defense against DNS rebinding). Implemented in the agent's native code when ported; browser `fetch()` does not expose DNS control, so the WebView agent is weaker here. - Allow-list override per owner tier (PN1-adjacent — not shipped). ## Header sanitization Server-side strips any header name in the browser-forbidden list before the row is inserted: ``` Accept-Charset, Accept-Encoding, Access-Control-Request-*, Connection, Content-Length, Cookie, Cookie2, Date, DNT, Expect, Host, Keep-Alive, Origin, Referer, TE, Trailer, Transfer-Encoding, Upgrade, Via ``` Also drops any header value containing a CR or LF (CRLF injection prevention). ## Approval lifecycle (D13 — allowlist model) Pulsar does NOT use HMAC-signed agent tokens. The server identifies each agent only by its `bid` (buildIdentifier) and resolves ownership through the `pulsarApprovedDevices` collection. There is no token to rotate, revoke, or leak — a device either appears in the owner's approved list and can claim jobs, or it does not and gets refused. Collections involved: | Collection | Role | TTL | |---|---|---| | `pulsarApprovedDevices` | one row per `{ownerUserId, bid}` tuple; a BID can be approved for multiple owners | optional per-row expiry | | `pulsarPendingDevices` | one row per unknown BID that attempted a `/session`; waiting for an owner to approve | 30 min | | `pulsarAgents` | one row per active agent session (TTL 24h via `expiresAt`, refreshed on each `/claim`) | 24 h | ### Approval flow 1. A new device opens `/pulsar/agent/ui` for the first time. 2. The agent `/session` call looks up the BID in `pulsarApprovedDevices`. Not found → the server inserts into `pulsarPendingDevices` and returns `403 device-pending`. 3. An owner, from the pulsar webapp's **Devices** tab, sees the pending row and approves it. The approval: - inserts into `pulsarApprovedDevices` with `{ownerUserId, bid, approvedByUserId, approvedAt, label?, accountName?}`, - deletes the pending row (TTL would clean it up anyway). 4. The agent retries `/session` and now gets `200 ok`. ### Revocation - Owner deletes an approved row from **Devices** tab. - The agent's next `/claim` returns `403 device-not-approved`; its live session row ages out via TTL (max 24 h). - No server-side purge is needed — the device simply stops receiving jobs. ### Why no token - A token adds a rotation surface, a secret-store surface, and a distribution surface, all for proving `bid`-ownership. The approval row already pins `{ownerUserId, bid}` in Mongo. The token was belt-and-suspenders. - Agent authors (whether SGApps or operator) write less code: no token refresh loop, no re-sign on expiry, no "token revoked" edge case. - Operator UX gets simpler: a single Devices table replaces "rotate tokens" + "re-attach preset" ops. Consumer auth = standard SGApps users service (cookie session or bearer, future API key via M10.g). No pulsar-specific auth tier. ## Billing counters The `androidBridge.accountStats` collection carries pulsar lifetime counters per user (`pulsarJobsTotal`, `pulsarBytesInTotal`, …). It remains in place for backwards compatibility; a nightly rollup (M8.c) will consolidate these into `pulsarUsageDaily` per `{userId, date}` for per-day reporting and future billing hooks. Charges against the user's wallet (soney) are **NOT** emitted by pulsar directly. The project-wide wallet (M10) + biller cron (M11) own money math. Pulsar's job is to count units of work; the biller converts them into soney via a pricing map. Rationale and model documented in `TODOs/proxy-pulsar/impl-plan/00-WORKLOG.md` D17-D20. Any service with db-connection access can read `accountStats` and `pulsarUsageDaily`. Treat them as internal data; a user viewing their own stats via `GET /pulsar/api/stats` or `/usage` is fine, cross-user visibility is not. ## What pulsar does NOT do - No HTTPS cert validation on sgapps.io side ever gets skipped — the consumer connection to `/pulsar/api/*` is always validated by sgapps.io's own cert chain. - No credential forwarding — `Cookie` is in the forbidden list; if a user wants to pass auth to upstream, they put it in `Authorization`. - No streaming — bodies are buffered, cropped, and returned atomically. A caller wanting HLS / SSE should request the response once per chunk and manage the state itself.