Skip to content

source_ip is taken from untrusted X-Forwarded-For: spoofable, and an invalid value 500s the upload #15

@erskingardner

Description

@erskingardner

Impact

The forensic source_ip recorded for every upload is derived from the leftmost, client-controlled value of the X-Forwarded-For header. This has two problems:

  1. Forensic integrity / spoofing. The leftmost X-Forwarded-For entry is supplied by the original client; trusted reverse proxies (e.g. Caddy) append to it rather than replacing it. An uploader can therefore set source_ip to any arbitrary value, poisoning the forensic record of where evidence came from.
  2. Availability / evidence loss. source_ip is a GenericIPAddressField, which maps to the PostgreSQL inet type. If the leftmost X-Forwarded-For value is not a syntactically valid IP (X-Forwarded-For: not-an-ip), the INSERT raises DataError: invalid input syntax for type inet. That exception is not caught by the except IntegrityError handler in ingest_audit_log_bytes(), so the whole upload transaction rolls back: the client gets a 500 and the raw upload evidence is never persisted.

Both are trivially triggerable by setting a single request header on the (token-authenticated) upload endpoint.

Code pointers

  • forensics/views.py:236client_ip() returns forwarded_for.split(",", 1)[0].strip(), trusting the untrusted leftmost hop with no validation.
  • forensics/views.py:131 — the value is passed straight to ingest_audit_log_bytes(source_ip=...).
  • forensics/models.py:135source_ip = models.GenericIPAddressField(...) (inet on Postgres).
  • forensics/ingest.py:129AuditFile.objects.create(source_ip=source_ip, ...) performs the failing INSERT.
  • forensics/ingest.py:145except IntegrityError does not catch the DataError.

Reproduction (analysis)

On a Postgres deployment, send a valid upload with header X-Forwarded-For: bogus. client_ip() returns "bogus", AuditFile.objects.create() raises invalid input syntax for type inet, the exception escapes, and the request 500s with no AuditFile saved. With a syntactically-valid-but-fake IP (X-Forwarded-For: 8.8.8.8), the upload succeeds but records an attacker-chosen source IP.

(Not reproducible on SQLite, which stores GenericIPAddressField as text and does not validate inet syntax — so it only bites in production.)

Expected behavior

  • source_ip should reflect the real client as seen by the trusted proxy boundary, not an arbitrary client-supplied header value.
  • An invalid/garbage forwarded value must never crash the upload or lose evidence; at worst source_ip should be NULL.

Suggested fix

  • Derive the client IP from the trusted-proxy boundary (e.g. count trusted hops from the right of X-Forwarded-For, or use a vetted library), and document the expected proxy chain. Consider a setting for the number of trusted proxies.
  • Validate the candidate with django.core.validators.validate_ipv46_address (or ipaddress) before storing; fall back to REMOTE_ADDR or None when it does not parse.
  • Add regression tests for (a) a spoofed leftmost X-Forwarded-For and (b) a non-IP X-Forwarded-For value, asserting the upload is still saved and source_ip is correct/NULL.

Metadata

Metadata

Assignees

No one assigned

    Labels

    HIGHSeverity: serious correctness, availability, or data-integrity issuebugSomething isn't working

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions