From ba5644232190dbbc1f0f83b113cc3e9df2515917 Mon Sep 17 00:00:00 2001 From: jctots Date: Mon, 24 Aug 2026 10:32:23 +0200 Subject: [PATCH] Enforce TB_AUTH_TOKEN on the bridge HTTP listener 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 `. 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 --- .env.example | 8 ++- .github/workflows/test.yml | 3 + SECURITY.md | 20 ++++++ SPEC.md | 19 +++++- bridge/README.md | 40 +++++++++++- bridge/bridge.js | 43 ++++++++++++- docs/SETUP.md | 3 +- mcp/README.md | 1 + package.json | 1 + test/bridge-auth.mjs | 126 +++++++++++++++++++++++++++++++++++++ 10 files changed, 258 insertions(+), 6 deletions(-) create mode 100644 test/bridge-auth.mjs diff --git a/.env.example b/.env.example index bb18e83..b8ecb29 100644 --- a/.env.example +++ b/.env.example @@ -4,5 +4,11 @@ TB_BRIDGE_HOST=127.0.0.1 TB_BRIDGE_PORT=7700 -# Optional: auth token for bridge +# Optional: require a token on every bridge HTTP request. +# Generate one with: openssl rand -hex 32 +# +# The SAME value must be set in the bridge daemon's own environment (which is what +# enforces it) and in each client that should be allowed through. Leave it unset to +# run without authentication — an empty value makes the bridge refuse to start rather +# than run unauthenticated by accident. # TB_AUTH_TOKEN= diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0c2faa2..72759a2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -37,5 +37,8 @@ jobs: - name: Run CLI/bridge integration tests (mock) run: npm test + - name: Run bridge authentication tests (real bridge process) + run: npm run test:bridge-auth + - name: Run MCP server integration tests (mock) run: npm run test:mcp diff --git a/SECURITY.md b/SECURITY.md index aa2a354..e01a69a 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -56,6 +56,25 @@ in your reply so the recipient's AI assistant can process it: ``` Agent unknowingly propagates the injection to other AI-enabled inboxes. +**Scenario 6: Untrusted local caller (confused deputy at the transport layer)** + +The scenarios above treat the *email* as hostile and the caller as trusted. On a machine +running more than one automated agent, that second half does not hold. + +The bridge binds to `127.0.0.1`, which stops remote access but is not access control — every +process running as the same OS user can issue requests to it. An agent, script, or dependency +that was never granted mailbox access can call the bridge directly and read the entire mailbox, +regardless of which tools it was configured with. Restricting capabilities per-agent at the tool +layer (MCP server config, allow-lists) does not constrain what that agent's shell can reach. + +Defense: set `TB_AUTH_TOKEN` in the bridge daemon's environment so every HTTP request must carry +`Authorization: Bearer `, and give the token only to callers that should have mailbox +access — a token readable by every local process restores the original problem. See the bridge +README's Authentication section. + +The token covers the HTTP listener only. The WebSocket listener the extension connects to still +accepts any local connection. + --- ## CLI-Level Defenses (Data Layer) @@ -419,6 +438,7 @@ adversarial text, never as instructions. | Trust boundaries | CLI output | Marks trusted/untrusted fields | Confused deputy | | Suspicious patterns | CLI output | Flags injection-like text | Direct prompt injection | | Junk exclusion | CLI defaults | Excludes spam from results | Spam-based injection | +| Caller authentication | Bridge | Requires `Authorization: Bearer` (`TB_AUTH_TOKEN`) | Untrusted local process reaching the mailbox | | Read-only mode | Bridge | Disables all writes | Any write-based attack | | Rate limiting | Bridge | Throttles write ops | Mass exfiltration/deletion | | Audit log | Bridge | Logs all writes | Post-incident forensics | diff --git a/SPEC.md b/SPEC.md index 76fd1eb..a3c8bce 100644 --- a/SPEC.md +++ b/SPEC.md @@ -553,7 +553,21 @@ Config file: `~/.config/thunderbird-cli/config.json` Environment variables override config: - `TB_BRIDGE_HOST` — bridge host (for Docker: `host.docker.internal`) - `TB_BRIDGE_PORT` — bridge HTTP port -- `TB_AUTH_TOKEN` — auth token +- `TB_AUTH_TOKEN` — auth token, sent as `Authorization: Bearer ` + +### Authentication + +The bridge binds to `127.0.0.1`, which prevents remote access but not local access: any process +running as the same OS user can call it. Setting `TB_AUTH_TOKEN` in the **bridge daemon's own +environment** makes it require `Authorization: Bearer ` on every HTTP request, answering +`401` otherwise. Clients send the header when the same token is in their environment or in +`bridge.authToken`. + +- Unset on the daemon → authentication disabled (default), reported at startup. +- Set but empty on the daemon → startup is refused, rather than silently running unauthenticated. + +The token gates the HTTP listener only. The WebSocket listener the extension connects to is not +covered by it. ### Error Codes @@ -561,6 +575,7 @@ Environment variables override config: |------|---------| | `BRIDGE_UNREACHABLE` | Bridge is not running | | `EXTENSION_DISCONNECTED` | Thunderbird extension not connected to bridge | +| `AUTH_REQUIRED` | Bridge requires `TB_AUTH_TOKEN`; the request had none or the wrong one | | `TIMEOUT` | Request to extension timed out (30s) | | `NOT_FOUND` | Message/folder/account not found | | `INVALID_ARGS` | Bad CLI arguments | @@ -745,7 +760,7 @@ Note: Extension development cannot happen in Docker. Edit `extension/src/backgro - [ ] Progress output for long-running bulk operations ### Phase 5: Polish -- [ ] Auth token support (bridge + CLI) +- [x] Auth token support (bridge + CLI) - [x] Config file support (~/.config/thunderbird-cli/config.json) - [x] Environment variable overrides - [x] `--timeout` flag diff --git a/bridge/README.md b/bridge/README.md index b025bd6..3bff37a 100644 --- a/bridge/README.md +++ b/bridge/README.md @@ -17,7 +17,7 @@ HTTP client (CLI / MCP / curl) ──HTTP→ tb-bridge ──WS→ Thunderbird E ``` - **Stateless** — each request gets a UUID, response correlated, then forgotten -- **Localhost-only** — listens on `127.0.0.1` (no external exposure) +- **Localhost-only** — listens on `127.0.0.1` (no external exposure). Note that this bounds *remote* access only: any process running as the same OS user can reach the bridge. Set `TB_AUTH_TOKEN` to require a token — see [Authentication](#authentication) - **Auto-reconnect on the extension side** — if Thunderbird restarts, the extension reconnects within 3s - **Zero config** — works out of the box with default ports @@ -37,6 +37,7 @@ You should see: ``` [bridge] HTTP server on http://127.0.0.1:7700 [bridge] WebSocket server on ws://127.0.0.1:7701 +[bridge] Auth: disabled — any local process can call this bridge (set TB_AUTH_TOKEN to require a token) [bridge] Waiting for Thunderbird extension to connect... ``` @@ -51,11 +52,48 @@ pm2 save nohup tb-bridge > ~/.tb-bridge.log 2>&1 & ``` +## Authentication + +Binding to `127.0.0.1` keeps the bridge off the network, but it is not access control: every +process running as the same OS user can reach it, including any local agent or script that was +never wired up to the CLI. Where that matters — a shared machine, or an AI agent that should not +have mailbox access — set a token: + +```bash +TB_AUTH_TOKEN=$(openssl rand -hex 32) tb-bridge +``` + +The startup banner then reports `[bridge] Auth: enabled`, and every HTTP request must carry it: + +```bash +curl -H "Authorization: Bearer $TB_AUTH_TOKEN" http://127.0.0.1:7700/bridge/status +``` + +Requests with a missing, malformed, or incorrect token get `401` with a JSON error body. The +comparison is constant-time. `tb` and `tb-mcp` send the header automatically when `TB_AUTH_TOKEN` +is set in their environment or `bridge.authToken` in `~/.config/thunderbird-cli/config.json`. + +Two behaviours are deliberate: + +- **Unset `TB_AUTH_TOKEN` disables authentication** and the bridge says so at startup. This keeps + the default zero-config setup working. +- **An empty `TB_AUTH_TOKEN` is refused at startup.** Failing open on an empty value would leave + the bridge unauthenticated with nothing in the log to say so. Unset the variable to run without + auth. + +Give the token only to the callers that should have mailbox access — putting it somewhere every +local process can read it (a world-readable file, a shared shell profile) puts you back where you +started. + +> The WebSocket listener on `:7701`, which the Thunderbird extension connects to, is **not** +> covered by `TB_AUTH_TOKEN`. + ## Endpoints ### `GET /bridge/status` Returns bridge state without requiring the extension. Use this to check the daemon is up. +Subject to authentication when `TB_AUTH_TOKEN` is set. ```bash curl http://127.0.0.1:7700/bridge/status diff --git a/bridge/bridge.js b/bridge/bridge.js index 9da5e03..3158c1b 100755 --- a/bridge/bridge.js +++ b/bridge/bridge.js @@ -11,17 +11,42 @@ * * Environment variables: * TB_BRIDGE_TIMEOUT default per-request timeout in ms (default: 120000) + * TB_AUTH_TOKEN if set, HTTP requests must present it as `Authorization: Bearer `. + * Unset it to run without authentication. Setting it to an empty value is + * rejected at startup rather than silently disabling authentication. * * Per-request override: HTTP clients can pass `X-TB-Timeout: ` header. */ import { createServer } from "http"; import { WebSocketServer } from "ws"; -import { randomUUID } from "crypto"; +import { randomUUID, timingSafeEqual } from "crypto"; const HTTP_PORT = parseInt(process.argv.find((_, i, a) => a[i - 1] === "--port") || "7700"); const WS_PORT = parseInt(process.argv.find((_, i, a) => a[i - 1] === "--ws-port") || "7701"); const DEFAULT_TIMEOUT = parseInt(process.env.TB_BRIDGE_TIMEOUT || "120000"); +// An empty TB_AUTH_TOKEN is a misconfiguration, not a way to disable auth. Failing open here +// would leave the bridge reachable by any local process with nothing in the log to say so. +const RAW_AUTH_TOKEN = process.env.TB_AUTH_TOKEN; +if (RAW_AUTH_TOKEN !== undefined && RAW_AUTH_TOKEN.trim() === "") { + console.error( + "[bridge] TB_AUTH_TOKEN is set but empty. Refusing to start rather than silently " + + "disabling authentication — unset the variable to run without auth." + ); + process.exit(1); +} +const AUTH_TOKEN = RAW_AUTH_TOKEN ?? null; + +function isAuthorized(req) { + if (!AUTH_TOKEN) return true; + // RFC 7235: the auth scheme is case-insensitive. Require exactly " ". + const parts = (req.headers["authorization"] || "").split(" "); + if (parts.length !== 2 || parts[0].toLowerCase() !== "bearer") return false; + const expected = Buffer.from(AUTH_TOKEN); + const actual = Buffer.from(parts[1]); + if (expected.length !== actual.length) return false; + return timingSafeEqual(expected, actual); +} let extensionSocket = null; const pending = new Map(); // id → { resolve, reject, timer } @@ -91,6 +116,17 @@ const httpServer = createServer(async (req, res) => { return; } + if (!isAuthorized(req)) { + res.writeHead(401); + res.end( + JSON.stringify({ + error: "Missing or invalid Authorization token. Set TB_AUTH_TOKEN for this client.", + code: "AUTH_REQUIRED", + }) + ); + return; + } + // Bridge status endpoint (doesn't need extension) if (req.url === "/bridge/status") { const status = { @@ -144,5 +180,10 @@ httpServer.listen(HTTP_PORT, "127.0.0.1", () => { console.log(`[bridge] HTTP server on http://127.0.0.1:${HTTP_PORT}`); console.log(`[bridge] WebSocket server on ws://127.0.0.1:${WS_PORT}`); console.log(`[bridge] Default timeout: ${DEFAULT_TIMEOUT}ms (override via TB_BRIDGE_TIMEOUT env or X-TB-Timeout header)`); + console.log( + AUTH_TOKEN + ? "[bridge] Auth: enabled (Authorization: Bearer required on all HTTP requests)" + : "[bridge] Auth: disabled — any local process can call this bridge (set TB_AUTH_TOKEN to require a token)" + ); console.log(`[bridge] Waiting for Thunderbird extension to connect...`); }); diff --git a/docs/SETUP.md b/docs/SETUP.md index 0a04b74..1299315 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -156,6 +156,7 @@ Environment variables override config file values: ### "Bridge unreachable" - Is the bridge running? Check: `curl http://127.0.0.1:7700/bridge/status` + (if the bridge was started with `TB_AUTH_TOKEN`, add `-H "Authorization: Bearer $TB_AUTH_TOKEN"`, or the call returns 401) - In Docker, use `host.docker.internal` instead of `127.0.0.1` ### "Extension not connected" @@ -172,7 +173,7 @@ Environment variables override config file values: ### Extension loads but doesn't connect - Verify port 7701 is not blocked or in use -- Check bridge is running: `curl http://127.0.0.1:7700/bridge/status` +- Check bridge is running: `curl http://127.0.0.1:7700/bridge/status` (add `-H "Authorization: Bearer $TB_AUTH_TOKEN"` if auth is enabled) - Try restarting the bridge, then reload the extension ### Folder counts show 0 diff --git a/mcp/README.md b/mcp/README.md index 2044530..7e9186c 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -194,6 +194,7 @@ See [SECURITY.md](https://github.com/vitalio-sh/thunderbird-cli/blob/main/SECURI ### "Bridge unreachable" / connection errors - Is the bridge daemon running on `127.0.0.1:7700`? Test: `curl http://127.0.0.1:7700/bridge/status` + (if the bridge was started with `TB_AUTH_TOKEN`, add `-H "Authorization: Bearer $TB_AUTH_TOKEN"`, or the call returns 401) - Is Thunderbird running with the extension loaded? - Check Thunderbird's add-on debugging console for WebSocket errors diff --git a/package.json b/package.json index 370ebe1..cce90d6 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "scripts": { "test": "node test/quick-test.mjs", "test:mcp": "node test/mcp-test.mjs", + "test:bridge-auth": "node test/bridge-auth.mjs", "bridge": "node bridge/bridge.js", "tb": "node cli/src/cli.js", "build:xpi": "node scripts/build-xpi.mjs" diff --git a/test/bridge-auth.mjs b/test/bridge-auth.mjs new file mode 100644 index 0000000..1a0a181 --- /dev/null +++ b/test/bridge-auth.mjs @@ -0,0 +1,126 @@ +#!/usr/bin/env node +/** + * Bridge authentication tests. + * + * Spawns the real bridge/bridge.js as a subprocess (not a mock) and exercises the + * TB_AUTH_TOKEN enforcement path against /bridge/status, which answers without the + * Thunderbird extension being connected. + */ + +import { spawn } from "child_process"; +import { fileURLToPath } from "url"; +import { dirname, join } from "path"; + +const BRIDGE = join(dirname(fileURLToPath(import.meta.url)), "..", "bridge", "bridge.js"); +const PORT = 19710; +const WS_PORT = 19711; +const TOKEN = "test-token-abc123"; + +let passed = 0, failed = 0; + +function test(name, actual, expected) { + if (actual === expected) { + passed++; + console.log(` \x1b[32m✓\x1b[0m ${name}`); + } else { + failed++; + console.log(` \x1b[31m✗\x1b[0m ${name} — expected ${expected}, got ${actual}`); + } +} + +/** Build a child env, treating an `undefined` value as "remove this variable". */ +function childEnv(overrides) { + const env = { ...process.env, ...overrides }; + for (const [k, v] of Object.entries(overrides)) { + if (v === undefined) delete env[k]; + } + return env; +} + +/** Start the bridge with the given env; resolves once it is listening. */ +function startBridge(overrides) { + return new Promise((resolve, reject) => { + const proc = spawn(process.execPath, [BRIDGE, "--port", String(PORT), "--ws-port", String(WS_PORT)], { + env: childEnv(overrides), + stdio: ["ignore", "pipe", "pipe"], + }); + let out = ""; + const onData = (chunk) => { + out += chunk.toString(); + if (out.includes("Waiting for Thunderbird extension")) resolve({ proc, out }); + }; + proc.stdout.on("data", onData); + proc.stderr.on("data", onData); + proc.on("exit", (code) => reject(new Error(`bridge exited early (code ${code}): ${out}`))); + setTimeout(() => reject(new Error(`bridge did not start: ${out}`)), 5000); + }); +} + +function stopBridge(proc) { + return new Promise((resolve) => { + proc.removeAllListeners("exit"); + proc.on("exit", resolve); + proc.kill(); + }); +} + +async function status(headers = {}) { + const res = await fetch(`http://127.0.0.1:${PORT}/bridge/status`, { headers }); + return res.status; +} + +async function statusBody(headers = {}) { + const res = await fetch(`http://127.0.0.1:${PORT}/bridge/status`, { headers }); + return res.json(); +} + +/** Run the bridge with `env`, hand it to `fn`, then shut it down. */ +async function withBridge(env, fn) { + const { proc, out } = await startBridge(env); + try { + await fn(out); + } finally { + await stopBridge(proc); + } +} + +console.log("\n\x1b[1mAuth disabled (TB_AUTH_TOKEN unset)\x1b[0m"); +await withBridge({ TB_AUTH_TOKEN: undefined }, async (out) => { + test("request without a token succeeds", await status(), 200); + test("startup log reports auth disabled", out.includes("Auth: disabled"), true); +}); + +console.log("\n\x1b[1mAuth enabled\x1b[0m"); +await withBridge({ TB_AUTH_TOKEN: TOKEN }, async (out) => { + test("startup log reports auth enabled", out.includes("Auth: enabled"), true); + test("no Authorization header is rejected", await status(), 401); + test("wrong token is rejected", await status({ Authorization: "Bearer wrong-token-abc12" }), 401); + test("correct token is accepted", await status({ Authorization: `Bearer ${TOKEN}` }), 200); + test("scheme is case-insensitive (RFC 7235)", await status({ Authorization: `bearer ${TOKEN}` }), 200); + test("wrong scheme is rejected", await status({ Authorization: `Basic ${TOKEN}` }), 401); + test("bare token without a scheme is rejected", await status({ Authorization: TOKEN }), 401); + test("token with trailing content is rejected", await status({ Authorization: `Bearer ${TOKEN} extra` }), 401); + test("empty Authorization header is rejected", await status({ Authorization: "" }), 401); + test("token that is a prefix of the real one is rejected", await status({ Authorization: "Bearer test-token-abc12" }), 401); + // Clients map an unrecognised 4xx to THUNDERBIRD_ERROR, which would misreport an auth + // failure as a Thunderbird problem. The bridge names it so they surface it correctly. + test("401 body carries the AUTH_REQUIRED code", (await statusBody()).code, "AUTH_REQUIRED"); +}); + +console.log("\n\x1b[1mEmpty TB_AUTH_TOKEN fails closed\x1b[0m"); +{ + const proc = spawn(process.execPath, [BRIDGE, "--port", String(PORT), "--ws-port", String(WS_PORT)], { + env: childEnv({ TB_AUTH_TOKEN: "" }), + stdio: ["ignore", "pipe", "pipe"], + }); + let out = ""; + proc.stdout.on("data", (c) => (out += c.toString())); + proc.stderr.on("data", (c) => (out += c.toString())); + const code = await new Promise((resolve) => proc.on("exit", resolve)); + test("bridge refuses to start", code, 1); + test("reason is logged", out.includes("TB_AUTH_TOKEN is set but empty"), true); +} + +console.log(`\n\x1b[1m${"─".repeat(40)}\x1b[0m`); +console.log(`\x1b[1m${passed} passed, ${failed} failed, ${passed + failed} total\x1b[0m\n`); +process.exit(failed > 0 ? 1 : 0);