aboutsummaryrefslogtreecommitdiff
path: root/scripts
diff options
context:
space:
mode:
authorTianren Dong <tianrendong.dtr@gmail.com>2026-05-16 11:55:27 -0400
committerTianren Dong <tianrendong.dtr@gmail.com>2026-05-16 11:55:27 -0400
commitad78b41140f9040094295a28c5ab6482483efa4f (patch)
treeba36801d51069779d76fe12ed94c9e73c343cdaa /scripts
parent7992115c6dd9884e1a1f2992db531342d2916fcd (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 'scripts')
-rwxr-xr-xscripts/security-smoke.sh98
1 files changed, 98 insertions, 0 deletions
diff --git a/scripts/security-smoke.sh b/scripts/security-smoke.sh
new file mode 100755
index 0000000..a8cabbd
--- /dev/null
+++ b/scripts/security-smoke.sh
@@ -0,0 +1,98 @@
+#!/usr/bin/env bash
+#
+# Negative-path smoke tests for the hardened pi-chrome bridge.
+#
+# Run against a live bridge (Pi session must be running with this pi-chrome version).
+# These tests do NOT require an extension to be paired; they assert that unauthenticated
+# callers cannot drive Chrome through the bridge.
+#
+# usage: ./scripts/security-smoke.sh
+#
+# Each test is independent. Failures print FAIL and a non-zero exit at the end.
+
+set -u
+
+BRIDGE_URL="${PI_CHROME_BRIDGE_URL:-http://127.0.0.1:17318}"
+FAILED=0
+pass() { echo "PASS $1"; }
+fail() { echo "FAIL $1 (got: $2)"; FAILED=$((FAILED+1)); }
+
+check_status() {
+ local desc="$1" expected="$2" actual="$3"
+ if [[ "$actual" == "$expected" ]]; then pass "$desc"; else fail "$desc — expected HTTP $expected" "$actual"; fi
+}
+
+http_status() {
+ curl -sS -o /dev/null -w "%{http_code}" "$@"
+}
+
+echo "Bridge URL: $BRIDGE_URL"
+
+# 1. /command without auth header -> 401 (broker auth required)
+status=$(http_status -X POST "$BRIDGE_URL/command" -H 'content-type: application/json' --data '{"action":"tab.list","params":{}}')
+check_status "/command without auth -> 401" 401 "$status"
+
+# 2. /next from random chrome-extension origin returns 200 idle and never delivers a command.
+# Until the bridge is paired, the response includes {"needsPairing":true}. After pairing,
+# a non-pinned origin still gets the same idle payload (never the queued command). Either
+# way: type=none, no commands.
+body=$(curl -sS -H 'Origin: chrome-extension://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' "$BRIDGE_URL/next?name=smoke")
+if echo "$body" | grep -q '"type":"none"' && ! echo "$body" | grep -q '"type":"command"'; then
+ pass "/next from unpinned chrome-extension origin is idle, never command"
+else
+ fail "/next from unpinned chrome-extension origin" "$body"
+fi
+
+# 3. /next with wrong-shaped Origin (real web origin) -> 403
+status=$(http_status -H 'Origin: https://example.com' "$BRIDGE_URL/next")
+check_status "/next from web origin -> 403" 403 "$status"
+
+# 4. Oversized body to /command -> 413. Stream via stdin so argv stays sane on macOS.
+status=$(head -c $((2*1024*1024)) /dev/zero | tr '\0' 'x' \
+ | curl -sS -o /dev/null -w "%{http_code}" -X POST "$BRIDGE_URL/command" \
+ -H 'content-type: application/json' --data-binary @-)
+check_status "/command oversized body -> 413" 413 "$status"
+
+# 5. Malformed JSON to /result without auth: must be rejected (401/403) and must not crash
+# the server. Verify by hitting /status afterwards.
+status=$(http_status -X POST "$BRIDGE_URL/result" \
+ -H 'content-type: application/json' \
+ -H 'Origin: chrome-extension://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' \
+ --data 'not json')
+case "$status" in
+ 401|403) pass "/result malformed JSON without auth rejected ($status)";;
+ *) fail "/result malformed JSON without auth" "$status";;
+esac
+# Confirm server stayed responsive after the parse-error attempt.
+status=$(http_status "$BRIDGE_URL/status")
+check_status "/status still responsive after malformed POST" 200 "$status"
+
+# 6. /status is OK (loopback only, no secrets) -> 200
+status=$(http_status "$BRIDGE_URL/status")
+check_status "/status -> 200" 200 "$status"
+
+# 7. /command with bogus auth header -> 401
+status=$(http_status -X POST "$BRIDGE_URL/command" \
+ -H 'content-type: application/json' \
+ -H 'x-pi-chrome-auth: v1 ts=1 nonce=AAAA sig=AAAA' \
+ --data '{"action":"tab.list","params":{}}')
+check_status "/command with bogus auth -> 401" 401 "$status"
+
+# 8. /pair without active pairing window -> 403
+status=$(http_status -X POST "$BRIDGE_URL/pair" \
+ -H 'content-type: application/json' \
+ -H 'Origin: chrome-extension://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' \
+ --data '{"extensionId":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","extensionNonce":"AAAA","mac":"AAAA"}')
+case "$status" in
+ 401|403) pass "/pair without active window rejected ($status)";;
+ *) fail "/pair without active window" "$status";;
+esac
+
+echo
+if [[ $FAILED -eq 0 ]]; then
+ echo "ALL SMOKE TESTS PASSED"
+ exit 0
+else
+ echo "$FAILED test(s) failed"
+ exit 1
+fi