Skip to content

Integrate reviewed open PRs, patch Dependabot alerts, block browser attacks on bridge - #25

Merged
vitalio-sh merged 9 commits into
mainfrom
integrate/open-prs
Sep 14, 2026
Merged

vitalio-sh merged 9 commits into
mainfrom
integrate/open-prs

Conversation

@vitalio-sh

@vitalio-sh vitalio-sh commented Sep 14, 2026 •

Copy link
Copy Markdown
Owner

Integrates the reviewed parts of all open PRs, patches every open Dependabot alert, and fixes browser-reachable vulnerabilities in the bridge that none of the PRs addressed.

Security

  • Browser attacks on the localhost bridge (new). Verified against main:

    • Any web page could make a blind cross-origin POST /compose with send: true, and it reached Thunderbird.
    • A page could open ws://127.0.0.1:7701 and replace the extension socket, then receive every request and return forged results.
    • DNS rebinding allowed reading mail.

    The bridge now rejects HTTP Origins outside TB_BRIDGE_CORS_ORIGINS, non-local Host headers (extend with TB_BRIDGE_ALLOWED_HOSTS), and WebSocket handshakes from web origins. tb, tb-mcp, curl and host.docker.internal are unaffected.

  • Auth token (Enforce TB_AUTH_TOKEN on the bridge HTTP listener #21). Optional TB_AUTH_TOKEN on the bridge, taken as-is.

  • Dependencies. One update to the current patched releases: ws, adm-zip, hono, @hono/node-server, fast-uri, qs, body-parser, ip-address, express-rate-limit. npm audit: 9 findings (5 high) → 0.

Fixes and improvements

Not taken from #20

  • 30s folder-info cache: stale unread counts right after mark-read.
  • Per-call /bridge/status preflight: the bridge already answers 503 immediately when the extension is disconnected.
  • SEARCH_UNHEALTHY/LIST_UNHEALTHY codes: they change the error contract, and the docs contradicted the code.
  • Error mapping that dropped the bridge's code: it would hide AUTH_REQUIRED.
  • Cleanup items: the /debug/compose-capabilities endpoint, the 15s resume-poll timer, and docs/ENERGY_AUDIT_MACOS25.md (AI-generated and already out of date).

Supersedes #1, #4, #6, #7, #13, #14, #17, #19, #20, #21. Contributors are credited via Co-authored-by.

Testing

  • Suites: npm test (46), test:mcp (37), test:bridge-auth (15), new test:bridge-security (27, real bridge process), new test:extension (34 + 39, real background scripts in a vm with a mocked messenger API). All pass; the new security and extension suites fail against main. CI runs the new suites.
  • End-to-end: real tb CLI → real bridge → real background.js over a WebSocket, with and without TB_AUTH_TOKEN.
  • Build: XPI builds with adm-zip 0.6.1 and its entries match the sources byte-for-byte.
  • Not yet done: testing in a live Thunderbird. The extension needs a version bump and a new ATN-signed XPI before release.

🤖 Generated with Claude Code

https://claude.ai/code/session_01RmV9wYm7QYGHPZ4YdS66Rw

vitalio-sh and others added 9 commits September 14, 2026 06:49
Supersedes dependabot PRs #1, #6, #7, #13, #14, #17 and #19, which were
opened against versions that have since received further advisories
(fast-uri, hono, qs, ip-address, adm-zip). Bumps each vulnerable package
to its current patched release instead:

- ws 8.20.0 -> 8.21.3 (direct; bridge range raised to ^8.21.3 so
  npm consumers of tb-bridge also get the fix)
- adm-zip 0.5.17 -> 0.6.1 (dev; XPI build output verified byte-identical)
- hono 4.12.12 -> 4.13.7, @hono/node-server 1.19.13 -> 1.19.17
- fast-uri 3.1.0 -> 3.1.7, qs 6.15.0 -> 6.16.0, body-parser 2.2.2 -> 2.3.0
- ip-address 10.1.0 -> 10.7.0, express-rate-limit 8.3.2 -> 8.7.0

npm audit: 9 vulnerabilities (5 high) -> 0. All tarball integrity hashes
resolve from registry.npmjs.org; none of the updated packages declare
install scripts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019uoUWTHVsDJpMJCVNFZz5L
The bridge binds to 127.0.0.1, which prevents remote access but is not
access control: every process running as the same OS user can call it and
read the whole mailbox, whether or not it was wired up to the CLI.

SPEC.md already documented TB_AUTH_TOKEN and both clients already send it
as `Authorization: Bearer <token>`. The daemon never checked it, so the
roadmap item "Auth token support (bridge + CLI)" was only half done.

The bridge now compares the header against TB_AUTH_TOKEN in constant time
and answers 401 when it is missing or wrong. Behaviour is unchanged when
the variable is unset, so the default zero-config setup still works.

Two deliberate choices:

- An empty TB_AUTH_TOKEN aborts startup rather than disabling auth.
  Failing open would leave the bridge unauthenticated with nothing in the
  log to say so.
- The startup banner reports whether auth is enabled, so the state of the
  control is visible without probing for it.

The 401 body carries code AUTH_REQUIRED because clients map an
unrecognised 4xx to THUNDERBIRD_ERROR, which would misreport a missing
token as a Thunderbird fault.

Scope is the HTTP listener only. The WebSocket listener on :7701 that the
extension connects to still accepts any local connection; closing that
needs a change on the extension side and is left for separate work.

Tests spawn the real bridge process rather than a mock.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…; add heartbeat

A web page in the user's browser could drive the localhost bridge:

- Blind cross-origin POSTs executed even though CORS hid the response
  (verified: a text/plain POST /compose {send:true} from
  Origin: https://evil.example reached the extension).
- DNS rebinding let a hostile domain read responses same-origin.
- Any page could open ws://127.0.0.1:7701 and replace the extension
  socket, receiving every CLI request and answering with forged data.

The bridge now rejects HTTP requests with an Origin outside
TB_BRIDGE_CORS_ORIGINS, Host headers that are not an IP literal,
localhost, *.localhost, *.internal (host.docker.internal) or listed in
TB_BRIDGE_ALLOWED_HOSTS, and WebSocket handshakes from http(s)/file/null
origins. tb, tb-mcp and curl send no Origin header and are unaffected;
the extension connects from moz-extension://.

The CORS allowlist and WebSocket ping/pong heartbeat (drops sockets that
stop answering, e.g. after sleep) come from PR #20.

test/bridge-security.mjs runs the real bridge: 27 checks, 16 of which
fail against the previous bridge.

Co-authored-by: le-dawg <3172733+le-dawg@users.noreply.github.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019uoUWTHVsDJpMJCVNFZz5L
… bounded parallelism

Integrates the extension parts of PR #4 and PR #20, with fixes:

Thread (from #4)
- Parse References / In-Reply-To / Message-ID from getRaw() (getFull()
  doesn't expose them) and strip angle brackets before
  messages.query({ headerMessageId }) — previously `tb thread` always
  returned an empty list. getFull() fallback now strips brackets too.
- Downstream replies found by subject are limited to exact normalized
  subject matches (Thunderbird's query is substring), exclude junk, and
  are labelled threadMatch: "subject" vs "references".
- thread-utils.js is loaded by the manifest and is the single source for
  the parsing helpers instead of a test-only copy.

Recent / search (from #4)
- /recent honours accountId, and now unreadOnly as well; the CLI sent
  both but the extension ignored them.
- /messages/search accepts headerMessageId.

Connection (from #20, reworked)
- Reconnect backoff 3s → 6s → 12s → 15s cap (PR used 60s, which would
  leave a restarted bridge waiting up to a minute), reset on connect,
  immediate retry on idle → active ("idle" permission).
- Socket handlers are bound to their own socket, so a late close from a
  replaced socket can't null the live one (main crashed on ws.send).

Throughput (from #20)
- read-batch, fetch, bulk tag/fetch and thread lookups run through a
  shared queue capped at 8 concurrent operations, preserving order.
- Attachment base64 encoded in 32 KB chunks instead of byte by byte.

Not taken from #20: the 30s folder-info cache (stale unread counts
right after mark-read; Thunderbird is the source of truth), the
/debug/compose-capabilities endpoint, and the 15s resume-poll interval.

test/extension.test.mjs loads the real background scripts in a vm with a
mocked messenger API (39 checks); against the previous extension the
thread, recent, search and reconnect checks fail.

Co-authored-by: Daniel Böckenhoff <dboe@ipp.mpg.de>
Co-authored-by: le-dawg <3172733+le-dawg@users.noreply.github.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019uoUWTHVsDJpMJCVNFZz5L
…egration

email_search no longer requires `query` when at least one filter is
given (from PR #20); with neither it fails with INVALID_ARGS instead of
running an unbounded search.

Not taken from PR #20's client changes: the per-call /bridge/status
preflight (the bridge already answers 503 immediately when the extension
is disconnected), the new SEARCH_UNHEALTHY / LIST_UNHEALTHY codes and
`timeout !== 30000` override heuristic (error-code contract change whose
docs disagreed with the code), and error mapping that dropped the
bridge's own `code` (it would have hidden AUTH_REQUIRED from #21).

Co-authored-by: le-dawg <3172733+le-dawg@users.noreply.github.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019uoUWTHVsDJpMJCVNFZz5L
7700 is commonly taken on developer machines — e.g. VS Code auto-forwards
it from dev containers, which shadowed the real bridge on the host with
EADDRINUSE. Defaults now live on an uncommon pair below the macOS/Linux
ephemeral range.

Updated: bridge defaults and usage text, extension WS_URL, CLI and MCP
client defaults, Dockerfile, .env.example, cli/config.example.json,
server.json, docs, skill, architecture.svg. CHANGELOG history entries
and the raster assets (architecture.png, social-preview.png, demo.gif)
still show the old ports.

BREAKING CHANGE: the extension in this release connects to 18771 only.
Pair a new bridge with the released 2.0.0 extension via
`tb-bridge --port 7700 --ws-port 7701` and TB_BRIDGE_PORT=7700.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RmV9wYm7QYGHPZ4YdS66Rw
EADDRINUSE on the HTTP or WebSocket port crashed with an unhandled
'error' event stack trace. The bridge now names the port, prints the
lsof command to find the holder, and exits 1. For the WebSocket port it
says to free the port rather than move it, since the extension always
connects to 18771.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RmV9wYm7QYGHPZ4YdS66Rw
Back to the original 7700 (HTTP) / 7701 (WebSocket) defaults, keeping
compatibility with the released 2.0.0 extension. The actual cause of
the EADDRINUSE was VS Code auto port forwarding, now disabled in the dev
container config; the bridge's port-in-use message (24ccb67) stays.

This reverts commit 0cf71f2.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RmV9wYm7QYGHPZ4YdS66Rw
- thunderbird-cli, thunderbird-cli-bridge, thunderbird-cli-mcp and
  server.json → 1.1.0; extension manifest → 2.1.0
- `tb --version` and the MCP serverInfo read the version from
  package.json (were stuck at 1.0.0); GET /health reports the loaded
  extension's manifest version instead of a fixed "2.0.0", so an old
  extension is visible at a glance
- server.json lists TB_AUTH_TOKEN (secret) for the MCP Registry
- CHANGELOG [1.1.0]; skill tracks thunderbird-cli-mcp@1.1.0
- release notes template no longer names the XPI after the npm version

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RmV9wYm7QYGHPZ4YdS66Rw
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.

2 participants