Skip to content

Enforce TB_AUTH_TOKEN on the bridge HTTP listener - #21

Closed
jctots wants to merge 1 commit into
vitalio-sh:mainfrom
jctots:fix/bridge-auth-token
Closed

jctots wants to merge 1 commit into
vitalio-sh:mainfrom
jctots:fix/bridge-auth-token

Conversation

@jctots

@jctots jctots commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Completes the Auth token support (bridge + CLI) item under Phase 5: Polish in SPEC.md.

TB_AUTH_TOKEN was already documented in SPEC.md, docs/COMMANDS.md, cli/README.md and
mcp/README.md, and cli/src/client.js already sends Authorization: Bearer <token> when it is
configured. 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.md builds a thorough model around hostile email reaching a trusted caller. It has no
row for an untrusted caller. The bridge binds to 127.0.0.1, which bounds remote access but is
not 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 — or curl the 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.js

  • Reads TB_AUTH_TOKEN from the daemon's environment. When set, every HTTP request must carry
    Authorization: Bearer <token>, or gets 401 with {"code": "AUTH_REQUIRED"}.
  • Comparison is constant-time (crypto.timingSafeEqual), with a length check first since
    timingSafeEqual throws on unequal lengths.
  • Scheme match is case-insensitive per RFC 7235, and the header must be exactly two parts, so a
    bare token or a token with trailing content is rejected.
  • Startup logs which mode it is in.

Two deliberate behaviour choices, both worth a look:

  1. An empty TB_AUTH_TOKEN refuses startup rather than disabling auth. TB_AUTH_TOKEN=
    in a unit file or a .env that failed to interpolate is a misconfiguration, and failing open
    would 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.
  2. /bridge/status is behind the gate too. It is the one behaviour change for existing
    users: a health check scripted against it needs the header once a token is set (docs/SETUP.md
    is 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_TOKEN unset — the default, matching authToken: null
in the config schema — behaviour is byte-for-byte what it was.

test/bridge-auth.mjs (new, wired into npm run test:bridge-auth and 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_REQUIRED code in the 401 body, and a token that
is a prefix of the real one.

Docs: SECURITY.md gains Scenario 6 (untrusted local caller) and a caller-authentication row
in the mitigations table; bridge/README.md gains an Authentication section; SPEC.md,
.env.example, docs/SETUP.md and mcp/README.md are updated to match. README.md and
docs/CLAUDE.md have no environment or bridge-status sections, so nothing there needed changing.

Testing

Per CONTRIBUTING, on Node 22:

  • npm test — 46 passed
  • npm run test:mcp — 34 passed
  • npm run test:bridge-auth — 15 passed

Also exercised live against a real Thunderbird install with the bridge under systemd: bare tb
calls return AUTH_REQUIRED, credentialed calls return all six accounts, and the extension
reconnects normally.

Out of scope

The WebSocket listener on :7701 is not covered by this token, and both SPEC.md and
SECURITY.md now say so explicitly rather than leaving it implied. It has the same local-caller
exposure, plus extensionSocket = ws is assigned unconditionally, so any local process can take
the 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.

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>
@vitalio-sh

Copy link
Copy Markdown
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.

@vitalio-sh vitalio-sh closed this Sep 14, 2026
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
@jctots

jctots commented Sep 14, 2026

Copy link
Copy Markdown
Contributor Author

Appreciate you folding this into #25 along with the broader hardening — good to
see it land in 1.1.0. Thanks for the quick turnaround.

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