diff --git a/apisix/init.lua b/apisix/init.lua index b25069c7445b..026b7fd194e6 100644 --- a/apisix/init.lua +++ b/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 + return false + end + local decoded = percent_decode(path, false) .. "/" + return core.string.find(decoded, "/./") ~= nil + or core.string.find(decoded, "/../") ~= nil +end + + local function common_phase(phase_name) local api_ctx = ngx.ctx.api_ctx if not api_ctx then @@ -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) + .. api_ctx.var.is_args .. (api_ctx.var.args or "") + end + end + -- To prevent being hacked by untrusted request_uri, here we -- record the normalized but not rewritten uri as request_uri, -- the original request_uri can be accessed via var.real_request_uri diff --git a/docs/en/latest/router-radixtree.md b/docs/en/latest/router-radixtree.md index 8b68d9d5c979..91292b1495f5 100644 --- a/docs/en/latest/router-radixtree.md +++ b/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 +`/admin/..%2Fapi/x` matches a route for `/api/*` and the upstream receives +`/api/x`. + +Plugins that set the upstream URI, such as `proxy-rewrite`, still take +precedence. + ### How to filter route by Nginx built-in variable? Nginx provides a variety of built-in variables that can be used to filter routes based on certain criteria. Here is an example of how to filter routes by Nginx built-in variables: diff --git a/docs/zh/latest/router-radixtree.md b/docs/zh/latest/router-radixtree.md index 6695affba2c3..5db2babeb758 100644 --- a/docs/zh/latest/router-radixtree.md +++ b/docs/zh/latest/router-radixtree.md @@ -231,6 +231,12 @@ apisix: 该选项会让位于 `delete_uri_tail_slash` 和 `normalize_uri_like_servlet`:等价性检查是与这两个选项处理之后的 URI 比较的,因此当其中任一确实改写了 URI(去掉末尾斜杠、剥离 servlet 风格的 `;` 参数)时,检查将不再成立,请求会回退到普通匹配而不保留 `%2F`。这种回退是安全的,只是保留编码斜杠的匹配对这类请求不再生效。 +#### 6. 转发给上游的路径 + +路由基于归一化后的 `$uri` 进行匹配:Nginx 会对路径进行百分号解码、解析点段(包括 `..%2F`、`%2e%2e` 等编码形式)并合并连续斜杠。当解码后的请求路径不含 `.` 或 `..` 段时,APISIX 将原始请求行(包括连续斜杠)转发给上游。当路径包含这类段时,APISIX 会转发重新编码后的匹配路径,并附带原始查询字符串。例如,请求 `/admin/..%2Fapi/x` 会匹配 `/api/*` 路由,上游收到的是 `/api/x`。 + +设置上游 URI 的插件(如 `proxy-rewrite`)仍然优先生效。 + ### 如何通过 Nginx 内置变量过滤路由 具体参数及使用方式请查看 [radixtree#new](https://github.com/api7/lua-resty-radixtree#new) 文档,下面是一个简单的示例: diff --git a/t/router/radixtree-uri-normalized-upstream.t b/t/router/radixtree-uri-normalized-upstream.t new file mode 100644 index 000000000000..c92bf7c14e7e --- /dev/null +++ b/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 +--- request +GET /rewrite/a/..%2Fb +--- response_body +request-uri=/fixed + + + +=== TEST 9: remove the routes +--- config + location /t { + content_by_lua_block { + local t = require("lib.test_admin").test + for _, id in ipairs({"admin", "api", "rewrite"}) do + local code, body = t("/apisix/admin/routes/" .. id, ngx.HTTP_DELETE) + if code >= 300 then + ngx.status = code + ngx.say(body) + return + end + end + + ngx.say("passed") + } + } +--- response_body +passed