fix: stop leaking TURN secret to browser clients
All checks were successful
Build / Build & release (push) Successful in 13m40s

WASTE_CONFIG.turnSecret was shipped in plaintext config.js and used to
compute coturn HMAC credentials client-side in browser.ts. Anyone reading
the PWA's JS could read the secret and mint unlimited long-lived TURN
credentials, turning the relay into an open proxy.

The anchor now mints short-lived (1h) credentials server-side via a new
GET /turn-credentials endpoint (-turn-secret flag), mirroring what the
daemon already does. The browser fetches credentials instead of holding
the secret. Daemon mode was unaffected (already server-side).

Docs updated to drop turnSecret from config.js examples, document the
new nginx route, and instruct anyone with an old config.js to rotate
the coturn secret since it was previously exposed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Fredrik Johansson
2026-06-30 18:48:59 +02:00
parent bf4009558d
commit f425e0bb8e
5 changed files with 84 additions and 23 deletions

View File

@@ -151,9 +151,13 @@ entries from all applicable share roots, with relative `path` fields
**Status:** implemented (browser mode + daemon mode) **Status:** implemented (browser mode + daemon mode)
**Affects:** ICE server configuration only, no wire changes **Affects:** ICE server configuration only, no wire changes
The browser adapter reads `WASTE_CONFIG.turnURL` and `WASTE_CONFIG.turnSecret` The browser adapter reads `WASTE_CONFIG.turnURL` and fetches short-lived
and adds a TURN server to the WebRTC `ICEServers` list. Credentials are credentials from the anchor's `GET /turn-credentials` endpoint (derived from
generated using HMAC-SHA1 of the username (coturn `use-auth-secret` scheme). `WASTE_CONFIG.signalURL`, or overridden via `WASTE_CONFIG.turnCredentialsURL`).
The anchor computes the credential using HMAC-SHA1 of the username (coturn
`use-auth-secret` scheme) — the shared secret itself is never sent to the
browser. Daemon mode does the equivalent computation locally, since the
daemon already holds `-turn-secret` server-side.
YAW/2 §0 explicitly declines TURN ("No relay (TURN)"). This extension is YAW/2 §0 explicitly declines TURN ("No relay (TURN)"). This extension is
opt-in via server configuration and does not affect peers that omit it. opt-in via server configuration and does not affect peers that omit it.

View File

@@ -41,7 +41,9 @@ Solved by using WebRTC DataChannels via pion. ICE gathers host + server-reflexiv
### TURN relay ✅ (shipped) ### TURN relay ✅ (shipped)
Both browser and daemon modes support TURN relay. Both browser and daemon modes support TURN relay.
**Browser mode:** `iceServers()` in `browser.ts` reads `WASTE_CONFIG.turnURL` and `WASTE_CONFIG.turnSecret`, generates time-limited HMAC-SHA1 credentials (compatible with coturn `use-auth-secret`), and adds the TURN server to the ICE candidate list. The peer dot turns yellow for relayed connections (`candidate_type: relay`). **Browser mode:** `iceServers()` in `browser.ts` reads `WASTE_CONFIG.turnURL` and fetches a time-limited credential from the anchor's `GET /turn-credentials` endpoint (HMAC-SHA1, compatible with coturn `use-auth-secret`) rather than holding the shared secret client-side. The peer dot turns yellow for relayed connections (`candidate_type: relay`).
> **Security fix:** earlier this previously embedded `turnSecret` directly in `WASTE_CONFIG`, which let anyone reading the PWA's JS mint unlimited long-lived TURN credentials. The secret now lives only on the anchor (`-turn-secret` flag); the anchor mints short-lived credentials per-request instead.
**Daemon mode:** `-turn-url` and `-turn-secret` flags on `cmd/daemon`. `turnICEServers()` in `internal/netmgr/manager.go` generates HMAC-SHA1 credentials and injects them into the ICE server list for every new peer connection. **Daemon mode:** `-turn-url` and `-turn-secret` flags on `cmd/daemon`. `turnICEServers()` in `internal/netmgr/manager.go` generates HMAC-SHA1 credentials and injects them into the ICE server list for every new peer connection.

View File

@@ -134,7 +134,7 @@ This tells the browser where to connect for signaling. Without it the join form
### 3. Nginx Proxy Manager setup ### 3. Nginx Proxy Manager setup
Create one **Proxy Host** for your domain (e.g. `waste.example.com`) with TLS enabled. You need two locations: Create one **Proxy Host** for your domain (e.g. `waste.example.com`) with TLS enabled. You need these locations:
**Location 1 — WebSocket signaling (`/ws`)** **Location 1 — WebSocket signaling (`/ws`)**
- Location: `/ws` - Location: `/ws`
@@ -142,6 +142,12 @@ Create one **Proxy Host** for your domain (e.g. `waste.example.com`) with TLS en
- Forward port: `8080` - Forward port: `8080`
- Enable: WebSockets Support - Enable: WebSockets Support
**Location 1b — TURN credentials (`/turn-credentials`, only if using TURN — see [step 4](#4-turn-relay-optional-fixes-mobile--cgnat))**
- Location: `/turn-credentials`
- Forward hostname/IP: `127.0.0.1`
- Forward port: `8080`
- Plain HTTP, no WebSockets toggle needed
**Location 2 — Web UI (catch-all)** **Location 2 — Web UI (catch-all)**
- Location: `/` - Location: `/`
- Choose "Serve Static Files" (or point to a local HTTP server serving `/var/www/waste-web`) - Choose "Serve Static Files" (or point to a local HTTP server serving `/var/www/waste-web`)
@@ -162,6 +168,7 @@ Or use `serve-web.sh` which handles PID tracking and restart:
The key requirements: The key requirements:
- `/ws` → anchor process (WebSocket, keep-alive) - `/ws` → anchor process (WebSocket, keep-alive)
- `/turn-credentials` → anchor process (plain HTTP; only needed if using TURN)
- `/*` → static file server (SPA fallback: return `index.html` for unknown paths) - `/*` → static file server (SPA fallback: return `index.html` for unknown paths)
### 4. TURN relay (optional, fixes mobile / CGNAT) ### 4. TURN relay (optional, fixes mobile / CGNAT)
@@ -200,21 +207,32 @@ systemctl enable coturn
systemctl start coturn systemctl start coturn
``` ```
**Update `config.js`** to tell browsers about the TURN server: **Start the anchor with the same secret**, so it can mint short-lived credentials on your behalf:
```bash
./waste-anchor -bind 0.0.0.0:8080 -turn-secret YOUR_SECRET_HERE
```
This enables `GET /turn-credentials` on the anchor, which returns a fresh `{username, credential}` pair (1-hour TTL) computed from the shared secret — the secret itself never leaves the server.
**Update `config.js`** to tell browsers about the TURN server (no secret here — only the public relay address):
```js ```js
window.WASTE_CONFIG = { window.WASTE_CONFIG = {
signalURL: 'wss://your-domain.com/ws', signalURL: 'wss://your-domain.com/ws',
turnURL: 'turn:your-domain.com:3478', turnURL: 'turn:your-domain.com:3478',
turnSecret: 'YOUR_SECRET_HERE',
} }
``` ```
The `use-auth-secret` mode generates short-lived TURN credentials from the shared secret — no user database required. The relay only sees opaque DTLS-encrypted blobs. > **Security note:** earlier versions of this doc had you put `turnSecret` directly in `config.js`. Don't — anyone reading the PWA's JS bundle could read it and mint unlimited, long-lived TURN credentials, turning your relay into an open proxy for anyone. The browser now calls the anchor's `/turn-credentials` endpoint instead and only ever sees a credential that expires in an hour. If you have an old `config.js` with `turnSecret` set, remove it and rotate the coturn secret (`static-auth-secret` in `turnserver.conf` and the anchor's `-turn-secret` flag) since the old one was exposed.
> The browser adapter reads `turnURL` and `turnSecret` from `WASTE_CONFIG` and adds the TURN server to the WebRTC `ICEServers` list automatically. If not configured, STUN-only is used (works for most desktop/home NAT situations). The browser adapter calls `signalURL` with `/ws` swapped for `/turn-credentials` to find the anchor's endpoint by default; set `turnCredentialsURL` explicitly in `WASTE_CONFIG` if the anchor is reachable at a different path. If `turnURL` is set but the credentials endpoint is unreachable, the browser falls back to STUN-only.
**Daemon mode TURN:** pass `-turn-url turn:your-domain.com:3478 -turn-secret YOUR_SECRET_HERE` when starting the daemon. The same coturn `use-auth-secret` HMAC-SHA1 scheme is used — no extra config required beyond what you set up for browser mode. You'll also need nginx to route the new path to the anchor, alongside `/ws` (see [step 3](#3-nginx-proxy-manager-setup)):
- `/turn-credentials` → anchor process (plain HTTP, no WebSocket upgrade needed)
**Daemon mode TURN:** pass `-turn-url turn:your-domain.com:3478 -turn-secret YOUR_SECRET_HERE` when starting the daemon. This is unaffected by the above — the daemon computes credentials itself server-side and never exposes the secret, same as the anchor now does for browser mode.
--- ---

View File

@@ -6,12 +6,17 @@ package main
import ( import (
"context" "context"
"crypto/ed25519" "crypto/ed25519"
"crypto/hmac"
"crypto/rand" "crypto/rand"
"crypto/sha1"
"crypto/sha256" "crypto/sha256"
"encoding/base64"
"encoding/hex" "encoding/hex"
"encoding/json"
"flag" "flag"
"log" "log"
"net/http" "net/http"
"strconv"
"sync" "sync"
"time" "time"
@@ -23,16 +28,40 @@ import (
func main() { func main() {
bind := flag.String("bind", "0.0.0.0:17339", "address to listen on") bind := flag.String("bind", "0.0.0.0:17339", "address to listen on")
turnSecret := flag.String("turn-secret", "", "coturn use-auth-secret shared secret; enables GET /turn-credentials")
flag.Parse() flag.Parse()
a := newAnchor() a := newAnchor()
http.HandleFunc("/ws", a.handleWS) http.HandleFunc("/ws", a.handleWS)
if *turnSecret != "" {
http.HandleFunc("/turn-credentials", turnCredentialsHandler(*turnSecret))
log.Printf("anchor: /turn-credentials enabled")
}
log.Printf("anchor: listening on %s", *bind) log.Printf("anchor: listening on %s", *bind)
if err := http.ListenAndServe(*bind, nil); err != nil { if err := http.ListenAndServe(*bind, nil); err != nil {
log.Fatalf("anchor: %v", err) log.Fatalf("anchor: %v", err)
} }
} }
// turnCredentialsHandler mints short-lived coturn use-auth-secret credentials
// server-side, so the shared secret never reaches the browser. Mirrors the
// scheme in internal/netmgr.Manager.turnICEServers (daemon mode).
func turnCredentialsHandler(secret string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Content-Type", "application/json")
expiry := strconv.FormatInt(time.Now().Add(time.Hour).Unix(), 10)
mac := hmac.New(sha1.New, []byte(secret))
mac.Write([]byte(expiry))
credential := base64.StdEncoding.EncodeToString(mac.Sum(nil))
json.NewEncoder(w).Encode(struct {
Username string `json:"username"`
Credential string `json:"credential"`
TTL int `json:"ttl"`
}{Username: expiry, Credential: credential, TTL: 3600})
}
}
// ── Anchor ──────────────────────────────────────────────────────────────────── // ── Anchor ────────────────────────────────────────────────────────────────────
type client struct { type client struct {

View File

@@ -16,23 +16,31 @@ const EKEY_PREFIX = 'yaw/2.1 ekey'
const FS_TIMEOUT = 2000 const FS_TIMEOUT = 2000
const STUN = 'stun:stun.l.google.com:19302' const STUN = 'stun:stun.l.google.com:19302'
async function turnCredential(secret: string, username: string): Promise<string> { // TURN credentials are short-lived and minted server-side by the anchor's
const key = await crypto.subtle.importKey( // GET /turn-credentials endpoint (see cmd/anchor). The shared coturn secret
'raw', new TextEncoder().encode(secret), // never reaches the browser — only a time-limited username/credential pair.
{ name: 'HMAC', hash: 'SHA-1' }, async function fetchTurnCredentials(credentialsURL: string): Promise<{ username: string; credential: string } | null> {
false, ['sign'] try {
) const res = await fetch(credentialsURL)
const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(username)) if (!res.ok) return null
return btoa(String.fromCharCode(...new Uint8Array(sig))) const data = await res.json()
if (!data.username || !data.credential) return null
return { username: data.username, credential: data.credential }
} catch {
return null
}
} }
async function iceServers(): Promise<RTCIceServer[]> { async function iceServers(): Promise<RTCIceServer[]> {
const cfg = (window as unknown as { WASTE_CONFIG?: { turnURL?: string; turnSecret?: string } }).WASTE_CONFIG const cfg = (window as unknown as { WASTE_CONFIG?: { turnURL?: string; turnCredentialsURL?: string; signalURL?: string } }).WASTE_CONFIG
const servers: RTCIceServer[] = [{ urls: STUN }] const servers: RTCIceServer[] = [{ urls: STUN }]
if (cfg?.turnURL && cfg?.turnSecret) { if (cfg?.turnURL) {
const user = Math.floor(Date.now() / 1000) + 3600 + ':waste' const credentialsURL = cfg.turnCredentialsURL
const credential = await turnCredential(cfg.turnSecret, user) ?? cfg.signalURL?.replace(/^ws/, 'http').replace(/\/ws\/?$/, '/turn-credentials')
servers.push({ urls: cfg.turnURL, username: user, credential }) if (credentialsURL) {
const creds = await fetchTurnCredentials(credentialsURL)
if (creds) servers.push({ urls: cfg.turnURL, username: creds.username, credential: creds.credential })
}
} }
return servers return servers
} }