fix(security): harden IP allowlist against XFF bypass and spoofing (#3)
Hardens the IP allowlist in require_auth against the two bypasses filed in #3. 1. Bypass via missing X-Forwarded-For (no IP to check, allowlist skipped). 2. Spoofing via attacker-controlled X-Forwarded-For (header trusted unconditionally). Resolves both by deriving the client IP from the socket peer (ConnectInfo<SocketAddr>) and only honoring X-Forwarded-For when the immediate peer is in a new security.trusted_proxies allowlist (default empty = strict). Fails closed with 403 forbidden_ip when a non-empty allowlist is configured and the client IP cannot be determined. Empty ip_whitelist continues to mean allow all (preserved for dev installs). 27 pm-auth tests pass (12 new resolver + 8 new middleware + 7 existing). Spec: tasks/ip-allowlist-spec.md.
This commit is contained in:
committed by
GitHub
parent
8873b2c70c
commit
3bdae4bcc5
@ -100,6 +100,87 @@ _(filled in at completion)_
|
||||
- **tokio::sync::Mutex over std::sync::Mutex** — Axum handlers must be Send; std::sync::MutexGuard is not Send across await points.
|
||||
- **DashMap session cleanup** — In-memory session stores (DashMap) need periodic cleanup tasks to prevent memory leaks. Pattern: tokio::spawn with interval + retain with time-based cutoff.
|
||||
|
||||
# IP Allowlist Hardening — Implementation Plan (Issue #3)
|
||||
|
||||
Spec: `tasks/ip-allowlist-spec.md` (v0.1.0, awaiting sign-off)
|
||||
|
||||
## Issues Identified
|
||||
1. **Allowlist bypass via missing XFF** — `extract_remote_ip` returns `None` when
|
||||
the header is absent, and the middleware's `if let Some(ip)` block has no
|
||||
`else` branch, so a request without `X-Forwarded-For` skips the check.
|
||||
2. **Allowlist spoofing via XFF** — `extract_remote_ip` reads the header
|
||||
unconditionally; any client can claim to be from a whitelisted IP.
|
||||
3. **No trusted-proxy concept** — there is no config field to declare which
|
||||
intermediate proxies are allowed to set `X-Forwarded-For`.
|
||||
4. **No `ConnectInfo<SocketAddr>` wiring** — the axum listeners in
|
||||
`pm-web/src/main.rs` do not use `into_make_service_with_connect_info`, so
|
||||
the middleware cannot access the real peer address.
|
||||
|
||||
## Phases
|
||||
|
||||
### Phase 1: Resolver helper in pm-auth
|
||||
- [ ] 1a: Add `fn resolve_client_ip(headers, peer, trusted_proxies) -> Option<IpAddr>`
|
||||
- [ ] 1b: Add 12 unit tests in `crates/pm-auth/src/rbac.rs` (cfg(test)) covering
|
||||
the resolution matrix (peer-only, XFF trusted/untrusted, multi-hop,
|
||||
IPv6, malformed, missing peer)
|
||||
- [ ] 1c: Run `cargo test -p pm-auth` and confirm green
|
||||
|
||||
### Phase 2: AuthConfig + SecurityConfig schema
|
||||
- [ ] 2a: Add `trusted_proxies: Arc<RwLock<Vec<IpNet>>>` to `AuthConfig`
|
||||
- [ ] 2b: Add `trusted_proxies: Vec<String>` to `SecurityConfig` in `crates/pm-core/src/config.rs`
|
||||
- [ ] 2c: Update `Default for AppConfig` to include `trusted_proxies: vec![]`
|
||||
- [ ] 2d: Add `update_trusted_proxies` setter on `AuthConfig` (symmetric to
|
||||
`update_ip_whitelist`)
|
||||
- [ ] 2e: Update `config/config.example.toml` with a documented `trusted_proxies`
|
||||
entry and a reverse-proxy runbook comment block
|
||||
- [ ] 2f: Plumb `trusted_proxies` from `SecurityConfig` into `AuthConfig::new`
|
||||
in `pm-web/src/main.rs`
|
||||
- [ ] 2g: Run `cargo check` and `cargo clippy --all-targets`
|
||||
|
||||
### Phase 3: Middleware change
|
||||
- [ ] 3a: Update `require_auth` to extract `ConnectInfo<SocketAddr>` from
|
||||
request extensions and call `resolve_client_ip`
|
||||
- [ ] 3b: Add fail-closed path: non-empty allowlist + unresolvable IP →
|
||||
`403 forbidden_ip`
|
||||
- [ ] 3c: Replace `forbidden("Access denied")` with the new error code in IP-deny path
|
||||
- [ ] 3d: Add `tracing::warn!` with `client_ip`, `peer`, `xff_present`, `reason`
|
||||
- [ ] 3e: Remove the old `extract_remote_ip` (header-only) function
|
||||
- [ ] 3f: Run `cargo check` and `cargo clippy --all-targets`
|
||||
|
||||
### Phase 4: pm-web listener wiring
|
||||
- [ ] 4a: Switch both TCP and TLS axum listeners in `pm-web/src/main.rs` to
|
||||
`into_make_service_with_connect_info::<SocketAddr>()`
|
||||
- [ ] 4b: Run `cargo check -p pm-web`
|
||||
|
||||
### Phase 5: Middleware integration tests
|
||||
- [ ] 5a: Add `TestApp` harness in `crates/pm-auth/src/rbac.rs` cfg(test)
|
||||
(no DB, single-route router, `tower::ServiceExt`-style call)
|
||||
- [ ] 5b: Add 8 middleware integration tests per spec section 6.1
|
||||
(allow empty, deny non-empty, allow in list, fail-closed no peer,
|
||||
spoofed XFF ignored, trusted proxy honors XFF, bad XFF fallback,
|
||||
no-JWT on deny)
|
||||
- [ ] 5c: Run `cargo test -p pm-auth` and confirm green
|
||||
|
||||
### Phase 6: Documentation
|
||||
- [ ] 6a: Update `docs/security-review.md` — update existing IP-allowlist row
|
||||
and reference new code path + `trusted_proxies` field
|
||||
- [ ] 6b: Update `SPEC.md` Security section (one paragraph)
|
||||
- [ ] 6c: Add a "Reverse proxy deployment" runbook under `docs/runbooks/`
|
||||
(optional, per Kelly)
|
||||
|
||||
### Phase 7: Review & commit
|
||||
- [ ] 7a: Self-review against the 8 acceptance criteria in the spec
|
||||
- [ ] 7b: Run `bash /a0/usr/skills/git-workflow/scripts/validate-push.sh`
|
||||
- [ ] 7c: Commit on `fix/3-ip-allowlist-bypass` (per git-workflow skill)
|
||||
- [ ] 7d: Push to `github/fix/3-ip-allowlist-bypass` and open PR against `master`
|
||||
- [ ] 7e: Comment on issue #3 linking the PR; close issue on merge
|
||||
- [ ] 7f: Capture lessons in this file
|
||||
|
||||
## Lessons Learned (this issue)
|
||||
_(filled in at completion)_
|
||||
|
||||
---
|
||||
|
||||
# Host Self-Enrollment Implementation Plan
|
||||
|
||||
## Phases
|
||||
|
||||
Reference in New Issue
Block a user