mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-05 08:02:18 +00:00
* feat: expose allowlisted request headers to scripts invoked via MCP Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F4i1qCTY9HQMqCPTBeTiV9 * fix: close header-forgery routes flagged in review of MCP header passthrough Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F4i1qCTY9HQMqCPTBeTiV9 * fix: match allowlisted headers exactly and withdraw every model-args run path Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F4i1qCTY9HQMqCPTBeTiV9 * fix: address review nits on MCP header passthrough Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F4i1qCTY9HQMqCPTBeTiV9 * fix: stop over-withdrawing deleteScriptByHash and align schema strip key space Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F4i1qCTY9HQMqCPTBeTiV9 * refactor: move MCP header field detail into a label tooltip Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F4i1qCTY9HQMqCPTBeTiV9 * fix: bound include_header parsing and narrow the duplicate-header drop * feat: handle runnable-executing tools instead of withdrawing them * docs: record the preprocessor kind seam on proxied run-by-path * fix: strip every runnable argument map and open the field to gateway tokens * fix: withhold connection credentials from runnables unless explicitly named * fix: keep endpoint control arguments out of the transport-owned strip * fix: exempt workspace_id from the strip only where it routes the call * style: reindent the MCP header tooltip block * refactor: deliver MCP request headers through the preprocessor only * fix: widen the proxy-owned header set and clear docs left by the redesign * fix: count proxied header delivery and finish the redesign doc sweep * fix: forward proxied headers only to a runnable that has a preprocessor * refactor: drop include_header and the MCP credential deny list Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F4i1qCTY9HQMqCPTBeTiV9 * chore: restore the blank line in CreateToken Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F4i1qCTY9HQMqCPTBeTiV9 * refactor: drop the mcp header_passthrough feature usage counter Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F4i1qCTY9HQMqCPTBeTiV9 * test: pin that a caller credential other than the hop's own travels Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F4i1qCTY9HQMqCPTBeTiV9 * refactor: deliver headers only through the direct script and flow tools Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F4i1qCTY9HQMqCPTBeTiV9 * feat: withhold connection credentials and pin MCP header delivery end to end Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F4i1qCTY9HQMqCPTBeTiV9 * test: send every credential the withheld-list assertions cover Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F4i1qCTY9HQMqCPTBeTiV9 --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
187 lines
6.7 KiB
Rust
187 lines
6.7 KiB
Rust
//! Request headers reaching a runnable's preprocessor over MCP.
|
|
//!
|
|
//! The property this pins is structural rather than a filter: the model writes
|
|
//! the tool's arguments, which become `event.body`, while the server writes
|
|
//! `event.headers`. A model that guesses a header's name can only ever land in
|
|
//! `body`, so an identity read from `headers` is one prompt injection cannot
|
|
//! forge. Nothing else in the suite exercises MCP argument shaping end to end.
|
|
//!
|
|
//! Requires: bun runtime, live database (migrations applied by sqlx::test).
|
|
#![cfg(feature = "mcp")]
|
|
|
|
use serde_json::{json, Value};
|
|
use sqlx::{Pool, Postgres};
|
|
use windmill_test_utils::*;
|
|
|
|
const SCRIPT_PATH: &str = "u/test-user/mcp_hdr_probe";
|
|
|
|
/// Echoes the two halves of the event separately, so the assertions can tell
|
|
/// which one a value arrived in.
|
|
const PREPROCESSOR_SCRIPT: &str = r#"
|
|
export async function preprocessor(event: any) {
|
|
return {
|
|
kind: event.kind,
|
|
from_headers: event.headers?.["x-user-id"] ?? "",
|
|
from_body: event.body?.x_user_id ?? "",
|
|
header_names: Object.keys(event.headers ?? {}).sort(),
|
|
};
|
|
}
|
|
|
|
export async function main(kind: string, from_headers: string, from_body: string, header_names: string[]) {
|
|
return { kind, from_headers, from_body, header_names };
|
|
}
|
|
"#;
|
|
|
|
async fn insert_mcp_token(db: &Pool<Postgres>) -> anyhow::Result<()> {
|
|
sqlx::query(
|
|
"INSERT INTO token (token_hash, token_prefix, token, email, label, super_admin, scopes)
|
|
VALUES (encode(sha256('MCP_TOKEN'::bytea), 'hex'), 'MCP_TOK', 'MCP_TOKEN', 'test@windmill.dev', 'mcp token', true, ARRAY['mcp:all'])",
|
|
)
|
|
.execute(db)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// POST one JSON-RPC message. The endpoint answers either `application/json` or
|
|
/// a single-event SSE stream, so strip the `data: ` framing before parsing.
|
|
async fn mcp_post(port: u16, headers: &[(&str, &str)], body: Value) -> anyhow::Result<Value> {
|
|
let mut req = reqwest::Client::new()
|
|
.post(format!(
|
|
"http://localhost:{port}/api/mcp/w/test-workspace/mcp"
|
|
))
|
|
.header("Authorization", "Bearer MCP_TOKEN")
|
|
.header("Accept", "application/json, text/event-stream")
|
|
.json(&body);
|
|
for (k, v) in headers {
|
|
req = req.header(*k, *v);
|
|
}
|
|
let text = req.send().await?.text().await?;
|
|
let payload = text
|
|
.lines()
|
|
.find_map(|l| l.strip_prefix("data: "))
|
|
.unwrap_or(text.trim());
|
|
serde_json::from_str(payload).map_err(|e| anyhow::anyhow!("unparseable MCP body {text:?}: {e}"))
|
|
}
|
|
|
|
#[sqlx::test(migrations = "../migrations", fixtures("base"))]
|
|
async fn test_mcp_preprocessor_receives_the_callers_headers(
|
|
db: Pool<Postgres>,
|
|
) -> anyhow::Result<()> {
|
|
initialize_tracing().await;
|
|
set_jwt_secret().await;
|
|
insert_mcp_token(&db).await?;
|
|
let server = ApiServer::start_mcp(db.clone()).await?;
|
|
let port = server.addr.port();
|
|
|
|
let resp = reqwest::Client::new()
|
|
.post(format!(
|
|
"http://localhost:{port}/api/w/test-workspace/scripts/create"
|
|
))
|
|
.header("Authorization", "Bearer SECRET_TOKEN")
|
|
.json(&json!({
|
|
"path": SCRIPT_PATH,
|
|
"summary": "mcp header probe",
|
|
"description": "",
|
|
"content": PREPROCESSOR_SCRIPT,
|
|
"language": "bun",
|
|
"lock": "",
|
|
"schema": {
|
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
"type": "object",
|
|
"properties": { "x_user_id": { "type": "string" } },
|
|
"required": []
|
|
}
|
|
}))
|
|
.send()
|
|
.await?;
|
|
assert_eq!(
|
|
resp.status(),
|
|
201,
|
|
"create script: {}",
|
|
resp.text().await.unwrap_or_default()
|
|
);
|
|
|
|
// A script counts as deployed once it has a lock, which normally arrives from
|
|
// a dependency job. Planting an empty one keeps the test to the path under
|
|
// test instead of a bun resolution whose timing it does not control.
|
|
sqlx::query("UPDATE script SET lock = '' WHERE path = $1 AND workspace_id = 'test-workspace'")
|
|
.bind(SCRIPT_PATH)
|
|
.execute(&db)
|
|
.await?;
|
|
|
|
let tools = mcp_post(
|
|
port,
|
|
&[],
|
|
json!({"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {}}),
|
|
)
|
|
.await?;
|
|
let tool_name = tools["result"]["tools"]
|
|
.as_array()
|
|
.and_then(|list| {
|
|
list.iter()
|
|
.filter_map(|t| t["name"].as_str())
|
|
.find(|n| n.contains("mcp__hdr__probe"))
|
|
})
|
|
.ok_or_else(|| anyhow::anyhow!("the deployed script was not listed as a tool: {tools}"))?
|
|
.to_string();
|
|
|
|
let result = in_test_worker(
|
|
db.clone(),
|
|
async {
|
|
mcp_post(
|
|
port,
|
|
// Every name the withheld list covers has to be on the wire, or
|
|
// asserting its absence proves nothing. `Authorization` is already
|
|
// set by `mcp_post`, and `extract_token` reads it before the
|
|
// cookie, so sending one does not disturb auth.
|
|
&[
|
|
("X-User-Id", "alice@corp.example"),
|
|
("Cookie", "session=secret"),
|
|
("Proxy-Authorization", "Basic Zm9v"),
|
|
],
|
|
json!({
|
|
"jsonrpc": "2.0", "id": 2, "method": "tools/call",
|
|
// The model names the header it wants to spoof. Its value is an
|
|
// argument, so it can only ever reach `event.body`.
|
|
"params": { "name": tool_name, "arguments": { "x_user_id": "attacker@evil.test" } }
|
|
}),
|
|
)
|
|
.await
|
|
},
|
|
port,
|
|
)
|
|
.await?;
|
|
|
|
let text = result["result"]["content"][0]["text"]
|
|
.as_str()
|
|
.ok_or_else(|| anyhow::anyhow!("tool call returned no text content: {result}"))?;
|
|
let out: Value = serde_json::from_str(text)?;
|
|
|
|
assert_eq!(out["kind"], "mcp", "preprocessor event kind: {out}");
|
|
assert_eq!(
|
|
out["from_headers"], "alice@corp.example",
|
|
"the caller's header must reach event.headers: {out}"
|
|
);
|
|
assert_eq!(
|
|
out["from_body"], "attacker@evil.test",
|
|
"the model's argument must land in event.body, not overwrite the header: {out}"
|
|
);
|
|
|
|
let names: Vec<&str> = out["header_names"]
|
|
.as_array()
|
|
.map(|a| a.iter().filter_map(|v| v.as_str()).collect())
|
|
.unwrap_or_default();
|
|
assert!(
|
|
names.contains(&"x-user-id"),
|
|
"event.headers must carry the request's own headers: {names:?}"
|
|
);
|
|
for withheld in ["authorization", "cookie", "proxy-authorization"] {
|
|
assert!(
|
|
!names.contains(&withheld),
|
|
"{withheld} is withheld from a preprocessor: {names:?}"
|
|
);
|
|
}
|
|
|
|
Ok(())
|
|
}
|