Skip to content

fix: surface the real client IP from nginx's X-Real-IP (#274) - #341

Open
mithro wants to merge 7 commits into
mainfrom
fix/274-real-client-ip
Open

fix: surface the real client IP from nginx's X-Real-IP (#274)#341
mithro wants to merge 7 commits into
mainfrom
fix/274-real-client-ip

Conversation

@mithro

@mithro mithro commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Closes #274

Problem

Behind gunicorn on a unix socket REMOTE_ADDR is empty, so audit logs, TOS-acceptance records and the compliance certification recorded the wrong client IP. Worse, four call sites preferred the first X-Forwarded-For entry — which is client-controlled (nginx appends the real IP after it) — so a request carrying a forged X-Forwarded-For was recorded as the forged value. Fixing REMOTE_ADDR alone would have been bypassed by that path; both are fixed together.

The infra side is done: hetzner-ansible#132/#133 give the platform nginx the true visitor (Cloudflare-scoped real_ip), and since #144 it sets X-Real-IP $remote_addr at server level so it is overwritten on every proxied location. This PR is the Django side that consumes it.

Design

Implements docs/superpowers/specs/2026-07-16-real-client-ip-design.md (user-approved; carried over from the spec-only issue/274-real-client-ip branch, with its "assumptions to verify" now recorded as verified).

  • wafer_space/core/middleware.pyRealClientIPMiddleware, registered first in MIDDLEWARE. When TRUST_X_REAL_IP is on, it copies the X-Real-IP value into REMOTE_ADDR via django-ipware with request_header_order=("HTTP_X_REAL_IP",), so X-Forwarded-For is never consulted. Absent/malformed header → REMOTE_ADDR untouched (no spoofable fallback).
  • TRUST_X_REAL_IP: False in base.py (dev/test), True in stage.py/prod.py. Trust is safe there because nginx is the sole ingress and gunicorn is socket-only — a client-supplied X-Real-IP is overwritten by nginx before it can reach Django.
  • wafer_space/core/utils.get_client_ip() — single source of truth for consumers; returns REMOTE_ADDR normalised so "" (the unix-socket case) becomes None, which the GenericIPAddressField/Postgres inet columns accept and "" does not (a latent DataError in the old code).
  • Call sites (projects/views_compliance.py, both blocks in projects/mixins.py, legal/views.py) drop their inline X-Forwarded-For logic and use the helper; the old local get_client_ip in views_compliance is deleted.
  • New dependency: django-ipware==7.0.1 (latest; sole dep python-ipware). Its semantics were probed against the installed package before use.

Testing (TDD — every test watched failing first)

  • New wafer_space/core/tests/test_middleware.py: rewrite from X-Real-IP (v4 + v6), header absent, header malformed, X-Forwarded-For-only ignored, TRUST_X_REAL_IP=False ignores X-Real-IP, and a guard that the middleware is first in MIDDLEWARE.
  • wafer_space/core/tests/test_utils.py: helper returns REMOTE_ADDR; None when absent; None (not "") when empty.
  • Rewrote the four tests that encoded the old spoofable behaviour into XFF-ignored / X-Real-IP-honoured end-to-end tests through the test client (legal/tests/test_views.py, legal/tests/test_oauth_tos_flow.py, projects/tests/test_views_compliance.py) and added an access-log XFF-ignore test in projects/tests/test_mixins.py.
  • make lint-fix && make lint && make type-check clean; make test: 1538 passed, 3 skipped, 0 failed (main: 1530).

Rollout note

Nothing to configure at deploy time: stage/prod already set the flag in settings, and nginx already sends X-Real-IP (re-rendered live on both on 2026-08-17). Verify after deploy by accepting the TOS / viewing a project as staff and checking the recorded ip_address is the real visitor rather than empty or 192.168.27.1.

🤖 Generated with Claude Code

https://claude.ai/code/session_01G4hgdz4RAYqZVLWHZRYrd2

mithro and others added 7 commits August 17, 2026 15:45
Design for issue #274: Django reads REMOTE_ADDR, which behind gunicorn on a unix socket is empty/local, so the real visitor IP never reaches the application.

Approach: django-ipware behind a thin RealClientIPMiddleware that reads the authoritative X-Real-IP set by nginx and rewrites REMOTE_ADDR, so all present and future consumers read the correct IP. The four existing X-Forwarded-For[0] extraction sites (spoofable) are refactored onto a single shared helper.

Cloudflare range validation stays at nginx (set_real_ip_from), the only layer that can see the connecting peer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ci2kPzRxhvaHXAz7MjrTjC
Spec review noted gunicorn on a unix socket leaves REMOTE_ADDR as an empty string, not absent. The three consumers store into GenericIPAddressField over a Postgres inet column, where "" raises DataError but None is valid. Normalise falsy to None in the shared helper and cover it with a test.

