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:
- 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.
- 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:236 — client_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:135 — source_ip = models.GenericIPAddressField(...) (inet on Postgres).
forensics/ingest.py:129 — AuditFile.objects.create(source_ip=source_ip, ...) performs the failing INSERT.
forensics/ingest.py:145 — except 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.
Impact
The forensic
source_iprecorded for every upload is derived from the leftmost, client-controlled value of theX-Forwarded-Forheader. This has two problems:X-Forwarded-Forentry is supplied by the original client; trusted reverse proxies (e.g. Caddy) append to it rather than replacing it. An uploader can therefore setsource_ipto any arbitrary value, poisoning the forensic record of where evidence came from.source_ipis aGenericIPAddressField, which maps to the PostgreSQLinettype. If the leftmostX-Forwarded-Forvalue is not a syntactically valid IP (X-Forwarded-For: not-an-ip), theINSERTraisesDataError: invalid input syntax for type inet. That exception is not caught by theexcept IntegrityErrorhandler iningest_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:236—client_ip()returnsforwarded_for.split(",", 1)[0].strip(), trusting the untrusted leftmost hop with no validation.forensics/views.py:131— the value is passed straight toingest_audit_log_bytes(source_ip=...).forensics/models.py:135—source_ip = models.GenericIPAddressField(...)(ineton Postgres).forensics/ingest.py:129—AuditFile.objects.create(source_ip=source_ip, ...)performs the failing INSERT.forensics/ingest.py:145—except IntegrityErrordoes not catch theDataError.Reproduction (analysis)
On a Postgres deployment, send a valid upload with header
X-Forwarded-For: bogus.client_ip()returns"bogus",AuditFile.objects.create()raisesinvalid input syntax for type inet, the exception escapes, and the request 500s with noAuditFilesaved. 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
GenericIPAddressFieldas text and does not validateinetsyntax — so it only bites in production.)Expected behavior
source_ipshould reflect the real client as seen by the trusted proxy boundary, not an arbitrary client-supplied header value.source_ipshould beNULL.Suggested fix
X-Forwarded-For, or use a vetted library), and document the expected proxy chain. Consider a setting for the number of trusted proxies.django.core.validators.validate_ipv46_address(oripaddress) before storing; fall back toREMOTE_ADDRorNonewhen it does not parse.X-Forwarded-Forand (b) a non-IPX-Forwarded-Forvalue, asserting the upload is still saved andsource_ipis correct/NULL.