Skip to content

feat(router): add match_uri_encoded_slash to keep %2F in path parameters#13626

Open
AlinsRan wants to merge 9 commits into
apache:masterfrom
AlinsRan:feat/encoded-slash-route-match
Open

feat(router): add match_uri_encoded_slash to keep %2F in path parameters#13626
AlinsRan wants to merge 9 commits into
apache:masterfrom
AlinsRan:feat/encoded-slash-route-match

Conversation

@AlinsRan

@AlinsRan AlinsRan commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Description

Nginx decodes an URL-encoded slash (%2F) into a real / in $uri before
route matching happens. As a result a request like
/v1/te%2Fst/products/electronics/list is seen by the router as
/v1/te/st/products/electronics/list, which has a different number of path
segments and therefore fails to match a route defined with path parameters such
as /v1/:id/products/:type/list (returns 404 Route Not Found). This is the
problem reported in #11810, and it cannot be fixed in lua-resty-radixtree
(the parameter pattern [^/]+ must stop at a real slash to keep segmentation
correct) because the information is already lost by the time the router runs.

This PR adds an opt-in apisix.match_uri_encoded_slash option (default false).
When enabled, the route matching uri keeps %2F/%2f encoded so it is treated
as part of a path parameter instead of a separator.

This only affects route matching: the request forwarded to the upstream
still uses APISIX's normal (decoded) URI, so upstream behaviour is unchanged.

How it stays safe

