feat(secrets-mcp): 增强 MCP 请求日志与 encryption_key 参数支持
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 3m28s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 1m36s

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
This commit is contained in:
voson
2026-04-06 10:53:06 +08:00
parent e0fee639c1
commit cab234cfcb
2 changed files with 235 additions and 10 deletions

View File

@@ -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::<AuthUser>()
.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<u64>,
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<String> {
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<String>,
rpc_method: Option<String>,
tool_name: Option<String>,
batch_size: Option<usize>,
/// Non-sensitive tool call arguments for diagnostic logging.
tool_args: Option<String>,
}
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<String> {
let args = value.pointer("/params/arguments")?;
let obj = args.as_object()?;
let pairs: Vec<String> = 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(),
}
}