What Gixy reports
[proxy_pass_normalized] Detect path after host in proxy_pass (potential URL decoding issue)
Severity: MEDIUM
Two modes, one directive
Whether proxy_pass has a path component after the host changes nginx's
behaviour completely, and the difference is a single character:
-
No path --
proxy_pass http://backend;-- nginx forwards the original request URI as the client sent it, byte for byte, encoding intact. -
Any path, including a bare slash --
proxy_pass http://backend/;-- nginx substitutes the matched location prefix, and sends the normalized, decoded URI.
That second mode is where it gets interesting. Normalization means percent-decoding and
collapsing . and .. segments. So %2F, which the client
encoded specifically so it would not be a path separator, becomes a real
/ on the way upstream.
The problem
location /api/ {
proxy_pass http://backend/;
}
Given a request for:
GET /api/user%2F..%2F..%2Fadmin/config HTTP/1.1
the backend receives:
GET /admin/config HTTP/1.1
The traversal sequence was encoded, so any front-end path check that looked at the raw URI saw a single harmless segment. nginx then decoded it into a real traversal and resolved it. If your access control lives in nginx and your routing lives upstream, they are now disagreeing about what the request was -- which is the whole basis of path-confusion attacks.
This is a request-smuggling-shaped bug
Nothing is exploitable in isolation. It becomes exploitable when two components parse the
same request differently -- an nginx location that denies
/admin/, and a backend that receives /admin/config after nginx
decoded its way there.
The fix
Drop the path. Pass the URI through untouched:
location /api/ {
proxy_pass http://backend;
}
Note the consequence: the backend now sees /api/... rather than
/..., because the location prefix is no longer stripped. Usually that is what
you want -- the backend gets the real path and can make its own decisions about it.
If you must strip the prefix
Be aware that nginx has no clean way to do it. The regex-capture form that gets recommended for this does not preserve encoding, and Gixy flags it under this same check:
location ~ ^/api/(.*)$ {
proxy_pass http://backend/$1$is_args$args;
}
nginx matches locations against the already decoded and normalized URI, so
$1 holds decoded text. %2F has become / before the
capture ever happens. You have re-created the original problem with extra steps — and
added a second one, since a variable in proxy_pass switches nginx to runtime DNS
resolution and now needs a resolver
(and the wrong resolver is its own HIGH finding).
The options that actually work:
- Keep the prefix and let the backend route on the full path. Simplest, and what the clean example above does.
- Strip it upstream — in the application's own router, or a base-path setting. Most frameworks have one.
- Accept the decoding deliberately, having confirmed nothing in your stack makes an authorization decision on the path.
rewrite ^ $request_uri; either. It is the
other common suggestion and it double-encodes: $request_uri is already raw, and
the rewrite encodes it again, so %2F arrives as %252F. Gixy flags
that pattern under this same check.
Verify the fix
gixy /etc/nginx/nginx.conf
# Log what the backend actually receives. In the upstream server block:
# log_format upstream_uri '$request_uri -> $uri';
# Or just observe from a test backend:
curl -sS -o /dev/null 'https://example.com/api/user%2F..%2F..%2Fadmin/config'
# Then check the backend's access log. It should show the encoded form,
# not /admin/config.
When this is a false positive
Often, in fact -- this check is a prompt to look, not a defect report. It is fine when:
- The backend does no path-based authorization. If every route is authenticated by token and nothing is gated on URL prefix, decoding changes nothing that matters.
- The upstream is a static file server with its own traversal protection. Most are.
-
Encoded slashes never occur in your URL space. If no legitimate path
segment can contain
%2F, there is nothing to confuse -- though consider rejecting them outright so that stays true.
Gixy already excludes the cases where the concern cannot apply: exact-match
(location =) blocks, proxy_pass in the stream context
where the semantics differ, and destinations that are a bare variable and cannot be analysed
statically.
Reference
- proxy_pass_normalized in the Gixy documentation — what the check inspects and its options
- All Gixy checks — the full list by severity
- Plugin source — the exact detection logic