Also sharpen the django-ipware assumption to require verification against the installed package, and require re-establishing the test baseline.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ci2kPzRxhvaHXAz7MjrTjC
Failing tests for the design in
docs/superpowers/specs/2026-07-16-real-client-ip-design.md: the
middleware rewrites REMOTE_ADDR from a trusted X-Real-IP only when
TRUST_X_REAL_IP is on and never consults X-Forwarded-For; the shared
helper returns REMOTE_ADDR normalised so "" (gunicorn on a unix socket)
becomes None. Adds django-ipware==7.0.1 for the header parsing.

Part of #274

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G4hgdz4RAYqZVLWHZRYrd2
Behind gunicorn on a unix socket REMOTE_ADDR is empty, so audit logs,
TOS acceptance records and anything else reading it recorded the wrong
value. nginx now resolves the true visitor (Cloudflare-scoped real_ip,
hetzner-ansible#132/#133) and forwards it in X-Real-IP; this middleware
copies that into REMOTE_ADDR, registered first so every later consumer
sees it.

- Only X-Real-IP is consulted (django-ipware with a fixed header order);
  X-Forwarded-For is never read -- its first entry is client-controlled.
- Gated by TRUST_X_REAL_IP: False in base (dev/test), True in stage/prod
  where nginx is the sole ingress and gunicorn is socket-only.
- Absent/malformed header -> REMOTE_ADDR untouched; no spoofable fallback.
- New wafer_space.core.utils.get_client_ip() is the single source of
  truth for consumers and normalises "" to None so the value can be
  stored in a GenericIPAddressField (Postgres inet rejects "").

Design: docs/superpowers/specs/2026-07-16-real-client-ip-design.md

Part of #274

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G4hgdz4RAYqZVLWHZRYrd2
Rewrite the tests that encoded the old, spoofable behaviour (first
X-Forwarded-For entry wins) to the new model: REMOTE_ADDR -- as resolved
from the trusted X-Real-IP by RealClientIPMiddleware -- is recorded, and a
client-supplied X-Forwarded-For never overrides it. Covers the compliance
helper, project access log, and both TOS-acceptance paths.

Part of #274

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G4hgdz4RAYqZVLWHZRYrd2
Delete the X-Forwarded-For-first extraction in the compliance view, the
two project access-log sites and the TOS acceptance view. Its first entry
is client-controlled (nginx appends the real IP after it), so a request
carrying a forged X-Forwarded-For was recorded as that forged value.
Everything now reads REMOTE_ADDR -- rewritten from the nginx-set X-Real-IP
by RealClientIPMiddleware -- through the one shared helper, which also
normalises "" to None for the inet-backed GenericIPAddressField columns.

Closes #274

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G4hgdz4RAYqZVLWHZRYrd2
The four pre-deploy assumptions (nginx overwrites X-Real-IP at server
level, hetzner-ansible#132/#133 merged and rendered live, django-ipware
7.0.1 API semantics probed against the installed package, fresh test
baseline) were all checked during implementation; note that and the new
branch name so the spec matches the PR.

Part of #274

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G4hgdz4RAYqZVLWHZRYrd2
@coderabbitai

coderabbitai Bot commented Aug 17, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@mithro, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 46 minutes

Limit details: You’ve used all 1 included review currently available under your plan.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: e9bf1bb7-1be7-4e60-9c11-012a51aa57b9

📥 Commits

Reviewing files that changed from the base of the PR and between c851c59 and d9f215e.

⛔ Files ignored due to path filters (1)
  • uv.lock is excluded by !**/*.lock
📒 Files selected for processing (16)
  • config/settings/base.py
  • config/settings/prod.py
  • config/settings/stage.py
  • docs/superpowers/specs/2026-07-16-real-client-ip-design.md
  • pyproject.toml
  • wafer_space/core/middleware.py
  • wafer_space/core/tests/test_middleware.py
  • wafer_space/core/tests/test_utils.py
  • wafer_space/core/utils.py
  • wafer_space/legal/tests/test_oauth_tos_flow.py
  • wafer_space/legal/tests/test_views.py
  • wafer_space/legal/views.py
  • wafer_space/projects/mixins.py
  • wafer_space/projects/tests/test_mixins.py
  • wafer_space/projects/tests/test_views_compliance.py
  • wafer_space/projects/views_compliance.py

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Django doesn't surface the real client IP (reads REMOTE_ADDR; trust X-Real-IP/X-Forwarded-For)

1 participant