A path in proxy_pass decodes %2F before proxying

Adding a path to proxy_pass flips nginx into a different URI-handling mode. Encoded slashes stop being encoded, and what your backend receives is not what the client sent.

MEDIUM proxy_pass_normalized 6 min read Updated: August 2026

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.
Do not reach for 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