fix(ned): normalize Host/Origin ports and trailing dots; warn on deprecated tokens #84

Merged
clanker merged 1 commit from pr/ned-security-followup into main 2026-09-08 22:28:05 +00:00
Member

Follow-up to #83, addressing review feedback on the CSRF/DNS-rebinding guard.

Changes

  1. Scheme-aware port normalization (ned/handler.py) — the Origin port comparison now resolves implicit scheme defaults (http→80, https→443), so a default-port origin (e.g. a page at http://127.0.0.1) can no longer masquerade as a listener on a non-default port even when the hostname matches. The request port is still only compared when explicit — a reverse proxy (Tailscale Serve, nginx) legitimately changes ports (https:443 → backend :8080) and its Host header carries no port, so strict 443-vs-backend equality would 403 the PWA behind them. (The reviewer's req_port or 80 formulation would break exactly that; this is the corrected version.)

  2. Host trailing-dot normalization (ned/handler.py) — _parse_host_header now rstrip(".")s the hostname, matching the allowlist and Origin normalization. curl and some proxies send FQDN Host: headers with a trailing dot (host.), which previously produced false 403s.

  3. Deprecated-token startup warning (ned/main.py) — when settings.web_token/--token is non-empty, NED logs that tokens are deprecated: the web/PWA client requires a tokenless listener (browsers can't send Authorization on navigation; ?token= is gone), so a non-empty token silently locks the PWA out while still serving non-browser clients via the header.

  4. agent.md docs — refreshed stale bearer-token claims (auth via tailnet ACLs, header-only deprecated tokens, Host/Origin enforcement).

Note on agent.md

agent.md has uncommitted work-in-progress edits in the local working tree (unrelated rewrite). This PR stages only a minimal prose fix on the committed version; the WIP is left untouched locally.

Verification

  • Full suite: 528 passed (new tests: implicit-default-port rejection, proxy implicit-port passthrough, trailing-dot Host, Host-parsing variants, CLI warning via Popen since subprocess.run discards stderr on timeout)
  • mypy (changed files): clean; remaining ned/client.py errors are pre-existing on main
  • pyflakes: only the pre-existing subprocess unused import in ned/main.py

Review steps

  1. git checkout pr/ned-security-followup
  2. QT_QPA_PLATFORM=offscreen ~/.local/share/pipx/venvs/lazarus-mail/bin/python -m pytest tests/test_ned_security.py -q
  3. Manual: ned --host 127.0.0.1 --token secret → observe the deprecation warning at startup.
Follow-up to #83, addressing review feedback on the CSRF/DNS-rebinding guard. ## Changes 1. **Scheme-aware port normalization** (`ned/handler.py`) — the Origin port comparison now resolves implicit scheme defaults (`http`→80, `https`→443), so a default-port origin (e.g. a page at `http://127.0.0.1`) can no longer masquerade as a listener on a non-default port even when the hostname matches. The request port is still only compared when explicit — a reverse proxy (Tailscale Serve, nginx) legitimately changes ports (https:443 → backend :8080) and its Host header carries no port, so strict 443-vs-backend equality would 403 the PWA behind them. (The reviewer's `req_port or 80` formulation would break exactly that; this is the corrected version.) 2. **Host trailing-dot normalization** (`ned/handler.py`) — `_parse_host_header` now `rstrip(".")`s the hostname, matching the allowlist and Origin normalization. curl and some proxies send FQDN `Host:` headers with a trailing dot (`host.`), which previously produced false 403s. 3. **Deprecated-token startup warning** (`ned/main.py`) — when `settings.web_token`/`--token` is non-empty, NED logs that tokens are deprecated: the web/PWA client requires a tokenless listener (browsers can't send `Authorization` on navigation; `?token=` is gone), so a non-empty token silently locks the PWA out while still serving non-browser clients via the header. 4. **agent.md docs** — refreshed stale bearer-token claims (auth via tailnet ACLs, header-only deprecated tokens, Host/Origin enforcement). ## Note on agent.md `agent.md` has uncommitted work-in-progress edits in the local working tree (unrelated rewrite). This PR stages only a minimal prose fix on the committed version; the WIP is left untouched locally. ## Verification - Full suite: **528 passed** (new tests: implicit-default-port rejection, proxy implicit-port passthrough, trailing-dot Host, Host-parsing variants, CLI warning via Popen since `subprocess.run` discards stderr on timeout) - mypy (changed files): clean; remaining `ned/client.py` errors are pre-existing on main - pyflakes: only the pre-existing `subprocess` unused import in `ned/main.py` ## Review steps 1. `git checkout pr/ned-security-followup` 2. `QT_QPA_PLATFORM=offscreen ~/.local/share/pipx/venvs/lazarus-mail/bin/python -m pytest tests/test_ned_security.py -q` 3. Manual: `ned --host 127.0.0.1 --token secret` → observe the deprecation warning at startup.
Follow-up to the CSRF/DNS-rebinding guard (#83), addressing review feedback:

- Port comparison now resolves implicit scheme defaults (http->80,
  https->443) so a default-port origin cannot masquerade as a listener on
  a non-default port, while still only comparing when the request port is
  explicit — reverse proxies (Tailscale Serve, nginx) legitimately change
  ports and their Host header carries no port, so strict 443-vs-backend
  equality would break the PWA behind them.
- Host header parsing strips trailing dots, matching the allowlist and
  Origin normalization (curl and some proxies send FQDN Hosts like
  'host.').
- ned --token/settings.web_token: log a startup deprecation warning —
  the web/PWA client requires a tokenless listener (browser navigation
  cannot send Authorization headers and ?token= is gone), so a non-empty
  token now silently locks the PWA out while still serving non-browser
  clients via the header.
- agent.md: refresh the stale bearer-token claims (ACL-based auth,
  header-only tokens, Host/Origin enforcement).

Tests: scheme-default port rejection, proxy implicit-port passthrough,
trailing-dot sniffing, Host parsing variants, and a CLI startup-warning
test (Popen, since subprocess.run discards stderr on timeout). Full
suite: 528 passed.
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!84
No description provided.