Rather than re-implementing Nginx's URI normalization in Lua (dot-segment
resolution, slash merging, NUL rejection, …), the option uses the
already-normalized $uri as an oracle:

  • It fully percent-decodes the raw request path and compares it to $uri.
  • If they are equal, Nginx applied nothing beyond decoding (no ../, no
    merged //, no fragment, not an absolute-form request line). Only then does
    it build the matching uri by decoding everything except %2F. The result
    provably differs from $uri only by showing some / as %2F.
  • Otherwise it returns nil and matching falls back to the normalized $uri.

So traversal (..%2F..%2F, %2e%2e), consecutive slashes, %00 and exotic
request lines all fail the equivalence check and fall back — no bypass is
possible even if the decode itself is wrong. The kept slash is normalized to
upper-case %2F. Requests without %2F in the path skip the check entirely and
keep using $uri with no overhead.

Combining %2F with dot segments or consecutive slashes therefore falls back to
standard $uri matching (such ambiguous requests are handled conservatively,
similar to Envoy's default for escaped-slash normalization).

Which issue(s) this PR fixes:

Fixes #11810

Checklist

  • I have explained the need for this PR and the problem it solves
  • I have explained the changes or the new features added to this PR
  • I have added tests corresponding to this change
  • I have updated the documentation to reflect this change
  • I have verified that this change is backward compatible (the option defaults to false)

@dosubot dosubot Bot added size:L This PR changes 100-499 lines, ignoring generated files. enhancement New feature or request labels Jun 29, 2026
@AlinsRan AlinsRan force-pushed the feat/encoded-slash-route-match branch from 6ca9581 to fa757bc Compare June 29, 2026 10:12
@AlinsRan AlinsRan marked this pull request as draft June 29, 2026 23:36
@AlinsRan AlinsRan force-pushed the feat/encoded-slash-route-match branch 3 times, most recently from 3df89d7 to b9da49e Compare June 30, 2026 01:05
Nginx decodes an URL-encoded slash (%2F) into a real '/' in $uri before
route matching, so a request like /v1/te%2Fst/products/electronics/list is
seen as /v1/te/st/products/electronics/list and fails to match a route with
path parameters such as /v1/:id/products/:type/list.

Add an opt-in apisix.preserve_encoded_slash option. When enabled, the route
matching uri is rebuilt from the raw request_uri with every percent-encoding
decoded except %2F, which stays encoded so it is treated as part of a path
parameter instead of a separator. The encoded slash is then forwarded to the
upstream as is. '.' and '..' segments are still normalized to prevent path
traversal, and a decoded null byte is rejected. The rebuild only runs when an
encoded slash is present, so normal requests keep using the normalized $uri
with no overhead.

Closes apache#11810
@AlinsRan AlinsRan force-pushed the feat/encoded-slash-route-match branch from b9da49e to 6e47607 Compare June 30, 2026 01:54
AlinsRan added 4 commits June 30, 2026 15:00
Address review feedback on the preserve_encoded_slash option:

- remove_dot_segments: ngx.re.split drops trailing empty fields, which
  turned "/", "//" and paths that resolve to the root into an empty
  string and silently stripped a trailing slash. Rebuild the path with a
  leading "/", merge consecutive slashes like Nginx, and re-add the
  trailing slash only when at least one segment remains.
- access phase: only inspect the path, so an encoded slash in the query
  string no longer triggers a rebuild.
- docs: warn that the option is global and clarify that only real,
  decoded path separators are normalized.
- tests: assert route matching vs route-not-found, upstream forwarding of
  %2F, and cover root, trailing-slash and query-string cases.
Reorder the segment handling so there is no empty if branch (luacheck
542), keeping the same merge/skip/pop behaviour.
Address review feedback:
- decode_uri_keep_encoded_slash now propagates the ngx.re.gsub error
  instead of silently passing nil to the null-byte check.
- run the preserve_encoded_slash rebuild before delete_uri_tail_slash and
  normalize_uri_like_servlet, and feed its result into them, so those
  options are no longer undone when an encoded slash is present.
…ative-match cases

The upstream-forwarding assertion and the no-match negative case depend on
cross-file etcd state in the shared test suite and are not deterministic.
Keep the path-parameter matching cases, which cover the fix for issue apache#11810.
@AlinsRan AlinsRan marked this pull request as ready for review July 1, 2026 00:52
AlinsRan added 3 commits July 1, 2026 11:18
… preserve_encoded_slash

preserve_encoded_slash only affects route matching; the request forwarded to
the upstream still uses APISIX's normal (decoded) URI. Remove the inaccurate
'forwarded to the upstream as is' wording and add the Chinese documentation.
…_encoded_slash

The option only affects how the matching URI is normalized (it keeps %2F
encoded instead of decoding it to a separator); it does not preserve the
encoded slash end-to-end. Rename it to sit alongside normalize_uri_like_servlet
and reflect that it is a URI-normalization variant used for route matching.
Name it in the delete_uri_tail_slash / normalize_uri_like_servlet family
(verb_uri_object) and scope it to route matching. The option only changes how
%2F is treated when matching the URI; it does not preserve the encoded slash
end-to-end. The internal helper keeps the descriptive name
normalize_uri_keep_encoded_slash.
@AlinsRan AlinsRan changed the title feat(router): add preserve_encoded_slash to keep %2F in path parameters feat(router): add match_uri_encoded_slash to keep %2F in path parameters Jul 1, 2026
Rebuilding the matching URI from the raw request_uri forced us to re-implement
Nginx's whole URI normalization (dot segments, slash merge, NUL, trailing
slash) in Lua, which was the entire safety surface.

Instead, use the already-normalized $uri as an oracle: keep %2F encoded only
when a plain full decode of the request path reproduces $uri exactly (proving
Nginx did nothing beyond decoding). The matching URI is then provably either
$uri or $uri with some '/' shown as %2F. Any request that also needed
normalization (traversal, //, %00, absolute-form, fragment) fails the
equivalence check and falls back to $uri, so no bypass is possible even if the
decode is wrong. The kept slash is normalized to upper-case %2F.

Requests combining %2F with dot segments or consecutive slashes now fall back
to $uri matching (previously matched via the hand-rolled normalization); such
ambiguous requests are handled conservatively. Tests updated accordingly.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request size:L This PR changes 100-499 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug: As a user, I want to use '%2F' in a path parameter

2 participants