nic-6443 commented on code in PR #14035:
URL: https://github.com/apache/apisix/pull/14035#discussion_r4292601465
##########
apisix/init.lua:
##########
@@ -887,6 +898,20 @@ function _M.http_access_phase()
end
end
+ -- Nginx resolved dot segments in $uri, so forward the path the route was
+ -- matched on.
+ if not (local_conf.apisix and
local_conf.apisix.normalize_uri_like_servlet) then
+ local path = api_ctx.var.request_uri
+ local args_pos = core.string.find(path, "?")
+ if args_pos then
+ path = str_sub(path, 1, args_pos - 1)
+ end
+ if path ~= uri and has_dot_segment(path) then
+ api_ctx.var.upstream_uri = core.utils.uri_safe_encode(uri)
Review Comment:
Setting `$upstream_uri` here, before any plugin has run, freezes both the
path and the raw query for every dot-segment request. A non-empty
`$upstream_uri` wins in `proxy_pass`, so anything that later edits the request
through `ngx.req.set_uri` / `set_uri_args` instead of writing `$upstream_uri`
gets silently thrown away. The worst case is `hide_credentials`: key-auth /
jwt-auth strip a query credential with `set_uri_args`, so the credential now
reaches the upstream as soon as the client adds a `./`:
```
# consumer key SECRET; route /ka/* with key-auth {hide_credentials = true}
--- request
GET /ka/./x?apikey=SECRET&a=1
--- response_body
request-uri=/ka/x?a=1
```
That passes on master. With this PR the upstream gets
`/ka/x?apikey=SECRET&a=1`. Same thing with a serverless function:
`ngx.req.set_uri("/svl/changed")` on `GET /svl/./x` reaches the upstream as
`/svl/x`, and `ngx.req.set_uri_args("added=1")` on `GET /sva/./x?b=2` still
sends `?b=2`. Reading the code, degraphql's GET path (`set_uri_args`) and
grpc-web (`ngx.req.set_uri`) have the same problem. proxy-mirror mirrors the
frozen value, credential included.
ext-plugin is affected too, because it treats a non-empty `$upstream_uri` as
"rewritten" (ext-plugin/init.lua ~494). On dot-segment requests the runner gets
the encoded path instead of `var.uri`, and the frozen query is written back
into `$args` with `set_uri_args`, so ext-plugin-post-req puts back the
credential key-auth just removed. A runner that rewrites only args loses the
change (~708), because `upstream_uri` is only rebuilt when the runner also
returns a path. The comment in the websocket path ("upstream_uri is only ever
set by plugins like proxy-rewrite", ~1227), which ext-plugin,
ext-plugin-post-resp and proxy-mirror also depend on, is no longer true either.
The raw query also ends up in `$upstream_uri`, and the default
`access_log_format` logs that variable, so data-mask's query masking no longer
covers these requests. With data-mask replacing `password`, `GET
/mask/./x?password=abc` logs `"http://localhost/mask/x?password=abc"` next to
the masked `$request_line`. On master nothing unmasked is logged.
I'd leave `$upstream_uri` alone and invalidate the unparsed URI instead.
Nginx then rebuilds the request line from the live `r->uri` / `r->args` at
proxy time, which is already what happens on master whenever key-auth strips a
query credential:
```lua
if uri_normalized_by_nginx(api_ctx.var.request_uri, uri) then
-- binary: $uri may hold decoded control chars, proxy_pass escapes them
ngx.req.set_uri(uri, false, true)
end
```
I tried this locally (with the `$uri`-oracle helper from my other comment).
Every case above goes back to master's behaviour, the bypass cases stay fixed,
the PR's test file passes, and t/router, key-auth, jwt-auth, proxy-rewrite,
proxy-mirror, data-mask, serverless and core/ctx show no new failures compared
with this PR head. You need the `binary` flag: without it, `GET /api/./a%0Ab`
becomes a 500 (`unsafe byte "0x0a"`).
I also tried the other direction: keep the flag here, but set
`$upstream_uri` late (right before `handle_upstream`, only if it is still
empty), from `ngx.var.uri` plus `ngx.var.args`. That's the same split Kong uses
between access.before and access.after. It fixes hide_credentials, the
set_uri/set_uri_args cases and ext-plugin, because all of those run before the
assignment. But the raw query still ends up in the access log, so I prefer
`set_uri`.
##########
apisix/init.lua:
##########
@@ -887,6 +898,20 @@ function _M.http_access_phase()
end
end
+ -- Nginx resolved dot segments in $uri, so forward the path the route was
+ -- matched on.
+ if not (local_conf.apisix and
local_conf.apisix.normalize_uri_like_servlet) then
+ local path = api_ctx.var.request_uri
Review Comment:
nit: this query strip duplicates the one in the `match_uri_encoded_slash`
block a few lines below. If the helper takes `request_uri` directly (sketch in
the comment on `has_dot_segment`), the raw path is derived in one place.
##########
apisix/init.lua:
##########
@@ -591,6 +591,17 @@ local function build_match_uri_keep_encoded_slash(path,
current_uri)
end
+-- whether the decoded path has a "." or ".." segment
+local function has_dot_segment(path)
+ if not core.string.find(path, ".") and not core.string.find(path, "%") then
Review Comment:
`has_dot_segment` re-implements part of nginx's normalization and misses the
forms it doesn't model. One of them is a literal `#`: nginx cuts the path there
before it resolves dot segments, so `GET /admin/..#x` gets `$uri` = `/`. The
decoded raw path `/admin/..#x/` contains no `/../`, so the raw line is
forwarded unchanged even though the request was matched as `/`:
```
# route uri = "/" without auth, upstream echoes $request_uri
--- raw_request eval
"GET /admin/..#x HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"
--- response_body
request-uri=/
```
(It currently returns `request-uri=/admin/..#x`.) This is narrower in
practice than `..%2F`, but it's the same class. Rather than modelling nginx,
I'd use `$uri` itself as the oracle, the way
`build_match_uri_keep_encoded_slash` already does: the raw path is safe to pass
through only if its percent-decoding, with slashes merged, equals `$uri`. That
covers dot segments, fragments and anything else nginx does, and it still
leaves `%2F`-only and `//`-only requests alone (TEST 6 and TEST 7 here still
pass with it).
It's also cheaper. Right now every request with a query pays for a `str_sub`
copy, and every percent-encoded path pays for a `re_gsub` decode with a Lua
callback. With a `has_prefix` check first, an unchanged request costs a single
comparison and no allocation:
```lua
local function uri_normalized_by_nginx(request_uri, uri)
if request_uri == uri then
return false
end
if core.string.has_prefix(request_uri, uri)
and str_byte(request_uri, #uri + 1) == str_byte("?") then
return false
end
local path = request_uri
local args_pos = str_find(path, "?", 1, true)
if args_pos then
path = str_sub(path, 1, args_pos - 1)
end
local decoded = percent_decode(path, false)
if decoded == uri then
return false
end
return re_gsub(decoded, [[//+]], "/", "jo") ~= uri
end
```
##########
docs/en/latest/router-radixtree.md:
##########
@@ -240,6 +240,20 @@ when either actually rewrites the URI (a stripped trailing
slash, a servlet-styl
matching without keeping `%2F`. The fallback is safe; the encoded-slash match
simply does not apply to such requests.
+#### 6. Path forwarded to the upstream
+
+Routes are matched on the normalized `$uri`: Nginx percent-decodes the path,
+resolves dot segments (including encoded ones such as `..%2F` and `%2e%2e`) and
+merges consecutive slashes. When the decoded request path has no `.` or `..`
+segment, APISIX forwards the original request line to the upstream, consecutive
+slashes included. When it has one, APISIX forwards the matched path re-encoded,
+followed by the original query string. For example, a request to
Review Comment:
Two things here don't match what actually happens.
With `normalize_uri_like_servlet` this block is skipped, and the servlet
branch sets `$upstream_uri` without args, so the query is dropped. That's
already the case on master, and for every request, not just dot-segment ones:
```
# yaml: apisix.normalize_uri_like_servlet: true, route /api/*
--- request
GET /api/x?q=1
--- response_body
request-uri=/api/x?q=1
```
The upstream gets `/api/x`, both on master and on this PR. That probably
needs its own fix, but this sentence shouldn't read as if it also applied to
servlet mode.
It's also worth documenting a trade-off: the forwarded path is `$uri`
re-encoded, so encoded delimiters near a dot segment arrive decoded. `GET
/api/./a%2Fb%3Bc` reaches the upstream as `/api/a/b;c`, while `GET /api/a%2Fb`
is forwarded raw. For the `..%2F` form this can't be avoided, since that `%2F`
is exactly the slash nginx treated as a separator when matching. For
`/./a%2Fb`-style requests it could be avoided by removing dot segments on the
raw path and using the result only when its decoding equals `$uri`. I don't
think that's worth the extra code, though, so a sentence here is enough.
##########
t/router/radixtree-uri-normalized-upstream.t:
##########
@@ -0,0 +1,185 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements. See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+use t::APISIX 'no_plan';
+
+repeat_each(1);
+log_level('info');
+worker_connections(256);
+no_root_location();
+no_shuffle();
+
+add_block_preprocessor(sub {
+ my ($block) = @_;
+
+ if (!$block->upstream_server_config) {
+ $block->set_value("upstream_server_config", <<'_EOC_');
+ location /admin/ {
+ content_by_lua_block {
+ ngx.say("request-uri=", ngx.var.request_uri)
+ }
+ }
+
+ location /api/ {
+ content_by_lua_block {
+ ngx.say("request-uri=", ngx.var.request_uri)
+ }
+ }
+
+ location = /fixed {
+ content_by_lua_block {
+ ngx.say("request-uri=", ngx.var.request_uri)
+ }
+ }
+_EOC_
+ }
+
+ if (!$block->request) {
+ $block->set_value("request", "GET /t");
+ }
+});
+
+run_tests();
+
+__DATA__
+
+=== TEST 1: set routes with and without auth on the same upstream
+--- config
+ location /t {
+ content_by_lua_block {
+ local t = require("lib.test_admin").test
+ local routes = {
+ {
+ id = "admin",
+ uri = "/admin/*",
+ plugins = {
+ ["key-auth"] = {},
+ },
+ },
+ {
+ id = "api",
+ uri = "/api/*",
+ },
+ {
+ id = "rewrite",
+ uri = "/rewrite/*",
+ plugins = {
+ ["proxy-rewrite"] = {
+ uri = "/fixed",
+ },
+ },
+ },
+ }
+
+ for _, route in ipairs(routes) do
+ route.upstream = {
+ nodes = { ["127.0.0.1:1980"] = 1 },
+ type = "roundrobin",
+ }
+
+ local code, body = t("/apisix/admin/routes/" .. route.id,
+ ngx.HTTP_PUT, route)
+ if code >= 300 then
+ ngx.status = code
+ ngx.say(body)
+ return
+ end
+ end
+
+ ngx.say("passed")
+ }
+ }
+--- response_body
+passed
+
+
+
+=== TEST 2: route with key-auth requires an API key
+--- request
+GET /admin/x
+--- error_code: 401
+--- response_body
+{"message":"Missing API key in request"}
+
+
+
+=== TEST 3: encoded slash dot segment forwards the matched path
+--- request
+GET /admin/..%2Fapi/x
+--- response_body
+request-uri=/api/x
+
+
+
+=== TEST 4: encoded dot segment forwards the matched path with args
+--- request
+GET /admin/%2e%2e/api/x?a=1&b=%2F
+--- response_body
+request-uri=/api/x?a=1&b=%2F
+
+
+
+=== TEST 5: plain dot segment forwards the matched path
+--- request
+GET /admin/../api/x
+--- response_body
+request-uri=/api/x
+
+
+
+=== TEST 6: merged slashes alone are forwarded unchanged
+--- request
+GET /api/https://example.com//x
+--- response_body
+request-uri=/api/https://example.com//x
+
+
+
+=== TEST 7: encoded path without normalization is forwarded unchanged
+--- request
+GET /api/a%2Fb%20c?x=1
+--- response_body
+request-uri=/api/a%2Fb%20c?x=1
+
+
+
+=== TEST 8: proxy-rewrite uri still takes precedence
Review Comment:
The cases that would have caught the problems above are missing:
- a plugin that changes the args or the path after matching (key-auth with
`hide_credentials` and the key in the query, a serverless `ngx.req.set_uri` /
`set_uri_args`)
- a single `.` segment (`/api/./x`; every case here uses `..`)
- a `#` after `..`
- the interaction with `delete_uri_tail_slash` (`/api/./x/` should forward
`/api/x/` like `/api/x/` does, which works today because the pre-strip `uri` is
used) and with servlet mode
Snippets for most of these are in the init.lua comments.
##########
apisix/init.lua:
##########
@@ -887,6 +898,20 @@ function _M.http_access_phase()
end
end
+ -- Nginx resolved dot segments in $uri, so forward the path the route was
+ -- matched on.
+ if not (local_conf.apisix and
local_conf.apisix.normalize_uri_like_servlet) then
Review Comment:
Minor: this changes what upstreams receive by default for every dot-segment
request, and there's no switch to get the old behaviour back. I think that's
fine for a security fix. Kong did the same in 2.3.2 (Kong/kong#6821): it
changed the default in a patch release without a switch. It's still worth a
CHANGELOG / upgrade note, because an upstream that relied on seeing the raw
form will notice the difference.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]