# The identity trap — a header is only an identity if the thing in front of you put it there This is the one lesson from *The laptop in the closet* that applies to every service behind a proxy. It is written down because it cost a day: the remote-access code trusted an identity header that one proxy injected and the other proxy passed through. ## The two front doors | Front door | Injects | Strips client-sent `Tailscale-*` | What the guard saw | |---|---|---|---| | `tailscale serve` (tailnet) | `Tailscale-User-Login` | **yes** | the real tailnet identity | | Cloudflare Tunnel + Access | `Cf-Access-Authenticated-User-Email` | **no** | nothing, or whatever the client sent | The guard was written for the first row. It trusted `Tailscale-User-Login` whenever it was present. On the second row: 1. Every tester arrived **identity-less** — so a guard that demanded a tailnet identity would have 403'd all of them. The fix was an explicit `REMOTE_ALLOW_ANONYMOUS=1` that maps them to one `remote-anonymous` sentinel, which can never equal the operator identity. 2. Worse: `cloudflared` forwards `Tailscale-*` headers from the client, so a tester could send `Tailscale-User-Login: ` and the guard would resolve them as the owner. That is a full read of the operator's history from a tester's laptop. ## The fix, in two independent layers **Layer 1 — the server.** A flag makes the header inert: `TRUST_TAILSCALE_IDENTITY=0`. The guard no longer resolves identity from a `Tailscale-*` header unless the deployment explicitly says the header is trustworthy. This is the layer that matters, because it holds even if the edge rule is missing. **Layer 2 — the edge.** A Cloudflare Transform Rule removes `Tailscale-User-Login` and `Tailscale-User-Name` on the hostnames, so the header never reaches the origin. This is defense-in-depth: if the flag is ever flipped by mistake, the header is not there to trust. Neither layer alone is sufficient. The flag is code; the edge rule is configuration. A deployment that assumes one is a deployment with a single point of trust. ## The test that proves it The test is a forged header, sent from outside, checking what identity the service resolves. Run it on the box against the loopback origin with the same headers the tunnel sets: ```bash # A remote request with a forged owner identity must NOT read owner data. curl -s -H 'X-Forwarded-For: 203.0.113.x' \ -H 'Tailscale-User-Login: ' \ http://localhost:5001/api/conversations # EXPECT: empty / anonymous scope — NOT the owner's threads. ``` And the guard probes, which must return 403 for a remote caller: ```bash BASE=http://localhost:5001 npx tsx tools/verify-remote-access.ts # expect PASS curl -s -H 'X-Forwarded-For: 203.0.113.x' http://localhost:5001/api/admin/shutdown # → 403 ``` The runbook's own verification section records the same two probes. The important part is that the forged-header probe is **not** "does the header get stripped" — it is "what identity did the request end up with". A stripped header and an inert header both pass; a forwarded header that is believed must fail. ## The general rule Before you trust an identity header, answer three questions for **every** proxy in the path: 1. **Does it inject this header?** (If not, a client can.) 2. **Does it strip a client-sent copy?** (If not, the client can override the proxy.) 3. **Does the origin check that the header came from a trusted peer?** (If not, anyone who can reach the origin sets it.) If any answer is "no" or "I think so", the header is a suggestion, not an identity. Prefer an identity the origin can verify itself — a signed session, a token, a mTLS peer — and treat proxy headers as hints that must be corroborated. ## What this does not cover - The header names and Sentinel behaviour are from this project's runbook; your proxy's headers and your guard's rules will differ. Test *your* pair. - Cloudflare Access is an allowlist, not a per-user authorisation model. Mapping vetted anonymous traffic to one sentinel is an accepted limit of this beta, not a design to copy if you need per-tester data. - The tunnel's credential file is a secret. It lives on the box, `0600`, and it is not in this pack.