Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
3 changes: 3 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
20 changes: 20 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <token>`, 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)
Expand Down Expand Up @@ -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 |
Expand Down
19 changes: 17 additions & 2 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -553,14 +553,29 @@ 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 <token>`

### 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 <token>` 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

| Code | Meaning |
|------|---------|
| `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 |
Expand Down Expand Up @@ -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
Expand Down
40 changes: 39 additions & 1 deletion bridge/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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...
```

Expand All @@ -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
Expand Down
43 changes: 42 additions & 1 deletion bridge/bridge.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <token>`.
* 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: <ms>` 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 "<scheme> <token>".
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 }
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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...`);
});
3 changes: 2 additions & 1 deletion docs/SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
126 changes: 126 additions & 0 deletions test/bridge-auth.mjs
Original file line number Diff line number Diff line change
@@ -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);