feat(ned): CSRF/DNS-rebinding guard on TCP listener; deprecate web tokens #83

Merged
clanker merged 1 commit from pr/ned-csrf-host-guard into main 2026-09-08 22:05:53 +00:00
Member

Closes the two holes an ACL-only security model can't cover, and deprecates token auth for the web client.

What and why

Tailnet ACLs + the Unix socket's OS permissions are now the security story. This PR adds the minimal in-app surface that network ACLs structurally can't provide, and removes the token-by-URL path that leaked secrets into history.

1. CSRF / DNS-rebinding guard on the TCP listener (ned/handler.py, ned/daemon.py)

  • Host-header allowlist: the listener only answers for loopback names, the configured bind host, and detected Tailscale IP / MagicDNS / Serve hostnames. Arbitrary hostnames (evil.example rebound to 127.0.0.1 or the Tailscale IP) get 403 Forbidden host header.
  • Origin match: when a browser sends an Origin header, its hostname (and explicit port) must match the Host header. No third-party website can drive state-changing POSTs (trash, expunge, send, sync, ...) against a tokenless listener. Non-browser clients (desktop, ned-client, ned-mcp, curl) send no Origin and are unaffected.
  • The allowlist is skipped only for 0.0.0.0/:: binds made via --allow-insecure; the Origin match still applies there.

2. Token deprecation for the web client (#3)

  • The legacy ?token= query parameter is removed from _check_auth (it leaked the secret into URLs/browser history; the SSE EventSource was the only consumer).
  • The PWA no longer tokenizes its SSE URL — it relies on Tailscale ACLs / local access like everything else.
  • Authorization: Bearer still works for non-browser clients; settings.web_token and --token are marked DEPRECATED.
  • Docs updated: docs/api.md (transports & auth), README.md Tailscale Serve walkthrough (dropped the token), OpenAPI security scheme note.

Testing (per verify skill, all green)

  • pytest: 515 passed (added 11 request-level guard tests + host parsing/allowlist construction to test_ned_security.py)
  • mypy on changed files: clean (the 4 remaining ned/client.py errors are pre-existing on main)
  • pyflakes: only the pre-existing subprocess unused-import warning in ned/main.py

Suggested review steps

  1. git checkout pr/ned-csrf-host-guard
  2. QT_QPA_PLATFORM=offscreen ~/.local/share/pipx/venvs/lazarus-mail/bin/python -m pytest tests/test_ned_security.py tests/test_ned.py -q
  3. Manual: ned --host 127.0.0.1 --port 8080, then curl -H "Host: evil.example" http://127.0.0.1:8080/api/v1/ping → 403.
Closes the two holes an ACL-only security model can't cover, and deprecates token auth for the web client. ## What and why Tailnet ACLs + the Unix socket's OS permissions are now the security story. This PR adds the minimal in-app surface that network ACLs structurally can't provide, and removes the token-by-URL path that leaked secrets into history. ### 1. CSRF / DNS-rebinding guard on the TCP listener (`ned/handler.py`, `ned/daemon.py`) - **Host-header allowlist**: the listener only answers for loopback names, the configured bind host, and detected Tailscale IP / MagicDNS / Serve hostnames. Arbitrary hostnames (`evil.example` rebound to `127.0.0.1` or the Tailscale IP) get `403 Forbidden host header`. - **Origin match**: when a browser sends an `Origin` header, its hostname (and explicit port) must match the `Host` header. No third-party website can drive state-changing POSTs (`trash`, `expunge`, `send`, `sync`, ...) against a tokenless listener. Non-browser clients (desktop, `ned-client`, `ned-mcp`, curl) send no `Origin` and are unaffected. - The allowlist is skipped only for `0.0.0.0`/`::` binds made via `--allow-insecure`; the Origin match still applies there. ### 2. Token deprecation for the web client (#3) - The legacy `?token=` query parameter is **removed** from `_check_auth` (it leaked the secret into URLs/browser history; the SSE `EventSource` was the only consumer). - The PWA no longer tokenizes its SSE URL — it relies on Tailscale ACLs / local access like everything else. - `Authorization: Bearer` still works for non-browser clients; `settings.web_token` and `--token` are marked `DEPRECATED`. - Docs updated: `docs/api.md` (transports & auth), `README.md` Tailscale Serve walkthrough (dropped the token), OpenAPI security scheme note. ## Testing (per verify skill, all green) - `pytest`: **515 passed** (added 11 request-level guard tests + host parsing/allowlist construction to `test_ned_security.py`) - `mypy` on changed files: clean (the 4 remaining `ned/client.py` errors are pre-existing on main) - `pyflakes`: only the pre-existing `subprocess` unused-import warning in `ned/main.py` ## Suggested review steps 1. `git checkout pr/ned-csrf-host-guard` 2. `QT_QPA_PLATFORM=offscreen ~/.local/share/pipx/venvs/lazarus-mail/bin/python -m pytest tests/test_ned_security.py tests/test_ned.py -q` 3. Manual: `ned --host 127.0.0.1 --port 8080`, then `curl -H "Host: evil.example" http://127.0.0.1:8080/api/v1/ping` → 403.
Adds a request-level security check to the TCP listener that ACL-style
access control cannot provide:

- Host-header allowlist (loopback, bind host, Tailscale IP/MagicDNS/Serve
  hostname) blocks DNS rebinding of arbitrary names to the listener.
- When an Origin header is present, its hostname must match Host, so no
  third-party website can drive state-changing requests (CSRF). Plain
  non-browser clients (desktop, ned-client, ned-mcp, curl) send no Origin
  and are unaffected.
- The allowlist is skipped only for 0.0.0.0/:: --allow-insecure binds;
  the Origin check still applies there.

Deprecates token auth for the web client per the tailnet-ACL security
model: the PWA no longer sends ?token= to the SSE stream, and the legacy
?token= query parameter is removed from the handler (Authorization header
still works for non-browser clients). Network-level access control — the
Unix socket's OS permissions and Tailscale ACLs — replaces tokens.

Tests: request-level guard coverage in test_ned_security.py (missing/
forbidden Host, cross-origin, null origin, same-origin, non-browser,
rebind-with-matching-origin, query-param token deprecation) plus host
parsing/allowlist construction.
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Home/lazarus!83
No description provided.