diff options
| author | Tianren Dong <tianrendong.dtr@gmail.com> | 2026-05-16 11:55:27 -0400 |
|---|---|---|
| committer | Tianren Dong <tianrendong.dtr@gmail.com> | 2026-05-16 11:55:27 -0400 |
| commit | ad78b41140f9040094295a28c5ab6482483efa4f (patch) | |
| tree | ba36801d51069779d76fe12ed94c9e73c343cdaa /SECURITY.md | |
| parent | 7992115c6dd9884e1a1f2992db531342d2916fcd (diff) | |
0.16.0: mandatory pairing + signed envelopes + cross-process state coherence
Major security hardening of the Chrome bridge driven by red-team findings.
- Mandatory pairing via /chrome onboard (one-time per machine). Bridge pins exact
chrome-extension:// origin and derives HMAC keys via HKDF. Until paired, the
bridge returns idle responses and refuses to deliver commands.
- Signed envelopes on /next, /result, /command. HMAC-SHA256 over protocol version,
direction, method, path, extension ID, bridge ID, timestamp, fresh nonce, and
SHA-256 of the body. +/-30s clock window; 5-min LRU nonce replay defense on
both request and response directions; constant-time MAC compare.
- Peer Pi sessions sharing the bridge sign each /command with a broker HKDF key
(no bearer token transmitted) and verify the owner's signed response.
- Global loopback enforcement. PI_CHROME_BRIDGE_HOST=0.0.0.0 ignored unless
PI_CHROME_BRIDGE_DANGEROUS_REMOTE=1; loopback check still applies per-endpoint.
- Cross-process state coherence: fs.watch + per-request mtime check on
~/.config/pi/chrome-bridge.json. A peer Pi's unpair (or any external rm/edit)
propagates to the owner's in-memory state before the next privileged verify;
replay caches reset on key rotation.
- Body cap 1 MiB -> 413. Queue/pending/waiter caps. Malformed JSON -> 400.
Non-object JSON bodies -> 400 instead of being trusted.
- page.navigate initScript: try/finally around tabs.update, per-call onCommitted
listener with bounded commit-wait window, one-shot delete in global onCommitted.
No more stale init scripts firing on later unrelated navigations.
- crypto.randomUUID() command IDs.
- Manifest diet (dropped activeTab); added popup.html/popup.js for the pair UI.
UX:
- /chrome onboard runs install + pair in one interactive flow.
- /chrome help reorganized into First-time / Day-to-day / Maintenance.
- Status bar lights up as green dot only when authorized.
- Early console/network capture at document_start so boot-time React errors and
initial API calls aren't dropped (ported from DaniBedz/pi-chrome fork).
Docs:
- SECURITY.md threat model rewritten with in-scope / out-of-scope sections.
- README rewritten with the install-then-pair flow front and center.
- CHANGELOG 0.16.0 entry with full patch list.
Diffstat (limited to 'SECURITY.md')
| -rw-r--r-- | SECURITY.md | 38 |
1 files changed, 30 insertions, 8 deletions
diff --git a/SECURITY.md b/SECURITY.md index ffc2c06..943352b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -6,17 +6,39 @@ Open a GitHub issue prefixed with `[security]` at https://github.com/tianrendong ## Threat model -`pi-chrome` is a developer tool you install knowingly. It is **not** designed to defend against: +`pi-chrome` is a developer tool you install knowingly. -- Hostile pages running in your Chrome trying to detect or escape automation. (Standard browser security boundaries still apply, but a hostile page that already runs in your tab can do anything that page can already do.) -- Other processes on your local machine. The bridge binds to `127.0.0.1:17318` (loopback only) and chrome_* tools require `/chrome authorize` inside Pi, but the bridge does not authenticate arbitrary non-browser local callers. If your threat model includes hostile local processes running as you, run pi-chrome on a separate user account. +### In scope (designed to defend against) -`pi-chrome` **is** designed to: +- **Ordinary web pages.** The bridge rejects browser-origin command requests; CORS cannot be used to drive Chrome. +- **Drive-by local processes / curl without the pairing secret.** Every privileged endpoint (`/next`, `/result`, `/command`) requires a signed envelope with an HMAC key established during explicit pairing. A random local process that has not been paired cannot poll commands, post results, or invoke `/command`. +- **Malicious Chrome extensions on the same browser.** The bridge pins the exact `chrome-extension://<id>` origin established at pairing time. Any other extension polling `/next` receives only an unsigned idle response (`type:"none"` + `needsPairing:true`); it never sees queued commands. An attacker who somehow learns a command ID still cannot forge a `/result` without the extension's HMAC key. The unsigned idle path exists purely so older or unrelated extensions can read the `x-pi-chrome-version` header and trigger the auto-reload migration path. +- **Pre-bind localhost impostors.** Pairing establishes mutual HMAC keys before any command is delivered. The Pi peer that talks to the bridge owner via `/command` signs every request and verifies the owner's signed response, so a process that binds `:17318` first cannot impersonate either side without the keys. +- **Replay / interleaving.** Every signed envelope binds protocol version, direction, method, path, extension ID, bridge ID, timestamp, fresh nonce, and SHA-256 of the body. Timestamps must be within ±30 s and nonces are rejected on reuse. +- **`/chrome authorize` bypass.** Even an attacker who can reach the bridge cannot drive Chrome from chrome_* tools unless the current Pi session also authorized control. -- Never exfiltrate page state to the network. All communication is loopback (`127.0.0.1`). -- Surface every action with an honest result envelope so the agent can't silently do the wrong thing. -- Keep Chrome control locked until the user explicitly runs `/chrome authorize` in the current Pi session. -- Reject browser-origin command requests to the loopback bridge so ordinary web pages cannot use CORS to drive Chrome. +### Out of scope (cannot defend against) + +- **Same-user malware.** A process running under your user account can read `~/.config/pi/chrome-bridge.json` (mode 0600), replace the Chrome extension, attach a debugger to Pi or Chrome, or use OS automation APIs. If your threat model includes hostile local processes running as you, isolate pi-chrome on a separate user account or VM. +- **Hostile pages running in your tabs.** Standard browser security boundaries apply. A page that already runs in a tab can read its own DOM. MAIN-world helpers (`window.__PI_CHROME_STATE__`) are page-tamperable; treat snapshot UIDs as advisory for dangerous actions on untrusted origins. +- **Network adversaries.** All communication is loopback. There is no network path to defend. + +### Properties (always-on) + +- **Loopback only.** Every endpoint rejects non-loopback `remoteAddress`. `PI_CHROME_BRIDGE_HOST=0.0.0.0` is ignored unless `PI_CHROME_BRIDGE_DANGEROUS_REMOTE=1` is set, and even then the per-endpoint loopback check still rejects remote callers. +- **Resource caps.** 1 MiB request body cap (413), 256 queued/pending commands, 4 concurrent long-pollers. +- **No telemetry.** All state stays on disk under your config dir or in the extension's `chrome.storage.local`. +- **Fail-closed.** Missing or invalid auth headers, expired timestamps, replayed nonces, and origin mismatches all return errors with no side effects. + +## Pairing flow + +1. Run `/chrome pair` in Pi. Pi generates a 32-byte invite, prints `pcp_<base64url>` (also copied to clipboard on macOS), and arms `/pair` for 10 minutes. +2. Open the **Pi Chrome Connector** extension popup in Chrome, paste the invite, click *Pair*. +3. The extension signs its identity with the invite key. The bridge verifies, derives two HKDF keys (one for extension↔bridge `/next`/`/result`, one for peer Pi↔owner Pi `/command`), pins the exact extension ID, and returns *only* the `extensionPairKey` over a response signed with the invite secret (so the extension proves it's talking to the same Pi process that issued the invite). The `brokerKey` stays Pi-side, persisted in `~/.config/pi/chrome-bridge.json`, and is never sent to the browser. +4. Both sides persist their copies: Pi at `~/.config/pi/chrome-bridge.json` mode 0600; extension in `chrome.storage.local`. +5. The invite is destroyed. + +Run `/chrome unpair` to clear pairing keys; every Pi session and the Chrome extension popup will then need to re-pair. ## The companion extension |
