From cab234cfcbc0be32f18391e7c78aa5962ca13057 Mon Sep 17 00:00:00 2001 From: voson Date: Mon, 6 Apr 2026 10:53:06 +0800 Subject: [PATCH] =?UTF-8?q?feat(secrets-mcp):=20=E5=A2=9E=E5=BC=BA=20MCP?= =?UTF-8?q?=20=E8=AF=B7=E6=B1=82=E6=97=A5=E5=BF=97=E4=B8=8E=20encryption?= =?UTF-8?q?=5Fkey=20=E5=8F=82=E6=95=B0=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit logging.rs: - 每条 MCP POST 日志新增 auth_key(Bearer token 前12字符掩码)、 enc_key(X-Encryption-Key 前4后4字符指纹,如 146b…5516(64) 或 absent)、 user_id、tool_args(白名单非敏感参数摘要)字段 - 新增辅助函数 mask_bearer / mask_enc_key / extract_tool_args / summarize_value tools.rs: - extract_enc_key 成功路径增加 debug 级指纹日志(raw_len/trimmed_len/prefix/suffix) - 新增 extract_enc_key_or_arg / require_user_and_key_or_arg:优先使用参数传入的密钥, fallback 到 X-Encryption-Key 头,绕过 Cursor Chat MCP 头透传异常 - GetSecretInput / AddInput / UpdateInput / ExportInput / EnvMapInput 各增加可选 encryption_key 字段,对应工具实现改用 require_user_and_key_or_arg --- crates/secrets-mcp/src/logging.rs | 141 +++++++++++++++++++++++++++++- crates/secrets-mcp/src/tools.rs | 104 ++++++++++++++++++++-- 2 files changed, 235 insertions(+), 10 deletions(-) diff --git a/crates/secrets-mcp/src/logging.rs b/crates/secrets-mcp/src/logging.rs index b20466b..0f982ad 100644 --- a/crates/secrets-mcp/src/logging.rs +++ b/crates/secrets-mcp/src/logging.rs @@ -5,20 +5,24 @@ use axum::{ extract::Request, http::{ HeaderMap, Method, StatusCode, - header::{CONTENT_LENGTH, CONTENT_TYPE, USER_AGENT}, + header::{AUTHORIZATION, CONTENT_LENGTH, CONTENT_TYPE, USER_AGENT}, }, middleware::Next, response::{IntoResponse, Response}, }; +use crate::auth::AuthUser; + /// Axum middleware that logs structured info for every HTTP request. /// /// All requests: method, path, status, latency_ms, client_ip, user_agent. /// POST /mcp requests: additionally parses JSON-RPC body for jsonrpc_method, -/// tool_name, jsonrpc_id, mcp_session, batch_size. +/// tool_name, jsonrpc_id, mcp_session, batch_size, tool_args (non-sensitive +/// arguments only), plus masked auth_key / enc_key fingerprints and user_id +/// for diagnosing header forwarding issues. /// -/// Sensitive headers (Authorization, X-Encryption-Key) and secret values -/// are never logged. +/// Sensitive headers (Authorization, X-Encryption-Key) are never logged in +/// full — only short fingerprints are emitted. pub async fn request_logging_middleware(req: Request, next: Next) -> Response { let method = req.method().clone(); let path = req.uri().path().to_string(); @@ -32,6 +36,10 @@ pub async fn request_logging_middleware(req: Request, next: Next) -> Response { .and_then(|v| v.to_str().ok()) .map(|s| s.to_string()); + // Capture header fingerprints before consuming the request. + let auth_key = mask_bearer(req.headers()); + let enc_key = mask_enc_key(req.headers()); + let is_mcp_post = path.starts_with("/mcp") && method == Method::POST; let is_json = header_str(req.headers(), CONTENT_TYPE) .map(|ct| ct.contains("application/json")) @@ -45,6 +53,11 @@ pub async fn request_logging_middleware(req: Request, next: Next) -> Response { let cap = content_len.unwrap_or(0); if cap <= 512 * 1024 { let (parts, body) = req.into_parts(); + // user_id is available after auth middleware has run (injected into extensions). + let user_id = parts + .extensions + .get::() + .map(|a| a.user_id.to_string()); match to_bytes(body, 512 * 1024).await { Ok(bytes) => { let rpc = parse_jsonrpc_meta(&bytes); @@ -61,6 +74,9 @@ pub async fn request_logging_middleware(req: Request, next: Next) -> Response { ua.as_deref(), content_len, mcp_session.as_deref(), + auth_key.as_deref(), + &enc_key, + user_id.as_deref(), &rpc, ); return resp; @@ -77,6 +93,9 @@ pub async fn request_logging_middleware(req: Request, next: Next) -> Response { ua = ua.as_deref(), content_length = content_len, mcp_session = mcp_session.as_deref(), + auth_key = auth_key.as_deref(), + enc_key = enc_key.as_str(), + user_id = user_id.as_deref(), "mcp request", ); return ( @@ -159,6 +178,9 @@ fn log_mcp_request( ua: Option<&str>, content_length: Option, mcp_session: Option<&str>, + auth_key: Option<&str>, + enc_key: &str, + user_id: Option<&str>, rpc: &JsonRpcMeta, ) { tracing::info!( @@ -174,18 +196,94 @@ fn log_mcp_request( tool = rpc.tool_name.as_deref(), jsonrpc_id = rpc.request_id.as_deref(), batch_size = rpc.batch_size, + tool_args = rpc.tool_args.as_deref(), + auth_key, + enc_key, + user_id, "mcp request", ); } +// ── Sensitive header masking ────────────────────────────────────────────────── + +/// Mask a Bearer token: emit only the first 12 characters followed by `…`. +/// Returns `None` if the Authorization header is absent or not a Bearer token. +/// Example: `sk_90c88844e4e5…` +fn mask_bearer(headers: &HeaderMap) -> Option { + let val = headers.get(AUTHORIZATION)?.to_str().ok()?; + let token = val.strip_prefix("Bearer ")?.trim(); + if token.is_empty() { + return None; + } + if token.len() > 12 { + Some(format!("{}…", &token[..12])) + } else { + Some(token.to_string()) + } +} + +/// Fingerprint the X-Encryption-Key header. +/// +/// Emits first 4 chars, last 4 chars, and raw byte length, e.g. `146b…5516(64)`. +/// Returns `"absent"` when the header is missing. Reveals enough to confirm +/// which key arrived and whether it was truncated or padded, without revealing +/// the full value. +fn mask_enc_key(headers: &HeaderMap) -> String { + match headers + .get("x-encryption-key") + .and_then(|v| v.to_str().ok()) + { + Some(val) => { + let raw_len = val.len(); + let t = val.trim(); + let len = t.len(); + if len >= 8 { + let prefix = &t[..4]; + let suffix = &t[len - 4..]; + if raw_len != len { + // Trailing/leading whitespace detected — extra diagnostic. + format!("{prefix}…{suffix}({len}, raw={raw_len})") + } else { + format!("{prefix}…{suffix}({len})") + } + } else { + format!("…({len})") + } + } + None => "absent".to_string(), + } +} + // ── JSON-RPC body parsing ───────────────────────────────────────────────────── +/// Safe (non-sensitive) argument keys that may be included verbatim in logs. +/// Keys NOT in this list (e.g. `secrets`, `secrets_obj`, `meta_obj`, +/// `encryption_key`) are silently dropped. +const SAFE_ARG_KEYS: &[&str] = &[ + "id", + "name", + "name_query", + "folder", + "type", + "entry_type", + "field", + "query", + "tags", + "limit", + "offset", + "format", + "dry_run", + "prefix", +]; + #[derive(Debug, Default)] struct JsonRpcMeta { request_id: Option, rpc_method: Option, tool_name: Option, batch_size: Option, + /// Non-sensitive tool call arguments for diagnostic logging. + tool_args: Option, } fn parse_jsonrpc_meta(bytes: &Bytes) -> JsonRpcMeta { @@ -215,12 +313,47 @@ fn parse_single(value: &serde_json::Value) -> JsonRpcMeta { .pointer("/params/name") .and_then(|v| v.as_str()) .map(|s| s.to_string()); + let tool_args = extract_tool_args(value); JsonRpcMeta { request_id, rpc_method, tool_name, batch_size: None, + tool_args, + } +} + +/// Extract a compact summary of non-sensitive tool arguments for logging. +/// Only keys listed in `SAFE_ARG_KEYS` are included. +fn extract_tool_args(value: &serde_json::Value) -> Option { + let args = value.pointer("/params/arguments")?; + let obj = args.as_object()?; + let pairs: Vec = obj + .iter() + .filter(|(k, v)| SAFE_ARG_KEYS.contains(&k.as_str()) && !v.is_null()) + .map(|(k, v)| format!("{}={}", k, summarize_value(v))) + .collect(); + if pairs.is_empty() { + None + } else { + Some(pairs.join(" ")) + } +} + +/// Produce a short, log-safe representation of a JSON value. +fn summarize_value(v: &serde_json::Value) -> String { + match v { + serde_json::Value::String(s) => { + if s.len() > 64 { + format!("\"{}…\"", &s[..64]) + } else { + format!("\"{s}\"") + } + } + serde_json::Value::Array(arr) => format!("[…{}]", arr.len()), + serde_json::Value::Object(_) => "{…}".to_string(), + other => other.to_string(), } } diff --git a/crates/secrets-mcp/src/tools.rs b/crates/secrets-mcp/src/tools.rs index 016a72b..a94ce43 100644 --- a/crates/secrets-mcp/src/tools.rs +++ b/crates/secrets-mcp/src/tools.rs @@ -265,6 +265,18 @@ impl SecretsService { rmcp::ErrorData::invalid_request("Invalid X-Encryption-Key header value", None) })?; let trimmed = hex_str.trim(); + // Debug-level fingerprint: helps diagnose header forwarding issues + // (e.g. Cursor Chat MCP truncating or transforming the key value) + // without revealing the full secret. + tracing::debug!( + raw_len = hex_str.len(), + trimmed_len = trimmed.len(), + key_prefix = trimmed.get(..8).unwrap_or(trimmed), + key_suffix = trimmed + .get(trimmed.len().saturating_sub(8)..) + .unwrap_or(trimmed), + "X-Encryption-Key received", + ); if trimmed.len() != 64 { tracing::warn!( got_len = trimmed.len(), @@ -289,7 +301,51 @@ impl SecretsService { .map_err(mcp_err_invalid_encryption_key_logged) } - /// Require both user_id and encryption key. + /// Extract the encryption key, preferring an explicit argument value over + /// the X-Encryption-Key HTTP header. + /// + /// `arg_key` is the optional `encryption_key` field from the tool's input + /// struct. When present, it is used directly and the header is ignored. + /// This allows MCP clients that cannot reliably forward custom HTTP headers + /// (e.g. Cursor Chat) to pass the key as a normal tool argument. + fn extract_enc_key_or_arg( + ctx: &RequestContext, + arg_key: Option<&str>, + ) -> Result<[u8; 32], rmcp::ErrorData> { + if let Some(hex_str) = arg_key { + let trimmed = hex_str.trim(); + tracing::debug!( + source = "argument", + raw_len = hex_str.len(), + trimmed_len = trimmed.len(), + key_prefix = trimmed.get(..8).unwrap_or(trimmed), + key_suffix = trimmed + .get(trimmed.len().saturating_sub(8)..) + .unwrap_or(trimmed), + "X-Encryption-Key received", + ); + if trimmed.len() != 64 { + return Err(rmcp::ErrorData::invalid_request( + format!( + "encryption_key must be exactly 64 hex characters (32-byte key), got {}.", + trimmed.len() + ), + None, + )); + } + if !trimmed.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(rmcp::ErrorData::invalid_request( + "encryption_key contains non-hexadecimal characters.", + None, + )); + } + return secrets_core::crypto::extract_key_from_hex(trimmed) + .map_err(mcp_err_invalid_encryption_key_logged); + } + Self::extract_enc_key(ctx) + } + + /// Require both user_id and encryption key (header only, no arg fallback). fn require_user_and_key( ctx: &RequestContext, ) -> Result<(Uuid, [u8; 32]), rmcp::ErrorData> { @@ -297,6 +353,17 @@ impl SecretsService { let key = Self::extract_enc_key(ctx)?; Ok((user_id, key)) } + + /// Require both user_id and encryption key, preferring an explicit argument + /// value over the X-Encryption-Key header. + fn require_user_and_key_or_arg( + ctx: &RequestContext, + arg_key: Option<&str>, + ) -> Result<(Uuid, [u8; 32]), rmcp::ErrorData> { + let user_id = Self::require_user_id(ctx)?; + let key = Self::extract_enc_key_or_arg(ctx, arg_key)?; + Ok((user_id, key)) + } } // ── Tool parameter types ────────────────────────────────────────────────────── @@ -370,6 +437,10 @@ struct GetSecretInput { id: String, #[schemars(description = "Specific field to retrieve. If omitted, returns all fields.")] field: Option, + #[schemars(description = "Encryption key as a 64-char hex string. \ + If provided, takes priority over the X-Encryption-Key HTTP header. \ + Use this when the MCP client cannot reliably forward custom headers.")] + encryption_key: Option, } #[derive(Debug, Deserialize, JsonSchema)] @@ -416,6 +487,10 @@ struct AddInput { )] #[serde(default, deserialize_with = "deser::option_vec_string_from_string")] link_secret_names: Option>, + #[schemars(description = "Encryption key as a 64-char hex string. \ + If provided, takes priority over the X-Encryption-Key HTTP header. \ + Use this when the MCP client cannot reliably forward custom headers.")] + encryption_key: Option, } #[derive(Debug, Deserialize, JsonSchema)] @@ -477,6 +552,10 @@ struct UpdateInput { )] #[serde(default, deserialize_with = "deser::option_vec_string_from_string")] unlink_secret_names: Option>, + #[schemars(description = "Encryption key as a 64-char hex string. \ + If provided, takes priority over the X-Encryption-Key HTTP header. \ + Use this when the MCP client cannot reliably forward custom headers.")] + encryption_key: Option, } #[derive(Debug, Deserialize, JsonSchema)] @@ -549,6 +628,10 @@ struct ExportInput { query: Option, #[schemars(description = "Export format: 'json' (default), 'toml', 'yaml'")] format: Option, + #[schemars(description = "Encryption key as a 64-char hex string. \ + If provided, takes priority over the X-Encryption-Key HTTP header. \ + Use this when the MCP client cannot reliably forward custom headers.")] + encryption_key: Option, } #[derive(Debug, Deserialize, JsonSchema)] @@ -572,6 +655,10 @@ struct EnvMapInput { Example: entry 'aliyun', field 'access_key_id' → ALIYUN_ACCESS_KEY_ID \ (or PREFIX_ALIYUN_ACCESS_KEY_ID with prefix set).")] prefix: Option, + #[schemars(description = "Encryption key as a 64-char hex string. \ + If provided, takes priority over the X-Encryption-Key HTTP header. \ + Use this when the MCP client cannot reliably forward custom headers.")] + encryption_key: Option, } #[derive(Debug, Deserialize, JsonSchema)] @@ -876,7 +963,8 @@ impl SecretsService { ctx: RequestContext, ) -> Result { let t = Instant::now(); - let (user_id, user_key) = Self::require_user_and_key(&ctx)?; + let (user_id, user_key) = + Self::require_user_and_key_or_arg(&ctx, input.encryption_key.as_deref())?; let entry_id = parse_uuid(&input.id)?; tracing::info!( tool = "secrets_get", @@ -930,7 +1018,8 @@ impl SecretsService { ctx: RequestContext, ) -> Result { let t = Instant::now(); - let (user_id, user_key) = Self::require_user_and_key(&ctx)?; + let (user_id, user_key) = + Self::require_user_and_key_or_arg(&ctx, input.encryption_key.as_deref())?; tracing::info!( tool = "secrets_add", ?user_id, @@ -1024,7 +1113,8 @@ impl SecretsService { ctx: RequestContext, ) -> Result { let t = Instant::now(); - let (user_id, user_key) = Self::require_user_and_key(&ctx)?; + let (user_id, user_key) = + Self::require_user_and_key_or_arg(&ctx, input.encryption_key.as_deref())?; tracing::info!( tool = "secrets_update", ?user_id, @@ -1318,7 +1408,8 @@ impl SecretsService { ctx: RequestContext, ) -> Result { let t = Instant::now(); - let (user_id, user_key) = Self::require_user_and_key(&ctx)?; + let (user_id, user_key) = + Self::require_user_and_key_or_arg(&ctx, input.encryption_key.as_deref())?; let tags = input.tags.unwrap_or_default(); let format = input.format.as_deref().unwrap_or("json"); tracing::info!( @@ -1387,7 +1478,8 @@ impl SecretsService { ctx: RequestContext, ) -> Result { let t = Instant::now(); - let (user_id, user_key) = Self::require_user_and_key(&ctx)?; + let (user_id, user_key) = + Self::require_user_and_key_or_arg(&ctx, input.encryption_key.as_deref())?; let tags = input.tags.unwrap_or_default(); let only_fields = input.only_fields.unwrap_or_default(); tracing::info!(