Repository navigation
Conversation
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>
Owner
|
Thank you @jctots — this landed as-is in #25 (commit a052c8d, authored by you) and ships in 1.1.0. The threat model write-up in SECURITY.md was spot on. While reviewing it we also closed the gap you called out: #25 adds Origin/Host checks on the HTTP listener and refuses WebSocket handshakes from web origins, so a page in the browser can no longer drive the bridge or take over the extension slot. Closing this in favour of #25. |
jctots
pushed a commit
to jctots/thunderbird-cli
that referenced
this pull request
Sep 14, 2026
…egration email_search no longer requires `query` when at least one filter is given (from PR vitalio-sh#20); with neither it fails with INVALID_ARGS instead of running an unbounded search. Not taken from PR vitalio-sh#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 vitalio-sh#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
Contributor
Author
|
Appreciate you folding this into #25 along with the broader hardening — good to |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Completes the
Auth token support (bridge + CLI)item under Phase 5: Polish inSPEC.md.TB_AUTH_TOKENwas already documented inSPEC.md,docs/COMMANDS.md,cli/README.mdandmcp/README.md, andcli/src/client.jsalready sendsAuthorization: Bearer <token>when it isconfigured. The bridge never read the variable and never checked the header, so the client half
had nothing to talk to. This PR adds the daemon-side enforcement. No client code changes.
I considered whether this belonged in a private report rather than a PR, and judged it a
documented-but-unimplemented feature rather than a vulnerability disclosure: the exposure requires
code execution as the user's own account, and the fix ships with the description. Happy to move
the discussion if you would rather handle it differently.
Why
SECURITY.mdbuilds a thorough model around hostile email reaching a trusted caller. It has norow for an untrusted caller. The bridge binds to
127.0.0.1, which bounds remote access but isnot access control: every process running as the same OS user can issue requests to it.
That gap is load-bearing once more than one automated agent runs on a machine. An agent whose MCP
configuration grants it no mail tools can still shell out to
tb— orcurlthe bridge directly —and read the whole mailbox. Capability restrictions applied at the tool layer do not constrain
what that agent's shell can reach. I hit this on my own machine: a coding agent with no mail
wiring at all read my full inbox on the first try.
It also matters for a control the defense matrix already claims. "Multi-agent split — separate
read/write agents" only holds if same-machine agents can actually be given different reach, and
without caller authentication they cannot be.
CONTRIBUTING describes the bridge as a dumb proxy, and I have kept it one: this is transport-layer
access control, not business logic. It lives at the listener because that is what accepts the
connection — no routing, request semantics or extension behaviour is touched.
What changed
bridge/bridge.jsTB_AUTH_TOKENfrom the daemon's environment. When set, every HTTP request must carryAuthorization: Bearer <token>, or gets401with{"code": "AUTH_REQUIRED"}.crypto.timingSafeEqual), with a length check first sincetimingSafeEqualthrows on unequal lengths.bare token or a token with trailing content is rejected.
Two deliberate behaviour choices, both worth a look:
TB_AUTH_TOKENrefuses startup rather than disabling auth.TB_AUTH_TOKEN=in a unit file or a
.envthat failed to interpolate is a misconfiguration, and failing openwould leave the bridge unauthenticated with nothing in the log to say so. Unsetting the
variable is the supported way to run without auth, and that path is unchanged.
/bridge/statusis behind the gate too. It is the one behaviour change for existingusers: a health check scripted against it needs the header once a token is set (
docs/SETUP.mdis updated to show this). The reasoning is that connection state and uptime should not be
readable by a caller that is not allowed to make requests. Happy to move it in front of the
gate if you would rather keep status probes credential-free.
Backward compatibility: with
TB_AUTH_TOKENunset — the default, matchingauthToken: nullin the config schema — behaviour is byte-for-byte what it was.
test/bridge-auth.mjs(new, wired intonpm run test:bridge-authand CI)Spawns a real bridge process on spare ports rather than mocking, and asserts 15 cases across
three configurations: auth disabled, auth enabled, and the empty-token startup refusal. Covers
the header-parsing edge cases above, the
AUTH_REQUIREDcode in the 401 body, and a token thatis a prefix of the real one.
Docs:
SECURITY.mdgains Scenario 6 (untrusted local caller) and a caller-authentication rowin the mitigations table;
bridge/README.mdgains an Authentication section;SPEC.md,.env.example,docs/SETUP.mdandmcp/README.mdare updated to match.README.mdanddocs/CLAUDE.mdhave no environment or bridge-status sections, so nothing there needed changing.Testing
Per CONTRIBUTING, on Node 22:
npm test— 46 passednpm run test:mcp— 34 passednpm run test:bridge-auth— 15 passedAlso exercised live against a real Thunderbird install with the bridge under systemd: bare
tbcalls return
AUTH_REQUIRED, credentialed calls return all six accounts, and the extensionreconnects normally.
Out of scope
The WebSocket listener on
:7701is not covered by this token, and bothSPEC.mdandSECURITY.mdnow say so explicitly rather than leaving it implied. It has the same local-callerexposure, plus
extensionSocket = wsis assigned unconditionally, so any local process can takethe extension's slot and see every forwarded request. Closing it needs a way for the extension to
hold a credential, which is an extension-side design question rather than a daemon patch. I would
rather not bundle that debate into a fix that is otherwise ready — I will open a separate issue
referencing this PR once it is up, unless you would prefer it discussed here.