Skip to content
Merged
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
6 changes: 6 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,12 @@ user's tabs**. `check`/`prompt`/`list` are fully offline; `serve`/`push` only ev
- `commands/tabs-serve.ts` — the bridge. `resolveServePort()` lives here and is the **one**
place a port is decided; `tabs-push.ts` calls it too, so the listener and the client can't
disagree (they used to: `push` ignored `--port` and silently queued onto the default).
Security model: loopback-only bind, **no token**, plus two header gates — `Host` must be
`127.0.0.1|localhost:<port>` (this is the anti-DNS-rebinding one: a rebound page's GETs are
*same-origin*, so they carry no `Origin` and the check below can't see them), and `Origin`,
when present, must be `chrome-extension://` (blocks a drive-by `POST /tabs`). Keep both —
they cover different halves. `tabs.json` is written `0o600` via `atomicWrite`'s `mode` arg:
it's browsing history, the config dir is not reliably `0700`, and umask alone gives `0644`.
- `commands/tabs-push.ts` — validates, then POSTs to the bridge. Deliberately **not** named
`run`: it cannot execute anything, and the old name had users believing their tabs had
already changed. Rejects a zero-op script locally rather than letting the server's
Expand Down
30 changes: 22 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -343,14 +343,28 @@ expect the extension side not to follow.

A departure from every other command, which is OAuth-gated: `tabs serve` binds
**`127.0.0.1` only** (hardcoded, not configurable) and requires **no token** — the
loopback-only bind is the entire security boundary. Anything already running as
you on this machine can reach it.

It also rejects any request whose `Origin` header is present and isn't
`chrome-extension://…`. That's cheap defense-in-depth against a stray webpage's
JS hitting the port — it blocks a drive-by `POST /tabs` from writing to your
disk, since browsers always attach `Origin` to non-GET requests. It's a no-op for
plain `curl`/scripts, which send no `Origin` at all.
loopback-only bind is the entire boundary against the network. Anything already
running as you on this machine can reach it.

Two header checks keep a *browser* from being used as the way in:

- **`Host` must be `127.0.0.1:<port>` or `localhost:<port>`.** This is what stops
[DNS rebinding](https://en.wikipedia.org/wiki/DNS_rebinding): a page on
`http://evil.com` whose DNS is flipped to `127.0.0.1` reaches this server while
keeping its own origin, making its requests *same-origin* — and browsers omit
`Origin` on same-origin GETs, so the check below alone would let it read a
queued script. The browser sets `Host` from the URL the page asked for, and
page JS can't forge it (it's a forbidden header name).
- **`Origin`, when present, must be `chrome-extension://…`.** Browsers always
attach `Origin` to non-GET requests, so this is what blocks a drive-by
`POST /tabs` from writing to your disk.

Neither affects `curl` or scripts run by you, which address `127.0.0.1` directly
and send no `Origin`.

The saved `tabs.json` is written **`chmod 600`**, like `credentials.json` — it
holds the URL and title of every open tab, which is browsing history and doesn't
become un-leaked the way a revoked token does.

A queued script is claimed by exactly one poll, so if the extension isn't
connected yet it simply waits; pushing again replaces whatever is still pending.
Expand Down
29 changes: 26 additions & 3 deletions src/commands/tabs-serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,13 @@ export async function tabsServe(opts: ServeOptions): Promise<void> {
}

const payload = { savedAt: new Date().toISOString(), count: tabs.length, tabs };
await atomicWrite(outPath, JSON.stringify(payload, null, 2) + "\n");
// 0600, like credentials.json. This is the URL and title of every open tab —
// browsing state, which is arguably worse to leak than the token: a token is
// revocable, a history isn't, and full URLs carry more than hostnames
// (account paths, doc links, share links with tokens in the query string).
// The default umask would leave it 0644, and the config dir is not reliably
// 0700, so the file mode is the only thing actually protecting it.
await atomicWrite(outPath, JSON.stringify(payload, null, 2) + "\n", 0o600);

return json({ ok: true, path: outPath, count: tabs.length });
}
Expand Down Expand Up @@ -99,12 +105,29 @@ export async function tabsServe(opts: ServeOptions): Promise<void> {
try {
const url = new URL(req.url);

// --- BEGIN origin hardening (optional, delete this block to disable) ---
// --- BEGIN origin/host hardening (optional, delete this block to disable) ---
// Host pinning is what stops DNS rebinding. A page on http://evil.com
// whose DNS is flipped to 127.0.0.1 reaches this server while keeping its
// own origin, so its requests are *same-origin* — and the Fetch spec omits
// `Origin` on same-origin GET/HEAD, which would sail past the check below
// and let the page read the response (no CORS between same origins). The
// browser sets `Host` from the URL it asked for (`evil.com:<port>`) and
// page JS cannot forge it — `Host` is a forbidden header name. Real
// extension traffic is addressed to 127.0.0.1 and passes untouched.
const host = req.headers.get("host");
if (host !== `127.0.0.1:${port}` && host !== `localhost:${port}`) {
return json({ error: "forbidden_host" }, 403);
}

// Origin still carries its own weight: browsers always attach it to
// non-GET/HEAD requests, so this is what blocks a drive-by `POST /tabs`
// from a random page writing to the user's disk. It's a no-op for
// curl/scripts, which send no Origin at all.
const origin = req.headers.get("origin");
if (origin && !origin.startsWith("chrome-extension://")) {
return json({ error: "forbidden_origin" }, 403);
}
// --- END origin hardening ---
// --- END origin/host hardening ---

if (req.method === "POST" && url.pathname === "/tabs") {
return await handlePostTabs(req);
Expand Down
15 changes: 13 additions & 2 deletions src/fsops.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,15 +48,26 @@ async function resolveWriteTarget(path: string): Promise<string> {
* Atomic write: temp file in the target's own directory, then rename over it.
* Same-directory rename is atomic on POSIX; a failed write leaves no temp litter.
* The parent directory must already exist.
*
* `mode` (e.g. 0o600) applies to the temp file at creation, so the content is
* never briefly readable at the default 0644 — and because `rename` makes that
* temp inode *become* the target, the mode survives even when the target already
* existed with looser permissions. No follow-up `chmod` is needed here, unlike
* the non-atomic write in credentials.ts. Omit it for files that should follow
* the umask, like the docs `init` writes.
*/
export async function atomicWrite(path: string, content: string): Promise<void> {
export async function atomicWrite(
path: string,
content: string,
mode?: number,
): Promise<void> {
const target = await resolveWriteTarget(path);
const tmp = join(
dirname(target),
`.${basename(target)}.tmp-${process.pid}-${randomUUID()}`,
);
try {
await writeFile(tmp, content);
await writeFile(tmp, content, mode === undefined ? undefined : { mode });
await rename(tmp, target);
} catch (err) {
await unlink(tmp).catch(() => {});
Expand Down