Network Exposure & Binding
How Prax and TeamWork decide which network interface they listen on, why the default is loopback only, and how to expose them safely when you don't use Tailscale — e.g. behind Google IAP, Cloudflare Access, or oauth2-proxy.
f7a8cb56
View source ↗
How Prax and TeamWork decide which network interface they listen on, why the default is loopback only, and how to expose them safely when you don’t use Tailscale — e.g. behind Google IAP, Cloudflare Access, or oauth2-proxy.
The model: secure by default, exposure is opt-in
The application processes bind 127.0.0.1 (loopback) by default, so a
host install of Prax/TeamWork is not reachable from the network — only from
the same machine. A reverse proxy on that machine (Tailscale serve, nginx, Caddy)
reaches the app by dialing localhost, so you get remote access without the
app ever listening on a routable interface.
| Process | Port | Bind knob | Default |
|---|---|---|---|
| Prax (Flask) | 5001 | PRAX_HOST (settings.bind_host) |
127.0.0.1 |
| TeamWork backend (uvicorn) | 8000 | TEAMWORK_HOST |
127.0.0.1 (set by the Makefile launch) |
| TeamWork Vite dev server | 5173 | VITE_BIND_ALL=1 → all ifaces |
127.0.0.1 |
Known gap (2026-09): the backing services do not follow this rule. The native
make run-local-all path starts Qdrant with docker run -p 6333:6333 (no
authentication) and Neo4j with docker run -p 7474:7474 -p 7687:7687 -e NEO4J_AUTH=neo4j/prax-memory (Makefile, _local-qdrant / _local-neo4j);
-p HOST:CONTAINER without an address publishes on all interfaces, and
Docker’s own iptables rules sit in front of a host firewall such as ufw. The
compose stack publishes Grafana as 3002:3000 with anonymous access enabled at
the Admin role (docker-compose.yml, GF_AUTH_ANONYMOUS_*). Until those
bind 127.0.0.1: explicitly, the cloud security group / host firewall is the
only thing keeping the vector store, graph DB and Grafana off the network.
Containers are the exception, on purpose. The Prax image sets
ENV PRAX_HOST=0.0.0.0(and TeamWork’s image binds0.0.0.0) because inside a container the network namespace is isolated — the real boundary is the published port / Kubernetes Service / firewall, not the in-container bind. Binding0.0.0.0on the host is what’s dangerous; binding it inside a container whose ports you control is normal.
Rule of thumb: the app should bind 0.0.0.0 only when something else owns
the security boundary — a container’s port mapping, or an authenticating proxy
plus a firewall. If nothing else owns it, loopback is correct.
Scenario A — Tailscale (the default here, recommended)
Keep the loopback binds. Expose TeamWork with tailscale serve: only devices
on your tailnet can reach it. tailscaled runs on the host and dials
localhost:5173/:8000, so no app ever binds a routable port. This is the
safest posture and needs no *_HOST changes. (make run-local-all-tail-dev sets
this up.)
tailscale funnel is different: it publishes the service to the open
internet, with no Tailscale login in front of it. Treat funnel like any public
tunnel — see “Public tunnels” below.
Public tunnels (ngrok, tailscale funnel, Cloudflare Tunnel)
What Prax itself may put on a public link, and how a person approves each share, is in public-exposure.md. In short: TeamWork is trusted (so secure it); a public link needs an explicit decision every time.
It works: files Prax posts to TeamWork chat are relative links
(/api/workspace/…/download?path=…), so they resolve against whatever address
TeamWork was opened at — tailnet name, localhost or tunnel URL.
But a tunnel makes TeamWork reachable by anyone on the internet, and out of the box TeamWork has no login. That exposes every workspace file (through the file API, not only the ones posted to chat), the whole chat history, the shared terminal and the live browser, where Prax may be logged in to your accounts. A random or unlisted tunnel URL is obscurity, not access control: URLs leak through history, screenshots, shared links and logs, and tunnel hostnames are scanned.
Before any public tunnel:
- Set
INTERNAL_API_KEYin TeamWork and log in once. Every/apicall and websocket then needs the key or your browser session. Chat images and links keep working for you (the session cookie goes with them), and a link copied out of the chat needs a login. - Tunnel TeamWork only — never Prax
:5001or a dev server (see the anti-patterns below). - To share one file with someone outside, use Prax’s share links (a revocable token per file, content-publishing.md), not an open TeamWork.
Prefer Scenario A. tailscale serve publishes nothing to the internet; a
tunnel plus a key is one leaked key away from publishing your workspace.
TeamWork’s own guide: TeamWork docs/security/exposure.md.
Scenario B — serve 0.0.0.0 behind an authenticating proxy
When you can’t use Tailscale and want the UI reachable behind, e.g., Google IAP (“app proxy”), Cloudflare Access, AWS ALB + Cognito/OIDC, or oauth2-proxy / nginx with auth. The non-negotiable rule:
Binding
0.0.0.0is safe only if an authenticating reverse proxy is the sole ingress AND a firewall admits only that proxy. Never put the raw app ports on0.0.0.0/0.
Two shapes, in order of preference:
B1 — Proxy on the same host → you DON’T need 0.0.0.0
Run the auth proxy (oauth2-proxy, nginx+auth_request, Caddy forward_auth) on
the same machine. It binds the public port (443) and dials the app at
localhost. Keep the app binds at loopback — they never touch a routable
interface. This is strictly safer than B2; prefer it whenever the proxy can be
co-located.
internet ──443──> oauth2-proxy (host) ──localhost:8000──> TeamWork ──localhost:5001──> Prax
B2 — Proxy on a different host / managed → app must bind 0.0.0.0
Google IAP (GCP HTTPS Load Balancer), Cloudflare (proxied), AWS ALB — the proxy reaches the app over the network, so the app must listen on a routable interface. Then all of the following are required:
-
Expose only the user-facing surface. Put the proxy in front of the TeamWork backend (
:8000, run in prod mode so it serves the built SPA + proxies to Prax). SetTEAMWORK_HOST=0.0.0.0. LeavePRAX_HOST=127.0.0.1— Prax:5001is internal (only TeamWork talks to it); never expose it or the Vite dev server. By default this is a firewall statement, not something the app enforces:POST /teamwork/webhook(which starts an agent turn) and the other/teamwork/*,/plugins/*and/api/users/*routes carry no inbound credential check unlessPRAX_API_KEYis set —TEAMWORK_API_KEYis only sent outbound byprax/services/teamwork_service.py. WithPRAX_API_KEYset (prax/blueprints/inbound_auth.py), those three route groups require a matchingX-API-Keyheader (constant-time compare, 401 otherwise); TeamWork’s Prax proxy routers (teamwork/src/teamwork/routers/prax.py) send it when TeamWork’s ownPRAX_API_KEYmatches. Set it in both.envfiles whenever:5001is reachable by anything other than TeamWork; unset (the default), whoever can reach:5001can drive Prax. -
Firewall the app port to the proxy only — never
0.0.0.0/0:- Google IAP / GCP LB: allow the backend port from the Google LB ranges
35.191.0.0/16and130.211.0.0/22(health checks + IAP) only; enable IAP on the backend service; grantIAP-secured Web App Userto the allowed identities. - Cloudflare: allow only Cloudflare’s published IP ranges — or, better, use
a
cloudflaredtunnel so there is no inbound port at all (this collapses to B1-like safety). - AWS ALB: the instance security group admits only the ALB’s security group; auth via ALB OIDC/Cognito.
- Google IAP / GCP LB: allow the backend port from the Google LB ranges
-
Authenticate at the proxy (IAP = Google identity; Access = your IdP; oauth2-proxy = OIDC). The app is never the auth boundary.
-
Verify the proxy’s signed assertion at the app (defense-in-depth, so a request that bypasses the proxy straight to the bound port is rejected by the app itself — not only by the firewall). TeamWork ships this as a fail-closed, default-off middleware (
teamwork/proxy_auth.py). Enable it on the exposed TeamWork backend:PROXY_AUTH_ENABLED=true PROXY_AUTH_PROVIDER=iap # or: cloudflare_access PROXY_AUTH_AUDIENCE=/projects/<PROJECT_NUMBER>/global/backendServices/<ID> # Cloudflare Access instead: # PROXY_AUTH_PROVIDER=cloudflare_access # PROXY_AUTH_AUDIENCE=<your Access application AUD tag> # PROXY_AUTH_ISSUER=https://<team>.cloudflareaccess.com # JWKS derived from thisThe preset fills the header (
x-goog-iap-jwt-assertion/cf-access-jwt-assertion), algorithms (ES256 / RS256), JWKS URL, and issuer;*_HEADER,*_JWKS_URL,*_ALGORITHMS,*_ISSUERare overridable for a custom provider. Every HTTP request exceptPROXY_AUTH_EXEMPT_PATHS(default/health,/healthz) and CORS preflight must then carry a signature the proxy minted, verified against the provider’s JWKS (audience + issuer + signature). Fail-closed for HTTP: enabled-but-misconfigured refuses to start; a missing/invalid assertion is401. Off by default → a complete no-op for Tailscale / same-host-proxy / dev.Known gap (2026-09): WebSockets bypass this check.
ProxyAuthMiddleware(teamwork/src/teamwork/proxy_auth.py) subclasses Starlette’sBaseHTTPMiddleware, whose__call__passes any non-httpscope straight through, so TeamWork’s WebSocket routes —/ws,/api/terminal/ws/{project_id}(the shared PTY),/api/browser/ws/{project_id},/api/browser/cast/{role},/api/desktop/websockify,/api/desktop/clipboard— are never asked for the assertion. For those surfaces the firewall rule in step 2 (plus the proxy’s own authentication of the upgrade request) is the only control; there is no app-level second layer until the middleware is rewritten as a raw ASGI middleware that handleswebsocketscopes. -
TLS terminates at the proxy; keep the proxy→app hop on a private network.
Minimal checklist for B2
-
TEAMWORK_HOST=0.0.0.0, TeamWork in prod mode (:8000serves the SPA) -
PRAX_HOST=127.0.0.1(Prax stays internal) — and Vite not running in prod - Firewall/SG admits only the proxy’s source ranges to
:8000 - Auth enforced at the proxy (IAP / Access / OIDC)
- App verifies the proxy JWT — set
PROXY_AUTH_ENABLED=true(+ provider/audience) on TeamWork (HTTP routes only — WebSockets rely on the firewall, see above) - No raw port (
5001/5173/8000) reachable from0.0.0.0/0
Anti-patterns
- ❌
PRAX_HOST=0.0.0.0on a cloud VM with the security group open to the world and no auth proxy — this is exactly the exposure loopback-by-default prevents. - ❌ Exposing Prax
:5001or the Vite dev server:5173to the internet. Only the user-facing TeamWork surface should ever be fronted. - ❌
ngrok http 5001as the Twilio webhook tunnel — which is whatscripts/ngrok-launch.shruns whenNGROK_AUTHTOKENis set, and what the Twilio setup in configuration.md tells a host install to run. An ngrok port tunnel is not path-scoped: it publishes every Flask route above (the unauthenticated/teamwork/*,/plugins/*,/api/users/*included) to the public internet, not just/sms,/transcribeand/shared/<token>. Restrict the tunnel to the Twilio paths (a path-filtering rule at the tunnel, or a reverse proxy that forwards only those routes) before using it. - ❌ Relying on “nobody knows the IP/port.” Obscurity is not the firewall.