Skip to content

Close test gaps and harden link consumption - #5

Open
lukeburden wants to merge 2 commits into
masterfrom
close-test-gaps
Open

Close test gaps and harden link consumption#5
lukeburden wants to merge 2 commits into
masterfrom
close-test-gaps

Conversation

@lukeburden

Copy link
Copy Markdown
Owner

Summary

Follows up on a test-gap review of the suite. Grows the suite from 38 to 52 tests and fixes two issues the review surfaced.

New test coverage

  • API Location header — the core contract of the create endpoint was never asserted; now checked with the default and a custom AUTHLINK_URL_TEMPLATE
  • X-Forwarded-For IP extraction — the code path changed in the 2.0.0 ipware port had no proxy-header tests. New tests pin the actual ipware 7 semantics (verified empirically before writing): public XFF wins; a leftmost-private chain fails closed (None) outside DEBUG; a wholly-private XFF falls back to REMOTE_ADDR; plus XFF-based IP matching
  • Middleware scope — proves password-established sessions are never confined by the whitelist middleware, even with an empty whitelist
  • AuthLinkBackend invariantauthenticate() returns None even for valid credentials (the backend is a session tag, not a real authenticator); closes the last uncovered line in the package
  • View edges — unknown key → 404, and AUTHLINK_NON_SUCCESS_REDIRECT_URL override
  • Key generation — length/charset defaults and AUTHLINK_KEY_LENGTH override
  • End-to-end flow — password login → POST /api/authlink/ → fresh session consumes the returned Location → whitelisted URL 200, everything else 403

Fixes

  • Concurrent double-consumption: the single-use check ran inside transaction.atomic but without a row lock, so two simultaneous requests could both consume a link. The view now fetches with select_for_update() (no-op on SQLite, real lock on PostgreSQL/MySQL)
  • Docs: AUTHLINK_NON_SUCCESS_REDIRECT_URL and AUTHLINK_KEY_LENGTH were undocumented; the latter now carries the caveat that the shipped migration is varchar(64), so values above 64 require a project-level migration

Test plan

  • 52/52 tests pass on Django 5.2.16 and 6.0.7 (Python 3.12) locally
  • Ruff check and format clean
  • Full matrix green in GitHub Actions on this PR

🤖 Generated with Claude Code

Tests (38 -> 52):
- Assert the Location header returned by the create API, including
  with a custom AUTHLINK_URL_TEMPLATE
- Cover X-Forwarded-For IP extraction: public XFF, leftmost-private
  chain failing closed outside DEBUG, and wholly-private XFF falling
  back to REMOTE_ADDR (pins the ipware 7 semantics shipped in 2.0.0)
- Assert the whitelist middleware ignores password-established sessions
- Assert AuthLinkBackend.authenticate() never authenticates anyone
- Cover unknown-key 404 and AUTHLINK_NON_SUCCESS_REDIRECT_URL
- Cover generate_authlink_key length/charset and AUTHLINK_KEY_LENGTH
- Add an end-to-end test: API create -> consume in a fresh session ->
  whitelisted URL accessible, everything else 403

Fixes:
- Lock the AuthLink row (select_for_update) during consumption so
  concurrent requests cannot both pass the is_used check
- Document AUTHLINK_NON_SUCCESS_REDIRECT_URL and AUTHLINK_KEY_LENGTH,
  including the varchar(64) migration caveat for the latter

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@lukeburden

Copy link
Copy Markdown
Owner Author

🚨 Critical Bug: IntegrityError in Production for Non-Routable IP Environments

  • The Issue: The new test cases explicitly verify that when DEBUG = False, extract_ipaddress() returns None for non-routable (private/local) IPs.
  • The Impact: However, AuthLink.ipaddress is a non-nullable GenericIPAddressField() (both in models.py and migration 0001_initial.py).
  • The Failure: If an administrator or user attempts to generate an authlink in production while connected via a corporate VPN, private local network, or intranet, extract_ipaddress() returns None. When adapter.create() tries to save the model, Django will raise an unhandled IntegrityError (NOT NULL constraint violation).
  • Action Required: Either make AuthLink.ipaddress nullable (null=True, blank=True) and handle the None case gracefully, or provide a safe fallback IP.

⚡ Architectural Flaw: select_for_update on GET Requests in Read/Write-Split Deployments

  • The Issue: The concurrent double-consumption fix wraps the GET view in @transaction.atomic and locks the row using AuthLink.objects.select_for_update().
  • The Impact: Many high-traffic Django deployments route all incoming GET requests to read-replicas.
  • The Failure: Running select_for_update() on a read-only database replica will crash the application with a database permission exception.
  • Action Required: Explicitly route this query/view to the 'default' database (using .using('default')).

🧩 Static State Leak: Module-Level Adapter in Middleware

  • The Issue: In authlink/middleware.py, the adapter is fetched at the module import level.
  • The Impact: This binds the middleware to the active AUTHLINK_ADAPTER_CLASS exactly once at start-up.
  • The Failure: Dynamic configuration overrides—such as multi-tenant adapter routing, or test overrides using @override_settings—will be ignored by the middleware.
  • Action Required: Retrieve the adapter dynamically inside __call__ or __init__.

🔒 Security: Missing Proxy Warning (IP Spoofing Risk)

  • The Issue: The new tests pin X-Forwarded-For behavior with django-ipware, which trusts proxy headers by default.
  • The Impact: Production apps behind a reverse proxy without strict ipware configuration are vulnerable to trivial IP spoofing by attackers.
  • Action Required: Add a warning to README.md in the Security section, emphasizing the need for proper proxy configuration to prevent spoofing of the generation IP.

From the review feedback on PR #5, two points accepted:

- Resolve AUTHLINK_ADAPTER_CLASS per request in the whitelist
  middleware instead of binding the adapter at import time, matching
  how the views resolve it; regression test included (fails against
  the old middleware when another test imports the module first)
- Document in the README security section that django-ipware trusts
  forgeable proxy headers by default, and that the reverse proxy must
  strip/overwrite them (or ipware trusted-proxy options be used) for
  IP matching to be meaningful

Declined (with reasons in the PR discussion): making
AuthLink.ipaddress nullable would weaken the IP-binding control the
library exists to provide, and hardcoding .using('default') would
override deployers' database routers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.

1 participant