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.

Synced from Prax at f7a8cb56 View source ↗

← Security

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 binds 0.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. Binding 0.0.0.0 on 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.

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:

  1. Set INTERNAL_API_KEY in TeamWork and log in once. Every /api call 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.
  2. Tunnel TeamWork only — never Prax :5001 or a dev server (see the anti-patterns below).
  3. 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.0 is safe only if an authenticating reverse proxy is the sole ingress AND a firewall admits only that proxy. Never put the raw app ports on 0.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:

  1. 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). Set TEAMWORK_HOST=0.0.0.0. Leave PRAX_HOST=127.0.0.1 — Prax :5001 is 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 unless PRAX_API_KEY is set — TEAMWORK_API_KEY is only sent outbound by prax/services/teamwork_service.py. With PRAX_API_KEY set (prax/blueprints/inbound_auth.py), those three route groups require a matching X-API-Key header (constant-time compare, 401 otherwise); TeamWork’s Prax proxy routers (teamwork/src/teamwork/routers/prax.py) send it when TeamWork’s own PRAX_API_KEY matches. Set it in both .env files whenever :5001 is reachable by anything other than TeamWork; unset (the default), whoever can reach :5001 can drive Prax.

  2. 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/16 and 130.211.0.0/22 (health checks + IAP) only; enable IAP on the backend service; grant IAP-secured Web App User to the allowed identities.
    • Cloudflare: allow only Cloudflare’s published IP ranges — or, better, use a cloudflared tunnel 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.
  3. Authenticate at the proxy (IAP = Google identity; Access = your IdP; oauth2-proxy = OIDC). The app is never the auth boundary.

  4. 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 this
    

    The preset fills the header (x-goog-iap-jwt-assertion / cf-access-jwt-assertion), algorithms (ES256 / RS256), JWKS URL, and issuer; *_HEADER, *_JWKS_URL, *_ALGORITHMS, *_ISSUER are overridable for a custom provider. Every HTTP request except PROXY_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 is 401. 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’s BaseHTTPMiddleware, whose __call__ passes any non-http scope 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 handles websocket scopes.

  5. 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 (:8000 serves 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 from 0.0.0.0/0

Anti-patterns

  • ❌ PRAX_HOST=0.0.0.0 on 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 :5001 or the Vite dev server :5173 to the internet. Only the user-facing TeamWork surface should ever be fronted.
  • ❌ ngrok http 5001 as the Twilio webhook tunnel — which is what scripts/ngrok-launch.sh runs when NGROK_AUTHTOKEN is 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, /transcribe and /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.