8000 feat: per-token and per-session domain policies by adeelahmad · Pull Request #9 · adeelahmad/Tether · GitHub
[go: up one dir, main page]

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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- Per-token and per-session domain policies (`docs/POLICIES.md`): a
`--policy-file` defines named tokens, each with allow/deny rules, an
`admin` flag and a `scripts` permission; `--deny-domains` adds a global
denylist; tabs carry their own session rules
(`POST /tether/v1/tabs {"policy": …}`, `GET`/`PUT /tether/v1/tabs/{id}/policy`,
WebDriver `tether:options.policy`). Rules are layered (global ∩ token ∩
owning token ∩ session) and restricted tabs are guarded in the browser via
the DevTools `Fetch` domain, so redirects, link clicks and page script
cannot leave the allowlist.
- Tab ownership: non-admin tokens only see and drive the tabs they opened,
get their own per-site default tabs, and cannot adopt existing tabs.
- `GET /tether/v1/whoami` reports the calling token and its permissions.

- Per-tab console capture: `console.*` calls, uncaught exceptions and
browser log entries (`Runtime.consoleAPICalled`, `Runtime.exceptionThrown`,
`Log.entryAdded`) are kept in a ring buffer per tab (`--console-buffer`,
Expand Down
19 changes: 18 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,20 @@ curl -x "http://tether:$TETHER_TOKEN@my-laptop:8787" --cacert tether-ca.pem http
Tether refuses to listen beyond loopback without a token. Read
[SECURITY.md](SECURITY.md) before exposing it anywhere.

### One token per agent

Give every agent its own token with its own domain rules. Each token only
sees the tabs it opened, and the rules are enforced inside the browser, so
redirects, link clicks and page script can't wander off the allowlist either.

```sh
tether serve --policy-file examples/policy.json # tokens + rules, see docs/POLICIES.md
curl -s -H "Authorization: Bearer $TETHER_RESEARCH_TOKEN" http://127.0.0.1:8787/tether/v1/whoami
# lock a single tab tighter still
curl -s -H "Authorization: Bearer $TETHER_RESEARCH_TOKEN" -X POST http://127.0.0.1:8787/tether/v1/tabs \
-d '{"url":"https://en.wikipedia.org/","policy":{"allow":["en.wikipedia.org"]}}'
```

## Configuration

Every flag has a `TETHER_*` environment variable; flags win.
Expand All @@ -183,6 +197,8 @@ Every flag has a `TETHER_*` environment variable; flags win.
| `--timeout` | `60s` | Default per-request timeout |
| `--console-buffer` | `1000` | Console/log entries kept per tab (`0` = no capture) |
| `--allow-domains` | all | Comma-separated navigation allowlist (applies to every face) |
| `--deny-domains` | none | Comma-separated denylist; deny beats allow |
| `--policy-file` | — | JSON file with per-token rules and tokens — see [docs/POLICIES.md](docs/POLICIES.md) |
| `--data-dir` | user config dir | Local CA and launched profile |
| `--no-mitm` | `false` | Refuse HTTPS `CONNECT` instead of intercepting |
| `--log-level`, `--log-format` | `info`, `text` | `json` for machines; secrets are always redacted |
Expand Down Expand Up @@ -216,8 +232,9 @@ local CA, and it only drives Chromium-family browsers.
- [x] Safe-by-default exposure, token auth, domain allowlist seam
- [ ] MCP server face (tools over the same core) — stubbed at `/mcp`
- [ ] Remote-browser UI with live screencast and input — stubbed at `/remote`
- [x] Per-token and per-session domain policies, enforced in the browser ([docs/POLICIES.md](docs/POLICIES.md))
- [x] Per-tab console and browser log capture (control API + WebDriver `get_log`)
- [ ] Per-session / per-agent policies; WebDriver Actions
- [ ] WebDriver Actions

## Contributing

Expand Down
17 changes: 11 additions & 6 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,10 @@ within 72 hours and to ship a fix or mitigation for confirmed issues within
|---|---|
| Binds to `127.0.0.1:8787` | Nothing off-machine can reach it unless you say so. |
| Refuses a non-loopback `--addr` without `--token` | Exposing a logged-in browser unauthenticated must be an explicit, loud choice (`--insecure-allow-public`). |
| Token auth on every request when `--token` is set | Constant-time comparison; `Proxy-Authorization` for proxy traffic (stripped before forwarding), `Authorization` for the API/WebDriver. |
| Token auth on every request when `--token` or a policy file's tokens are set | Constant-time comparison against every token; `Proxy-Authorization` for proxy traffic (stripped before forwarding), `Authorization` for the API/WebDriver. |
| Per-token tab ownership | Non-admin tokens only see tabs they opened, get their own per-site default tabs, and cannot adopt the user's tabs. Another token's tab looks like a closed one. |
| Loopback guard when running without a token | Tether's own endpoints only answer a loopback `Host` with no cross-site `Origin`, so web pages in your browser cannot drive Tether via `fetch('http://127.0.0.1:8787')` or DNS rebinding. |
| Per-session `core.Policy` in the request path | `--allow-domains` restricts navigation for every face; it is the hook for future per-agent permissions. |
| Layered domain policy in the request path *and* in the browser | Global (`--allow-domains`/`--deny-domains`), per-token and per-tab rules ([docs/POLICIES.md](docs/POLICIES.md)). Restricted tabs get a DevTools `Fetch` guard, so redirects, link clicks and page script are held to the rules too. |
| Redacted logs | Attributes named like tokens, cookies, authorization or passwords are replaced with `[REDACTED]`; URLs are logged without query strings or userinfo. |
| Session cookie never forwarded | `tether_tab` is stripped before any request reaches the page or the site. Site `Set-Cookie` headers are not relayed to clients; the browser keeps its cookies. |
| Background tabs; adopted tabs never closed | Tether doesn't steal focus, and only closes tabs it opened. |
Expand Down Expand Up @@ -53,8 +54,11 @@ ability to act as the user on any site, and Tether's local CA private key
(`<data-dir>/ca-key.pem`), which can mint certificates for any host *that a
client trusts this CA for*.

**Trust boundary.** Whoever can authenticate to the Tether listener is fully
trusted to use the browser. Tether is not a multi-tenant isolation layer.
**Trust boundary.** An admin token (including `--token`) is fully trusted to
use the browser. A non-admin token from a policy file is trusted only within
its domain rules and its own tabs; it is a guard rail for agents you run, not
a sandbox against a determined attacker who also shares the browser profile
(see *Limits* in [docs/POLICIES.md](docs/POLICIES.md)).

| Threat | Mitigation | Residual risk |
|---|---|---|
Expand All @@ -63,8 +67,9 @@ trusted to use the browser. Tether is not a multi-tenant isolation layer.
| Token leaks via logs | Redacting log handler; secrets never logged | Tokens passed on the command line are visible in `ps`; prefer `TETHER_TOKEN` or `--token-file`. |
| Token leaks via `curl -x http://user:token@…` history | — | Shell history; prefer `--proxy-header "Proxy-Authorization: Bearer $TETHER_TOKEN"`. |
| Local CA key theft | Key written `0600` in the per-user config dir | Anyone with your user account already has your browser. Only trust the CA in clients that use Tether; don't add it to your OS trust store unless you understand the implication. |
| An agent/script navigates somewhere it shouldn't | `--allow-domains` policy enforced for every face | Allowlist is per-instance today; per-session/agent policies are the planned extension. |
| Tether used to read a page the user has open | Only tabs Tether opened, or tabs explicitly addressed by ID, are driven | Any authenticated client can address any tab by ID. |
| An agent/script navigates somewhere it shouldn't | Global, per-token and per-tab rules, checked in the core and enforced in the browser for top-level navigations (including redirects, clicks and script) | Subresources and `fetch()` from page script are not restricted; give agents `"scripts": false`. |
| One agent reads or steers another agent's tab | Per-token ownership: non-admin tokens only resolve their own tabs, including via cookie, header and WebDriver session ID | Admin tokens see everything by design. |
| Tether used to read a page the user has open | Only tabs Tether opened, or tabs explicitly addressed by ID, are driven; adopting requires an admin token | Any admin token can address any tab by ID. |
| Chrome DevTools port exposed | `--launch` binds it to loopback; Tether never re-exposes it | If you start Chrome yourself with `--remote-debugging-address=0.0.0.0`, that is outside Tether's control. |
| Secrets printed to the console by a page (tokens, PII in debug logs) | Console capture is only readable through the authenticated listener and is never written to Tether's own logs | Any token holder can read the captured console of any tab. `--console-buffer 0` turns capture off. |
| Sites detecting automation via `Runtime.enable` | — | Console capture enables the `Runtime` domain, which some anti-bot scripts can detect. `--console-buffer 0` leaves it off. |
Expand Down
27 changes: 23 additions & 4 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,11 +54,12 @@ flowchart LR
| `internal/app` | Assembles a running Tether from a `Config`: Chrome, core, faces, server. Used by the CLI and the integration tests. |
| `internal/cdp` | The only package that speaks CDP. `Browser` and `Tab` interfaces; `Chrome` implementation over a single flattened websocket; endpoint discovery and `Launch`. `cdptest` holds fakes. |
| `internal/session` | `Session` = one page target. `Manager` resolves requests to sessions, opens/adopts/closes tabs, reaps idle ones, and serialises work per tab. |
| `internal/core` | `Core.Do` (execute an HTTP request in a tab), `Eval`, `Navigate`, the `Policy` seam, header hygiene, and error classification shared by HTTP faces. |
| `internal/policy` | Domain rules, principals (tokens) and the policy file. A leaf package shared by session and core. |
| `internal/core` | `Core.Do` (execute an HTTP request in a tab), `Eval`, `Navigate`, the layered `RulesPolicy` and browser-side guard, header hygiene, and error classification shared by HTTP faces. |
| `internal/proxy` | Forward-proxy face. Plain HTTP and CONNECT with TLS interception via `internal/certs`. |
| `internal/webdriver` | W3C WebDriver face (see [WEBDRIVER.md](WEBDRIVER.md)). |
| `internal/api` | Control plane: health, tabs, eval, CA download. |
| `internal/server` | Listener, routing, token auth, loopback guard, graceful shutdown, exposure checks. |
| `internal/api` | Control plane: health, whoami, tabs, tab policies, eval, CA download. |
| `internal/server` | Listener, routing, per-token auth, loopback guard, graceful shutdown, exposure checks. |
| `internal/mcp`, `internal/remote` | Planned faces, mounted and answering `501` today. |
| `internal/certs`, `internal/logx`, `internal/config`, `internal/version`, `internal/httpx` | Supporting pieces. |

Expand Down Expand Up @@ -140,12 +141,30 @@ proxy auth and Tether control headers never reach the page.
|---|---|---|
| HTTP proxy | **Shipping** | `Sessions.Resolve`, `Core.Do` |
| WebDriver | **Shipping** | `Sessions.Create/Get/Close`, `Core.Navigate`, `cdp.Tab` primitives |
| MCP server | Stub (`/mcp` → 501) | Will map tools (`navigate`, `read_page`, `click`, `type`, `eval`, `screenshot`, `tabs`) onto the same calls. `core.Policy` becomes per-agent permissions. Streamable HTTP transport on the same listener, so it inherits auth and Tailscale exposure. |
| MCP server | Stub (`/mcp` → 501) | Will map tools (`navigate`, `read_page`, `click`, `type`, `eval`, `screenshot`, `tabs`) onto the same calls. Each agent authenticates with its own token, so per-token policies and tab ownership apply unchanged. Streamable HTTP transport on the same listener, so it inherits auth and Tailscale exposure. |
| Remote UI | Stub (`/remote` → 501) | Will stream a tab with `Page.startScreencast` (WebRTC later) and forward input through `cdp.Tab.Click`/`Key`, letting a person watch or take over exactly the tab an agent is driving. |

Every face implements `core.Face` (`Name()` + `http.Handler`) and is mounted
by the server; adding one is a new package and one line in `internal/app`.

## Policy and identity

`internal/policy` is a leaf package: domain `Rules`, the `Principal` a request
acts as (one per token, carried in the request context by the server's
authenticator), and the policy file. The layers meet in two places:

- **`session.Manager`** records each tab's owner and refuses non-admin
principals any tab they don't own (`Get`, `Resolve`, `Close`); per-site
default tabs are keyed by principal.
- **`core.RulesPolicy`** checks global ∩ requesting principal ∩ owning
principal ∩ the tab's session rules on every `Do`/`Navigate`, and re-checks
the final URL. `Core.Prepare` installs `cdp.Tab.Guard` on restricted tabs: a
`Fetch`-domain interceptor that holds every top-level document request to
the same policy inside Chrome.

Faces stay policy-agnostic: they pass the request context through and call
the core. See [POLICIES.md](POLICIES.md) for the user-facing model.

## Design choices

- **CDP, no extension.** Tether attaches over the DevTools Protocol, which
Expand Down
10BC8
127 changes: 127 additions & 0 deletions docs/POLICIES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
# Policies: per-token and per-session rules

Tether hands out a logged-in browser, so "who may take it where" matters.
Policies answer that in three layers, all enforced in the core — every face
(proxy, WebDriver, control API, and the MCP and remote-UI faces as they land)
is held to them.

| Layer | Set by | Scope |
|---|---|---|
| **Global** | `--allow-domains`, `--deny-domains`, the policy file's top-level `allow`/`deny` | Every request, every tab |
| **Token** | The policy file's `tokens[]` | Requests made with that token, *and* every tab it opens (the rules follow the tab) |
| **Session** | `POST /tether/v1/tabs` `{"policy": …}`, `PUT /tether/v1/tabs/{id}/policy`, WebDriver `tether:options.policy` | One tab |

A URL is allowed only if **every** layer allows it: global ∩ requesting
token ∩ owning token ∩ session. A lower layer can only narrow, and `deny`
always beats `allow`.

## Rule syntax

```json
{"allow": ["example.com", "*.github.io"], "deny": ["admin.example.com"]}
```

- `example.com` matches `example.com` and every subdomain.
- `*.example.com` matches subdomains only.
- `*` matches every host.
- An empty `allow` means "anything not denied".
- As soon as a layer has any rule, only `http`, `https`, `about:` and `data:`
URLs pass it (`file:`, `chrome:` and `javascript:` do not).

## Tokens and the policy file

Give each agent or script its own token, so each gets its own rules, its own
tabs and its own name in the logs.

```sh
tether serve --policy-file examples/policy.json
```

```json
{
"deny": ["accounts.google.com"],
"tokens": [
{"name": "research-agent", "token_env": "TETHER_RESEARCH_TOKEN",
"allow": ["wikipedia.org", "arxiv.org"], "scripts": false},
{"name": "mail-triage", "token_file": "mail-triage.token", "allow": ["mail.google.com"]},
{"name": "ops", "token_env": "TETHER_OPS_TOKEN", "admin": true}
]
}
```

| Field | Default | |
|---|---|---|
| `name` | required | 1–64 of `A-Z a-z 0-9 . _ -`; shown in logs, tab listings and errors |
| `token` / `token_env` / `token_file` | exactly one | The secret (≥16 chars). `token_file` is relative to the policy file. Prefer `token_env` or `token_file` over inline secrets. |
| `allow`, `deny` | none | This token's rules |
| `admin` | `false` | See and drive every tab, including other tokens' tabs and tabs the user already had open (adoption) |
| `scripts` | `true` | Allow arbitrary JavaScript: `POST /tether/v1/tabs/{id}/eval` and WebDriver *Execute Script* |

`--token` still works and is the admin token named `default`. A policy file
with tokens enables authentication on its own, so it also satisfies the
"token required off loopback" rule. Unknown fields, duplicate names and
duplicate secrets are startup errors.

`GET /tether/v1/whoami` tells a client which token it is and what it may do.

## Tab ownership

- A tab belongs to the token that opened it. Non-admin tokens see and drive
only their own tabs; another token's tab, or a tab the user opened, looks
exactly like a tab that does not exist (`410 tab_gone`, WebDriver
`invalid session id`), so tokens learn nothing about each other.
- The proxy's default tab per site is per token: two agents browsing
`github.com` get two tabs.
- Only admin tokens can adopt tabs Tether did not open.
- With no authentication configured (loopback, no token) everything behaves
as before: one implicit admin.

## Session rules

Narrow a single tab, for a task that should stay on one site:

```sh
# open a tab locked to one site
curl -s -X POST http://127.0.0.1:8787/tether/v1/tabs \
-d '{"url":"https://github.com/me","policy":{"allow":["github.com"],"deny":["gist.github.com"]}}'

# inspect every layer that applies to a tab
curl -s http://127.0.0.1:8787/tether/v1/tabs/$TAB/policy

# replace the tab's session rules ({} clears them)
curl -s -X PUT http://127.0.0.1:8787/tether/v1/tabs/$TAB/policy -d '{"allow":["github.com"]}'
```

WebDriver:

```json
{"capabilities": {"alwaysMatch": {"tether:options": {"policy": {"allow": ["example.com"]}}}}}
```

Any token that can use a tab may change its session rules: they can never
grant more than the global and token rules already allow.

## Enforcement: in the core *and* in the browser

Checking the URL a client asks for is not enough: a page can redirect, a
WebDriver click can follow a link, and script can assign `location`. So for
every restricted tab (any layer has rules), Tether installs a browser-side
guard over the DevTools `Fetch` domain: every top-level document request the
tab makes is paused and allowed only if the tab's policy (global ∩ owning
token ∩ session) allows it; otherwise Chrome fails it with
`net::ERR_BLOCKED_BY_CLIENT` and the forbidden page never loads.
Unrestricted tabs, including the user's own tabs under an admin token, are
never intercepted.

