DEV Community

Davi
Davi

Posted on Originally published at blog.mago.team

API Gateway Path Normalization Bypass: When HAProxy Sees /admin and Your App Sees /admin

A request for /%2fadmin reaches your gateway. Your ACL blocks /admin. The gateway sees an encoded string that does not match the literal /admin rule and passes it through. The backend decodes first, routes second, and delivers the admin panel to the caller.

Six CVEs across six different tools document the same structural flaw. The security ACL evaluates the path at a different normalization state than the path the backend ultimately serves. This is not misconfiguration. RFC 3986 leaves decode-order implementation-defined.

RFC 3986 Does Not Say When to Decode

RFC 3986 §6.2.2 describes normalization as optional preprocessing with no required ordering constraint. Section 2.3 states that percent-encodings of unreserved characters SHOULD be decoded by normalizers. The spec specifies no point in the middleware stack where this must happen. Each gateway decides independently.

nginx demonstrates the ambiguity in a single configuration line. With proxy_pass http://backend/, nginx decodes and normalizes before forwarding. Without a path in the directive, it forwards the raw client string unchanged. Same binary, two behaviors, one configuration difference.

Gravitee documents 3 explicit modes: RAW, REJECT, and NORMALIZE, confirming there is no consensus on a correct default. Any HTTP stack with two or more parsers carries an inconsistency window. Its size depends on each pair of design decisions, not on operational error.

Six CVEs, One Root Cause

Apache APISIX, Apache httpd (twice), Traefik, Spring MVC, ModSecurity, and AWS API Gateway all shipped the same structural flaw. The ACL evaluates the path at a different normalization state than the path the backend serves.

CVE-2021-43557 (APISIX) uses $request_uri without normalization in the uri-block plugin. A path like //internal/ does not match the ^/internal/ block rule on literal string comparison. CVE-2021-41773 (Apache httpd 2.4.49, CVSS 7.5) decoded literal .. but not %2e%2e, enabling directory traversal exploited as a zero-day before the patch landed.

CVE-2021-42013 (Apache httpd 2.4.50, CVSS 9.8) was the incomplete fix. The patch normalized literal .. but %2e%2e still bypassed the check, enabling RCE via CGI. CVE-2025-66490 (Traefik <=2.11.31 and <=3.6.2, CVSS 7.8) evaluates PathPrefix, Path, and PathRegex matchers before decoding, then forwards the decoded path.

A request for /%2freport_note bypasses the PathPrefix('/report_note') middleware chain because the matcher sees /%2freport_note while the backend receives /report_note. CVE-2023-20860 (Spring Framework 5.3.0-5.3.25 and 6.0.0-6.0.6, CVSS 8.8) exposes the same pattern within a single framework. Spring Security's mvcRequestMatcher and the Spring MVC dispatcher interpret the ** wildcard differently, creating an intra-framework bypass window.

CVE-2024-1019 (ModSecurity 3.0.0-3.0.11, CVSS 8.6) decodes %2F before separating the path from the query string. This hides attack payloads from WAF rules inspecting the path component.

The AWS HTTP API (no CVE assigned, 2026) suffered the same layer separation: route-matcher and Lambda authorizer are independent services. A trailing slash matched the route but dropped the authorizer context (userId = undefined), enabling unauthenticated wire transfers at a fintech.

Four Bypass Patterns Against Any Stacked Parser

The same 4 encoding techniques cross gateway boundaries. They all exploit the same ambiguity: whether normalization happens before or after security evaluation, not any gateway-specific behavior.

Double slash: GET //admin HTTP/1.1. Most gateways parse this as two path segments and do not match the literal /admin ACL. RFC 3986 §3.3 permits empty segments, making //admin valid syntax. Backends with path cleaning collapse it to /admin.

Encoded slash: GET /%2Fadmin HTTP/1.1. RFC 3986 §3.3 distinguishes %2F from /, reserved versus delimiter. A gateway doing string-match against /admin never sees /admin, it sees /%2Fadmin. The backend decodes before routing.

Double encoding: GET /%252Fadmin HTTP/1.1. First decode: %25 becomes %, yielding %2Fadmin. Second decode at the backend: %2F becomes /, yielding /admin. A gateway that decodes once and applies the ACL misses the second decode entirely.

Dot-dot traversal via encoding: GET /public/%2e%2e/admin HTTP/1.1. The gateway ACL permits the /public/ prefix. The backend resolves %2e%2e to .. and normalizes /public/../admin to /admin. CVE-2021-41773 and CVE-2021-42013 are exactly this pattern.

Why Normalizing Only at the Gateway Does Not Close the Gap

Moving normalization to the gateway shifts the mismatch, it does not eliminate it. The gateway and backend are independent HTTP parsers with no shared normalization contract.

nginx's proxy_pass with a path normalizes before forwarding. This breaks raw-payload proxying for backends that expect the client's literal string, such as HMAC-signed paths. HAProxy does not normalize URLs by design, passing exactly what the client sent. This is correct for HAProxy's role, but leaves every decode decision to the backend.

AWS HTTP API added trailing-slash normalization to routes without fixing the authorizer context-drop, because the two components make independent decisions. Quarkus (GHSA-qcxp-gm7m-4j5v) shows the problem has additional dimensions: encoded semicolons (%3B) smuggle matrix parameters past the security layer even when the gateway normalizes slashes.

Systematic Detection: A Diff, Not a Pass/Fail

Detecting normalization bypass requires comparing gateway behavior to backend behavior for the same request. Checking whether a single encoded path returns 403 is not sufficient, because both components can agree on the wrong answer.

For each protected route, send 6 variants: (1) /admin, (2) /%61dmin, (3) //admin, (4) /%2Fadmin, (5) /public/%2e%2e/admin, (6) /%252Fadmin. Use curl --path-as-is to prevent the HTTP client from normalizing before the request reaches the gateway. Without this flag, curl resolves .. and repeated slashes client-side.

Compare the HTTP status returned by the gateway against the handler recorded in the backend access log. A 200 from both with different handlers means normalization mismatch has already been exploited at the infrastructure level. The canary check closes the loop: send a request the gateway ACL would explicitly block and confirm the backend never logged it.

Defense: One Normalization Point, One Policy Enforcement Point

The only reliable defense is enforcing the ACL against the normalized path at the same layer that performs the final normalization. Splitting these two operations across layers is what creates the vulnerability class.

Envoy 1.9.1+ normalizes the URL inside the HTTP Connection Manager before the routing decision. Normalization and routing are coupled in the same component, closing the window between decoding and evaluation. ModSecurity 3.0.12's fix moves normalization to after the path/query split, aligning decode order with RFC 3986 semantics.

Traefik 2.11.32/3.6.3's fix decodes the path before evaluating router rules, then forwards the decoded path. Evaluation and forwarding share the same normalization state. For Spring MVC, replacing the ** wildcard with explicit matchers in mvcRequestMatcher eliminates the pattern mismatch between the security and dispatch layers.

The operational rule: never use $request_uri in an ACL without normalizing it first. Use $uri in nginx, already decoded by the server, or an explicit normalize() call in the auth service. The MAGO Intel tool (intel.mago.team) includes the tech_detector module. It identifies gateways in production and cross-references versions against path-normalization CVEs, including CVE-2025-66490 and CVE-2024-1019, as part of the application attack-surface report.

The gateway is not the last line of defense. If you treat it as one, your ACL needs to operate on the same URL your backend will ultimately serve. When those two values differ, you do not have an access control rule. You have a suggestion.

Top comments (0)