Skip to content

fix: match parse_field header names only at line starts#95

Open
kugland wants to merge 3 commits into
emikulic:masterfrom
kugland:fix/parse-field-line-start
Open

fix: match parse_field header names only at line starts#95
kugland wants to merge 3 commits into
emikulic:masterfrom
kugland:fix/parse-field-line-start

Conversation

@kugland
Copy link
Copy Markdown
Contributor

@kugland kugland commented May 13, 2026

Fixes #94.

Summary

  • parse_field() used strcasestr over the whole request buffer, so any substring matching a header name was accepted — including bytes inside the request-target or another header's value. This let clients smuggle Authorization, Range, X-Forwarded-For, Host, If-Modified-Since, etc.
  • Most impactful: with --trusted-ip, a client whose connection matches the trusted proxy address could spoof its logged IP by hiding X-Forwarded-For: <ip> inside a header it controls (e.g. Referer), without ever sending a real X-Forwarded-For.
  • Fix: walk header lines and only match the field name at the start of a line (strncasecmp after each \n). The request line itself never matches a header name.

Repro (pre-fix, server started with --trusted-ip 127.0.0.1, client on 127.0.0.1):

printf 'GET / HTTP/1.0\r\nHost: x\r\nReferer: X-Forwarded-For: 99.99.99.99\r\n\r\n' \
  | nc 127.0.0.1 18080

logs 99.99.99.99 - - ... "GET / ..." 200 ... "X-Forwarded-For: 99.99.99.99" "". Post-fix, real client IP is logged.

Test plan

  • Added test_xff_smuggled_in_referer to devel/test_log_xff.py. Fails on 7b9cafa (vulnerable), passes after the fix.
  • All 5 existing test_log_xff.py cases still pass.
  • Smoke-tested manually with --trusted-ip 127.0.0.1 for both the smuggling attempt and a legitimate X-Forwarded-For: 1.2.3.4 header.

@kugland kugland force-pushed the fix/parse-field-line-start branch from 155e29d to a09277c Compare May 13, 2026 20:09
kugland added 2 commits May 13, 2026 17:23
Send a request whose Referer value contains the substring
"X-Forwarded-For: <ip>" but no real X-Forwarded-For header. With the
pre-fix parse_field (strcasestr over the whole buffer) the smuggled
value was logged as the client IP; with the fix the real client IP is
logged.
parse_field used strcasestr against the entire request buffer, so any
substring matching the header name was accepted — including bytes
from the request-target or another header's value. This let any
client smuggle Authorization, Range, X-Forwarded-For, Host,
If-Modified-Since, etc., into log entries and request handling
(notably enabling X-Forwarded-For spoofing through the trusted-proxy
path). Walk header lines and only match the field at the start of a
line.
@kugland kugland force-pushed the fix/parse-field-line-start branch from a09277c to b8680c5 Compare May 13, 2026 20:23
@kugland
Copy link
Copy Markdown
Contributor Author

kugland commented May 13, 2026

I reordered the commits and force-pushed them, so you can checkout the one with the test but without the fix, and see it failing.

@g-rden
Copy link
Copy Markdown
Contributor

g-rden commented May 15, 2026

Unless I'm mistaken the whole thing can be simply solved by changing

if (pos == NULL)
to if (pos == NULL || (pos != conn->request && pos[-1] != '\n'))

When the target field name appears in a prior header's value and the
real header follows, a fix based on a single strcasestr call with a
pos[-1] != '\n' guard returns NULL and drops the real header. The
line-walking approach handles this correctly.
@kugland
Copy link
Copy Markdown
Contributor Author

kugland commented May 16, 2026

Thanks for the suggestion, @g-rden! The check does correctly reject the smuggled case, but there's a subtlety: if the field name happens to appear as a substring inside a prior header's value and a real header with that name also follows, strcasestr finds the embedded occurrence first, the pos[-1] != '\n' guard rejects it, and the function returns NULL — silently losing the real header.

For example, if a request contains both Referer: http://evil.example/X-Forwarded-For: 99.99.99.99 and a real X-Forwarded-For: 10.20.30.40, your approach would log the direct connection IP instead of the real forwarded client IP. I added test_xff_real_header_not_masked_by_prior_value_match to cover this case — it fails with a single-hit approach but passes with the line-walking fix.

@g-rden
Copy link
Copy Markdown
Contributor

g-rden commented May 16, 2026

What if the string for the field we compare the request with already contains a leading '\n'. There would be no need for any extra testing. The request fields that are passed to the function always follow a newline anyway IFAIK.

It would be as simple as:

char *request_field = NULL;

xasprintf(&request_field, "\n%s", field);
pos = strcasestr(conn->request, request_field);

But I night have missed something

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.

parse_field() matches header names anywhere in request → X-Forwarded-For spoofing + header smuggling

2 participants