Skip to content

Harden the local bridge: pin Host, write tabs.json 0600 - #24

Merged
colevels merged 1 commit into
mainfrom
fix/harden-local-bridge
Jul 19, 2026
Merged

Harden the local bridge: pin Host, write tabs.json 0600#24
colevels merged 1 commit into
mainfrom
fix/harden-local-bridge

Conversation

@colevels

Copy link
Copy Markdown
Owner

Two gaps in the security model tabs serve documents. Both found reviewing #22; split out of #23 so the restructure stayed reviewable.

1. tabs.json was world-readable

tabs.json         -rw-r--r--     ← before
credentials.json  -rw-------

Same directory, different permissions. tabs.json is written through the shared atomicWrite, whose other caller is init — where 0644 is correct — so it inherited the umask default.

That file is the URL and title of every open tab. It's worse to leak than the token in the next file over: a token is revocable, browsing history isn't, and full URLs carry more than hostnames (account paths, doc links, share links with tokens in the query string).

I checked whether the directory saved us. It doesn't — ~/.config/tabbrew is drwxr-xr-x in practice, not the 0700 the code intends, so the file mode was the only protection and it wasn't set. Worse with --out ./tabs.json into a repo folder, where it's also a git add away from being committed.

atomicWrite now takes an optional mode, applied to the temp file at creation so the content is never briefly readable at 0644. Because rename makes that temp inode become the target, the mode survives even over a pre-existing looser file — no follow-up chmod needed, unlike the non-atomic write in credentials.ts. init's files are untouched (verified below).

2. The Origin check didn't stop DNS rebinding

The header the check relies on isn't sent in the case that matters:

  1. Victim opens http://evil.com; attacker's DNS has a short TTL
  2. Page polls http://evil.com:49227/script
  3. Attacker flips DNS to 127.0.0.1
  4. Request hits this server — but the page's origin is still http://evil.com, so the browser treats it as same-origin
  5. Per the Fetch spec, Origin is omitted on same-origin GET/HEAD → the check waves it through
  6. Same-origin ⇒ no CORS ⇒ the page reads the response

It gets the queued script and, because GET /script has pop semantics, denies it to the real extension. Confirmed by hand before the fix:

$ curl -H 'Host: evil.example' http://127.0.0.1:49302/script
{"script":"DEL 999","queuedAt":"..."}   [200]

Now Host must be 127.0.0.1:<port> or localhost:<port>. The browser sets Host from the URL the page asked for (evil.com:49227) and page JS can't forge it — Host is a forbidden header name. Extension traffic addresses 127.0.0.1 directly and is unaffected.

The Origin check stays. Browsers always attach Origin to non-GET requests, so it's what blocks a drive-by POST /tabs from writing to disk — a page can do that with Content-Type: text/plain to dodge preflight. The two checks cover different halves.

Severity, honestly

Neither is urgent. The rebinding payload is a list of tab ids and group names that exists for seconds, and needs the victim on the attacker's page at exactly the right moment. The file mode matters mainly on shared machines, CI runners, and synced home directories. But the README now states a security model, and a stated model should be complete — both fixes are a handful of lines.

Test plan

  • bun run typecheck, bun run build
  • tabs.json is now -rw-------
  • Host: evil.example403 forbidden_host on both /script and /health
  • 127.0.0.1 and localhost still 200 — the bridge isn't broken
  • POST /tabs with a foreign Origin still 403 (no regression on the check being kept)
  • serve + push round-trip still works end to end
  • Regression: init still writes CLAUDE.md / TABBREW-CLI.md / SKILL.md at 0644

🤖 Generated with Claude Code

Two gaps in the local bridge's stated security model, both found while
reviewing #22.

`tabs.json` was written at the umask default (0644) by the shared `atomicWrite`,
whose other caller is `init` — where 0644 is right. But this file is the URL and
title of every open tab: browsing history, which unlike a token can't be revoked
once leaked, and whose full paths carry more than hostnames (account pages, doc
links, share links with tokens in the query string). Verified the config dir is
*not* reliably 0700 either, so the file mode was the only thing left, and it
wasn't set. `atomicWrite` now takes an optional mode, applied to the temp file at
creation so the content is never briefly world-readable; the rename makes that
inode the target, so no follow-up chmod is needed. `init`'s files are unchanged.

The Origin check did not stop DNS rebinding. A page on http://evil.com rebound to
127.0.0.1 keeps its own origin, so its requests are same-origin — and the Fetch
spec omits `Origin` on same-origin GET/HEAD, so `GET /script` sailed through and
the page could read the response (no CORS between same origins), stealing the
queued script and, via pop semantics, denying it to the extension. Confirmed by
hand: `curl -H 'Host: evil.example' .../script` returned 200 with the script.
Now Host must be 127.0.0.1|localhost:<port>; page JS can't forge it (forbidden
header name) and real extension traffic is unaffected.

The Origin check stays — browsers always attach Origin to non-GET requests, so
it's what blocks a drive-by POST /tabs from writing to disk. The two cover
different halves.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@colevels
colevels merged commit fe3cead into main Jul 19, 2026
1 check passed
@colevels
colevels deleted the fix/harden-local-bridge branch July 19, 2026 19:17
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.

1 participant