As a second line, the core re-checks the URL a request finally landed on.
Requests that end somewhere forbidden return `403 forbidden`, and a
navigation that got there leaves the tab on `about:blank`.

## Limits

- Rules govern **navigation** (top-level documents). Subresources and
third-party iframes on an allowed page load normally, and script with the
`scripts` permission can still `fetch()` other origins. Set
`"scripts": false` for tokens that should not run arbitrary JavaScript.
- Rules match host names, not paths or ports.
- Tokens are static; change the policy file and restart to rotate them.
5 changes: 5 additions & 0 deletions docs/WEBDRIVER.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ With a token: `ClientConfig(remote_server_addr=..., auth_type=AuthType.BEARER, t
`{"capabilities": {"alwaysMatch": {"tether:options": {"tabId": "<id>"}}}}`.
Attached tabs are left open when the session ends (override with
`"keepTab": false`).
- Session rules: `{"tether:options": {"policy": {"allow": ["example.com"], "deny": ["admin.example.com"]}}}`
restricts where the session's tab may navigate, on top of the global and
token rules (see [POLICIES.md](POLICIES.md)). Navigating outside them fails
with `unsupported operation`; *Execute Script* needs a token with the
`scripts` permission.
- One session = one tab = one window handle. Window switching, new windows and
frames are out of scope.

Expand Down
21 changes: 21 additions & 0 deletions examples/policy.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"deny": ["accounts.google.com", "*.bank.example"],
"tokens": [
{
"name": "research-agent",
"token_env": "TETHER_RESEARCH_TOKEN",
"allow": ["wikipedia.org", "arxiv.org", "github.com"],
"scripts": false
},
{
"name": "mail-triage",
"token_file": "mail-triage.token",
"allow": ["mail.google.com"]
},
{
"name": "ops",
"token_env": "TETHER_OPS_TOKEN",
"admin": true
}
]
}
Loading
Loading
0