nic-6443 commented on code in PR #14154:
URL: https://github.com/apache/apisix/pull/14154#discussion_r4475591373
##########
apisix/plugins/openapi-to-mcp/tools/handler.lua:
##########
@@ -124,19 +124,72 @@ local function apply_query_defaults(tool, query)
end
+-- A path parameter fills part of one segment of the operation's path, so its
+-- value cannot carry a separator. A "/" goes out as "%2F", which a server that
+-- accepts an encoded separator decodes back into one, and the call would then
+-- address a path the operation does not describe. This reads the value as it
+-- arrived, because the encoding hides the separator from the finished path.
+local function has_separator(value)
+ -- core.string.find is a plain search; this is a pattern, so it needs
+ -- string.find itself. A backslash is a separator on some servers.
+ return str_find(value, "[/\\]") ~= nil
+end
+
+
+-- The segments of a path, with the empty one a leading or trailing slash
+-- leaves: "/items/x" is { "", "items", "x" }.
+local function path_segments(path)
+ local segments = {}
+ for segment in str_gmatch(path .. "/", "([^/]*)/") do
+ segments[#segments + 1] = segment
+ end
+ return segments
+end
+
+
+-- A server receiving the request resolves "." and ".." away, but only where
+-- one is a whole segment. A template expression may fill part of a segment,
+-- and "{name}.json" filled with ".." comes out as "...json", which addresses
+-- nothing else, so the value alone does not say whether it moves the request.
+-- The finished path does.
+--
+-- Only the segments an expression helped build are checked: one the template
+-- contributes by itself is not the caller's to shift. Rejecting a separator
+-- above keeps both segment lists the same length, so they line up by index.
+local function resolved_away(segment)
+ return segment == "" or segment == "." or segment == ".."
+end
+
+
local function build_path(template, path_params)
local path = template
for name, value in pairs(path_params) do
+ local text = tostring(value)
+ if has_separator(text) then
Review Comment:
I'd relax this check. Rejecting every `/` and `\` also turns away calls that
have nothing to do with traversal: GitLab's `/projects/{id}` documents
`group%2Fproject` as the way to call it, and GitHub's
`/repos/{owner}/{repo}/contents/{path}` with `src/index.js` or a FastAPI
`{file_path:path}` route have the same shape. The caller can't work around it
either, since sending `group%2Fproject` gets encoded again into
`group%252Fproject`. The OpenAPI spec only rules out an unescaped `/`, and the
value goes out escaped.
What we want to stop is a value that climbs out of its segment, not any
value with a slash in it. So I'd only reject a value with a separator when one
of its pieces is empty, `.` or `..`:
```lua
local function climbs_out(value)
if not str_find(value, "[/\\]") then
return false
end
for piece in str_gmatch(value .. "/", "([^/\\]*)[/\\]") do
if resolved_away(piece) then
return true
end
end
return false
end
```
(`resolved_away` has to move above it.) `../admin`, `a/../b` and `a//b`
still fail, `group/project` goes out as `group%2Fproject`, and a value with no
separator stays with the finished-path check below. `escape_uri` already
encodes the separator, so the two segment lists still line up by index. The
comment above `resolved_away`, the docs and the separator tests (TEST 90, 91,
98) would need to follow, and the backward-compatibility caveat in the
description goes away.
How a server treats `%2F` beyond that is the server's call. We can't keep up
with every decoder out there, but we can make sure the URL we build doesn't
climb.
##########
apisix/plugins/openapi-to-mcp/tools/handler.lua:
##########
@@ -124,19 +124,72 @@ local function apply_query_defaults(tool, query)
end
+-- A path parameter fills part of one segment of the operation's path, so its
+-- value cannot carry a separator. A "/" goes out as "%2F", which a server that
+-- accepts an encoded separator decodes back into one, and the call would then
+-- address a path the operation does not describe. This reads the value as it
+-- arrived, because the encoding hides the separator from the finished path.
+local function has_separator(value)
+ -- core.string.find is a plain search; this is a pattern, so it needs
+ -- string.find itself. A backslash is a separator on some servers.
+ return str_find(value, "[/\\]") ~= nil
+end
+
+
+-- The segments of a path, with the empty one a leading or trailing slash
+-- leaves: "/items/x" is { "", "items", "x" }.
+local function path_segments(path)
+ local segments = {}
+ for segment in str_gmatch(path .. "/", "([^/]*)/") do
+ segments[#segments + 1] = segment
+ end
+ return segments
+end
+
+
+-- A server receiving the request resolves "." and ".." away, but only where
+-- one is a whole segment. A template expression may fill part of a segment,
+-- and "{name}.json" filled with ".." comes out as "...json", which addresses
+-- nothing else, so the value alone does not say whether it moves the request.
+-- The finished path does.
+--
+-- Only the segments an expression helped build are checked: one the template
+-- contributes by itself is not the caller's to shift. Rejecting a separator
+-- above keeps both segment lists the same length, so they line up by index.
+local function resolved_away(segment)
+ return segment == "" or segment == "." or segment == ".."
+end
+
+
local function build_path(template, path_params)
local path = template
for name, value in pairs(path_params) do
+ local text = tostring(value)
+ if has_separator(text) then
+ return nil, "path parameter " .. name .. " cannot contain a path
separator"
Review Comment:
Small one: `_M.call` warns when it drops a header parameter, but this
rejection and the one below leave nothing in the gateway log, so an operator
can't see someone probing for traversal or work out why a legit call is being
turned away. A `core.log.warn` with the error where `build_path` fails at line
418 would cover both.
##########
apisix/plugins/openapi-to-mcp/tools/handler.lua:
##########
@@ -124,19 +124,72 @@ local function apply_query_defaults(tool, query)
end
+-- A path parameter fills part of one segment of the operation's path, so its
+-- value cannot carry a separator. A "/" goes out as "%2F", which a server that
+-- accepts an encoded separator decodes back into one, and the call would then
+-- address a path the operation does not describe. This reads the value as it
+-- arrived, because the encoding hides the separator from the finished path.
+local function has_separator(value)
+ -- core.string.find is a plain search; this is a pattern, so it needs
+ -- string.find itself. A backslash is a separator on some servers.
+ return str_find(value, "[/\\]") ~= nil
+end
+
+
+-- The segments of a path, with the empty one a leading or trailing slash
+-- leaves: "/items/x" is { "", "items", "x" }.
+local function path_segments(path)
+ local segments = {}
+ for segment in str_gmatch(path .. "/", "([^/]*)/") do
+ segments[#segments + 1] = segment
+ end
+ return segments
+end
+
+
+-- A server receiving the request resolves "." and ".." away, but only where
+-- one is a whole segment. A template expression may fill part of a segment,
+-- and "{name}.json" filled with ".." comes out as "...json", which addresses
+-- nothing else, so the value alone does not say whether it moves the request.
+-- The finished path does.
+--
+-- Only the segments an expression helped build are checked: one the template
+-- contributes by itself is not the caller's to shift. Rejecting a separator
+-- above keeps both segment lists the same length, so they line up by index.
+local function resolved_away(segment)
+ return segment == "" or segment == "." or segment == ".."
+end
+
+
local function build_path(template, path_params)
local path = template
for name, value in pairs(path_params) do
+ local text = tostring(value)
+ if has_separator(text) then
+ return nil, "path parameter " .. name .. " cannot contain a path
separator"
+ end
-- the name is a literal, so escape any pattern magic in it
local pattern = "{" .. str_gsub(name, "([%^%$%(%)%%%.%[%]%*%+%-%?])",
"%%%1") .. "}"
- local escaped = escape_uri(tostring(value))
+ local escaped = escape_uri(text)
-- The replacement has to be a function: percent-encoding produces
"%2F"
-- and friends, and gsub would read those as capture references in a
-- string replacement and raise "invalid capture index".
path = str_gsub(path, pattern, function()
return escaped
end)
end
+
+ local declared = path_segments(template)
+ for index, segment in ipairs(path_segments(path)) do
Review Comment:
This misses templates that carry a query or fragment, because the last
segment then holds more than the expression. `/{Bucket}?acl` with `Bucket=..`
goes out as `/..?acl`: the segment is `..?acl`, `resolved_away` doesn't match,
and the server resolves the path part to `/`. An empty `Bucket` gives `/?acl`,
and `/{Bucket}/{Key}#acl` with `Key=..` gives `/b/..#acl`. `endpoints.extract`
passes these templates through as they are, and S3-style documents use them.
A `?` or `#` in the finished path can only come from the template, since
values are encoded, so cutting both strings at the first one (`^[^?#]*`) before
splitting is enough.
##########
apisix/plugins/openapi-to-mcp/tools/handler.lua:
##########
@@ -124,19 +124,72 @@ local function apply_query_defaults(tool, query)
end
+-- A path parameter fills part of one segment of the operation's path, so its
+-- value cannot carry a separator. A "/" goes out as "%2F", which a server that
+-- accepts an encoded separator decodes back into one, and the call would then
+-- address a path the operation does not describe. This reads the value as it
+-- arrived, because the encoding hides the separator from the finished path.
+local function has_separator(value)
+ -- core.string.find is a plain search; this is a pattern, so it needs
+ -- string.find itself. A backslash is a separator on some servers.
+ return str_find(value, "[/\\]") ~= nil
+end
+
+
+-- The segments of a path, with the empty one a leading or trailing slash
+-- leaves: "/items/x" is { "", "items", "x" }.
+local function path_segments(path)
+ local segments = {}
+ for segment in str_gmatch(path .. "/", "([^/]*)/") do
+ segments[#segments + 1] = segment
+ end
+ return segments
+end
+
+
+-- A server receiving the request resolves "." and ".." away, but only where
+-- one is a whole segment. A template expression may fill part of a segment,
+-- and "{name}.json" filled with ".." comes out as "...json", which addresses
+-- nothing else, so the value alone does not say whether it moves the request.
+-- The finished path does.
+--
+-- Only the segments an expression helped build are checked: one the template
+-- contributes by itself is not the caller's to shift. Rejecting a separator
+-- above keeps both segment lists the same length, so they line up by index.
+local function resolved_away(segment)
+ return segment == "" or segment == "." or segment == ".."
+end
+
+
local function build_path(template, path_params)
local path = template
for name, value in pairs(path_params) do
+ local text = tostring(value)
+ if has_separator(text) then
+ return nil, "path parameter " .. name .. " cannot contain a path
separator"
+ end
-- the name is a literal, so escape any pattern magic in it
local pattern = "{" .. str_gsub(name, "([%^%$%(%)%%%.%[%]%*%+%-%?])",
"%%%1") .. "}"
- local escaped = escape_uri(tostring(value))
+ local escaped = escape_uri(text)
-- The replacement has to be a function: percent-encoding produces
"%2F"
-- and friends, and gsub would read those as capture references in a
-- string replacement and raise "invalid capture index".
path = str_gsub(path, pattern, function()
return escaped
end)
end
+
+ local declared = path_segments(template)
+ for index, segment in ipairs(path_segments(path)) do
+ local from_template = declared[index]
+ if from_template and str_find(from_template, "{", 1, true)
+ and resolved_away(segment)
+ then
+ return nil, "path parameter in segment " .. from_template ..
+ " cannot produce an empty or dot path segment"
+ end
+ end
+
return path
Review Comment:
Not new in this PR, but this loop walks right past it: a `{expr}` that never
got a value goes out literally. When the document doesn't mark the parameter as
required, schema validation passes and `{}` sends `/items/{id}/detail`.
`/x/{a}{b}` with only `a=..` sends `/x/..{b}`, which also gets past the dot
check.
Values are encoded, so a `{` left in the finished path can only be an
unfilled expression, and failing the call on that would close both.
##########
docs/en/latest/plugins/openapi-to-mcp.md:
##########
@@ -65,6 +65,7 @@ When a tool is called, the Plugin builds the request from the
operation:
* Query parameters are serialized according to their `style` and `explode`, as
defined by the [OpenAPI Parameter
Object](https://spec.openapis.org/oas/v3.0.3#style-values). With the defaults
(`form`, exploded), `tags: ["a", "b"]` is sent as `tags=a&tags=b` -- not as
`tags[]=a&tags[]=b`, and an array parameter declared `explode: false` is sent
as `tags=a,b`. `spaceDelimited`, `pipeDelimited` and `deepObject` are
supported. An API that expects the bracket form has to be reached through a
Plugin that rewrites the query string.
* A request body is sent with the media type the operation declares, unless
`headers` sets `Content-Type`.
* Header parameters are applied before the Plugin's own `headers`, so a tool
call cannot replace a credential the route adds, and a header whose value
contains a newline is dropped.
+* A path parameter cannot contain a slash or a backslash, and once every
parameter is filled in, a segment an expression helped build cannot be empty,
`.` or `..`. Either fails the call with `isError` set, because the server
receiving the request would resolve such a path to one the operation does not
describe. A parameter that fills only part of a segment is free to hold those
values: `/files/{name}.json` with `name` set to `..` addresses `/files/...json`.
Review Comment:
`those values` reads as if it covers the slash and backslash from earlier in
the sentence, but TEST 98 rejects a separator in a partly filled segment too.
It isn't unconditional for dots either: `/x/{a}{b}` with `.` and `.` is
rejected, and so is `/x/.{a}` with `.`.
How about stating the rule instead: "What counts is the finished segment, so
`/files/{name}.json` with `name` set to `..` is accepted and addresses
`/files/...json`." Line 68 of the zh page needs the same change.
##########
t/plugin/openapi-to-mcp.t:
##########
@@ -1573,3 +1573,173 @@ streamable_http 5: true
streamable_http 6: true
streamable_http 7: true
streamable_http 8: true
+
+
+
+=== TEST 86: a route over a document with a string path parameter
Review Comment:
Coverage gap: all of these use one expression with nested `pathParameters`,
so a check that only looks at segments that are exactly one expression would
still pass every one of them. Worth adding the cases the finished-path check
exists for: `/x/{a}{b}` with `.` and `.`, `/x/{a}.{b}` with two empty values,
`/x/.{a}` with `.`, a run with `flatten_parameters`, and `?` / `#` templates
for the case above.
--
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]