mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-10 16:05:58 +00:00
fix: reject path traversal in MCP endpoint path parameters (#9211)
* fix: reject path traversal in MCP endpoint path parameters Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: narrow MCP path-param validator to structural escapes only Codex review: rejecting whitespace/`:`/`@` regressed legitimate Windmill paths (app paths with spaces, email-style usernames like u/admin@windmill.dev/...). These are ordinary path-segment data in an absolute URL and cannot redirect the request. Reject only structural escapes: control chars, `\`, `%`, `?`, `#`, and `.`/`..`/empty segments. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
8b7f7b37bd
commit
ad5ec293b5
@@ -263,6 +263,67 @@ fn get_original_name(renamed_key: &str, field_renames: &Option<Value>) -> String
|
|||||||
.unwrap_or_else(|| renamed_key.to_string())
|
.unwrap_or_else(|| renamed_key.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reject path parameter values that could alter the URL structure of the
|
||||||
|
/// internal backend request (path traversal, query/fragment injection,
|
||||||
|
/// percent-encoded and backslash bypasses).
|
||||||
|
///
|
||||||
|
/// MCP endpoint tools build internal API URLs by string-substituting these
|
||||||
|
/// values into a fixed path template. A value containing `..` segments would
|
||||||
|
/// let a narrowly-scoped tool reach unrelated same-method endpoints once the
|
||||||
|
/// HTTP client normalizes the URL (e.g. `scripts/get/p/../../../resources/...`
|
||||||
|
/// collapses to `resources/...`).
|
||||||
|
///
|
||||||
|
/// Only structural escapes are rejected — not every character the backend
|
||||||
|
/// happens not to use. Windmill paths legitimately contain spaces (app paths)
|
||||||
|
/// and `@` (email-style usernames, e.g. `u/admin@windmill.dev/...`); those are
|
||||||
|
/// ordinary path-segment data in an absolute URL and cannot redirect the
|
||||||
|
/// request, so rejecting them would regress valid MCP calls.
|
||||||
|
fn validate_path_param_value(param_name: &str, value: &str) -> BackendResult<()> {
|
||||||
|
let reject = |reason: &str| {
|
||||||
|
tracing::warn!(
|
||||||
|
"Rejected MCP endpoint path parameter '{}': {}",
|
||||||
|
param_name,
|
||||||
|
reason
|
||||||
|
);
|
||||||
|
Err(ErrorData::invalid_params(
|
||||||
|
format!("Invalid path parameter '{}': {}", param_name, reason),
|
||||||
|
None,
|
||||||
|
))
|
||||||
|
};
|
||||||
|
|
||||||
|
if value.is_empty() {
|
||||||
|
return reject("must not be empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Structurally dangerous characters only:
|
||||||
|
// - control chars (incl. tab/CR/LF): the WHATWG URL parser strips these,
|
||||||
|
// so `.<TAB>.` could be reassembled into `..`
|
||||||
|
// - `\`: WHATWG converts it to `/` for http(s), enabling `..\..\` traversal
|
||||||
|
// - `%`: would let `%2e%2e%2f` decode to `../` server-side
|
||||||
|
// - `?` / `#`: query/fragment delimiters that truncate or redirect the path
|
||||||
|
// A literal space is *not* rejected: the URL crate percent-encodes it
|
||||||
|
// (`%20`) so it cannot alter routing, and app paths legitimately use it.
|
||||||
|
if let Some(bad) = value
|
||||||
|
.chars()
|
||||||
|
.find(|c| c.is_control() || matches!(*c, '\\' | '%' | '?' | '#'))
|
||||||
|
{
|
||||||
|
return reject(&format!("contains disallowed character {:?}", bad));
|
||||||
|
}
|
||||||
|
|
||||||
|
// No leading/trailing slash, no empty/dot/dot-dot segments. Splitting on
|
||||||
|
// `/` keeps legitimate Windmill paths (`u/alice/db`, `f/folder/name`)
|
||||||
|
// valid while catching `..`, `.`, `//`, leading and trailing `/`.
|
||||||
|
for segment in value.split('/') {
|
||||||
|
match segment {
|
||||||
|
"" => return reject("contains an empty path segment or leading/trailing slash"),
|
||||||
|
"." | ".." => return reject("contains a '.' or '..' path segment"),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Substitute path parameters in the URL template
|
/// Substitute path parameters in the URL template
|
||||||
pub fn substitute_path_params(
|
pub fn substitute_path_params(
|
||||||
path: &str,
|
path: &str,
|
||||||
@@ -282,6 +343,7 @@ pub fn substitute_path_params(
|
|||||||
match args_map.get(param_name) {
|
match args_map.get(param_name) {
|
||||||
Some(param_value) => {
|
Some(param_value) => {
|
||||||
if let Some(str_val) = param_value.as_str() {
|
if let Some(str_val) = param_value.as_str() {
|
||||||
|
validate_path_param_value(&original_name, str_val)?;
|
||||||
path_template = path_template.replace(&placeholder, str_val);
|
path_template = path_template.replace(&placeholder, str_val);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -442,3 +504,100 @@ pub async fn parse_response_body(response: Response<Body>) -> BackendResult<Valu
|
|||||||
|
|
||||||
Ok(serde_json::from_str(&body_str).unwrap_or_else(|_| Value::String(body_str)))
|
Ok(serde_json::from_str(&body_str).unwrap_or_else(|_| Value::String(body_str)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_path_param_value_accepts_legitimate_windmill_paths() {
|
||||||
|
for ok in [
|
||||||
|
"u/alice/prod_db",
|
||||||
|
"f/folder/sub/my-script",
|
||||||
|
"g/all",
|
||||||
|
"myscript",
|
||||||
|
"01h00000-0000-0000-0000-000000000000",
|
||||||
|
"123",
|
||||||
|
"u/admin/My App", // app paths legitimately contain spaces
|
||||||
|
"u/admin@windmill.dev/x", // email-style usernames contain '@'
|
||||||
|
"f/folder/tag:v1", // ':' is valid path-segment data
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
validate_path_param_value("path", ok).is_ok(),
|
||||||
|
"expected {ok:?} to be accepted"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn validate_path_param_value_rejects_traversal_and_injection() {
|
||||||
|
for bad in [
|
||||||
|
"../../../resources/get/u/alice/prod_db", // path traversal (the report PoC)
|
||||||
|
"..",
|
||||||
|
".",
|
||||||
|
"a/../b",
|
||||||
|
"a/./b",
|
||||||
|
"/leading",
|
||||||
|
"trailing/",
|
||||||
|
"double//slash",
|
||||||
|
"",
|
||||||
|
"back\\slash", // WHATWG converts '\' -> '/'
|
||||||
|
"with\nnewline", // control char (stripped by URL parser)
|
||||||
|
"tab\there", // control char
|
||||||
|
"query?x=1", // query delimiter truncates the path
|
||||||
|
"frag#ment", // fragment delimiter truncates the path
|
||||||
|
"pct%2e%2e%2fencoded", // percent-encoded `../`
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
validate_path_param_value("path", bad).is_err(),
|
||||||
|
"expected {bad:?} to be rejected"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn substitute_path_params_blocks_cross_endpoint_traversal() {
|
||||||
|
let path_schema = Some(json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": { "path": { "type": "string" } }
|
||||||
|
}));
|
||||||
|
let mut args = serde_json::Map::new();
|
||||||
|
args.insert(
|
||||||
|
"path".to_string(),
|
||||||
|
json!("../../../resources/get/u/alice/prod_db"),
|
||||||
|
);
|
||||||
|
|
||||||
|
let result = substitute_path_params(
|
||||||
|
"/w/{workspace}/scripts/get/p/{path}",
|
||||||
|
"dev",
|
||||||
|
&args,
|
||||||
|
&path_schema,
|
||||||
|
&None,
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
result.is_err(),
|
||||||
|
"traversal payload must be rejected before URL substitution"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn substitute_path_params_allows_normal_path() {
|
||||||
|
let path_schema = Some(json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": { "path": { "type": "string" } }
|
||||||
|
}));
|
||||||
|
let mut args = serde_json::Map::new();
|
||||||
|
args.insert("path".to_string(), json!("u/alice/my_script"));
|
||||||
|
|
||||||
|
let result = substitute_path_params(
|
||||||
|
"/w/{workspace}/scripts/get/p/{path}",
|
||||||
|
"dev",
|
||||||
|
&args,
|
||||||
|
&path_schema,
|
||||||
|
&None,
|
||||||
|
)
|
||||||
|
.expect("legitimate path should substitute");
|
||||||
|
assert_eq!(result, "/w/dev/scripts/get/p/u/alice/my_script");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user