diff --git a/README.md b/README.md index 973e556..c9a6ec7 100644 --- a/README.md +++ b/README.md @@ -271,7 +271,7 @@ The API is split into two groups: | **Browser** | Check if Chrome CDP is reachable; stream a live browser screencast via WebSocket at `/api/browser/ws/{project_id}`. | | **Projects** | Pause/resume/reset projects, update config. | -> **The internal API has no authentication of its own** (as of 2026-09). None of the credential, capability, membership, approval or event-log machinery above applies to it, and that includes the WebSocket surfaces — `/ws`, `/api/terminal/ws/{project_id}` (a shell in the sandbox container), `/api/browser/ws/{project_id}` (CDP relay: screenshots, input, navigation), `/api/desktop/websockify`. The optional `PROXY_AUTH_*` check covers HTTP requests only, not WebSockets (see [Environment Variables](#environment-variables)). Treat the bound port as trusted-network-only: loopback, or a tailnet / authenticating proxy in front. Details: [`docs/security/agent-identity.md`](docs/security/agent-identity.md). +> **The internal API is open unless you set `INTERNAL_API_KEY`.** By default it has no authentication, and none of the credential, capability, membership, approval or event-log machinery above applies to it — including the WebSocket surfaces: `/ws`, `/api/terminal/ws/{project_id}` (a shell in the sandbox container), `/api/browser/ws/{project_id}` (CDP relay: screenshots, input, navigation), `/api/desktop/websockify`, and every workspace file via `/api/workspace/{project}/download`. With `INTERNAL_API_KEY` set, `/api` and the websockets require the `X-Internal-Key` header or a browser session from `POST /api/session/login`. The optional `PROXY_AUTH_*` check covers HTTP requests only, not WebSockets. Without a key, treat the port as trusted-network-only (loopback or a tailnet); **before any public exposure — ngrok, `tailscale funnel`, any tunnel — set the key.** Details: [`docs/security/exposure.md`](docs/security/exposure.md), [`docs/security/agent-identity.md`](docs/security/agent-identity.md). > See `http://localhost:8000/docs` for the full list of endpoints with request/response schemas. diff --git a/docs/security/exposure.md b/docs/security/exposure.md new file mode 100644 index 0000000..f03c81d --- /dev/null +++ b/docs/security/exposure.md @@ -0,0 +1,67 @@ +# Exposing TeamWork — Tailscale, ngrok, and who can open your files + +TeamWork binds to loopback by default. How you reach it from elsewhere decides +who else can reach it, and TeamWork serves more than a chat page: the **file +browser, file downloads, the terminal, the browser screencast and the desktop** +all live behind the same address. + +## The short version + +| How you expose it | Who can reach it | Required | +|---|---|---| +| `tailscale serve` (recommended) | Devices on your tailnet only | Nothing more; `INTERNAL_API_KEY` still advised | +| `tailscale funnel` | **The whole internet** | `INTERNAL_API_KEY` (or proxy auth) | +| ngrok, Cloudflare Tunnel, any public tunnel | **The whole internet** | `INTERNAL_API_KEY` (or proxy auth) | +| Behind an authenticating proxy (IAP, oauth2-proxy) | Whoever the proxy lets in | `PROXY_AUTH_ENABLED` so TeamWork checks the proxy's assertion | + +**Prefer `tailscale serve`.** Only your own devices can connect, and nothing is +published to the internet at all. A public tunnel's URL being random or hard to +guess is obscurity, not access control: URLs leak through browser history, +screenshots, shared links, logs and referrer headers, and tunnel hostnames are +scanned. + +## What is open without a login + +Out of the box, TeamWork has **no login** (`INTERNAL_API_KEY` is empty and +proxy auth is off). On a tailnet, that means "anyone on my tailnet". Over a +public tunnel, it means **anyone on the internet**, including: + +- every file in the agent's workspace, through the file browser and + `GET /api/workspace/{project}/download?path=…` — not just files posted to + chat; +- the chat history and every message; +- the shared terminal and the live browser and desktop, where the agent may be + logged in to your accounts. + +## Turn on the login before any public exposure + +Set `INTERNAL_API_KEY` in TeamWork's `.env` and restart TeamWork. Every `/api` +request and websocket then needs either the `X-Internal-Key` header (for +agents and scripts) or the browser session cookie you get by logging in once +(`POST /api/session/login`). The cookie is `HttpOnly`, `SameSite=Strict`, +`Secure` over HTTPS. + +What that means for file links in chat: + +- **Links and embedded images keep working for you.** An agent posts files as + relative links (`/api/workspace/…/download?path=…`), so they resolve against + whatever address you opened TeamWork at — tailnet name, localhost or tunnel + URL — and your browser sends the session cookie with them, so images still + display inline. +- **A copied link does not work for anyone else.** Pasted into another app or + sent to someone, it needs a TeamWork login. That is the point: a link to a + workspace file is not a public share. + +To publish something to people without a TeamWork login, use the agent's +explicit share feature, which issues a token for that one file and can revoke +it. Don't open TeamWork itself to the internet for that. + +## Checklist for a public tunnel + +1. `INTERNAL_API_KEY` set, TeamWork restarted, and a logged-out browser gets + `401` on `/api/workspace//files`. +2. The tunnel points at TeamWork only — never at the agent's own port or a dev + server. (For Prax, see its + [network-exposure guide](https://github.com/praxagent/prax/blob/main/docs/security/network-exposure.md).) +3. You would be comfortable with the workspace contents leaking if the key did. + If not, use `tailscale serve` instead.