release(secrets-mcp): 0.5.4 — Web 分页修正与 hex 解码;批量删除上限;MCP @ 路径检测
Some checks failed
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 4m55s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Failing after 6s

This commit is contained in:
voson
2026-04-05 11:48:40 +08:00
parent 1860cce86c
commit 9d6ac5c13a
11 changed files with 92 additions and 32 deletions

3
Cargo.lock generated
View File

@@ -2049,6 +2049,7 @@ dependencies = [
"aes-gcm",
"anyhow",
"chrono",
"hex",
"rand 0.10.0",
"serde",
"serde_json",
@@ -2065,7 +2066,7 @@ dependencies = [
[[package]]
name = "secrets-mcp"
version = "0.5.3"
version = "0.5.4"
dependencies = [
"anyhow",
"askama",

View File

@@ -12,6 +12,7 @@ aes-gcm.workspace = true
anyhow.workspace = true
thiserror.workspace = true
chrono.workspace = true
hex = "0.4"
rand.workspace = true
serde.workspace = true
serde_json.workspace = true

View File

@@ -61,7 +61,7 @@ pub fn decrypt_json(master_key: &[u8; 32], data: &[u8]) -> Result<Value> {
/// Parse a 64-char hex string (from X-Encryption-Key header) into a 32-byte key.
pub fn extract_key_from_hex(hex_str: &str) -> Result<[u8; 32]> {
let bytes = hex::decode_hex(hex_str.trim())?;
let bytes = ::hex::decode(hex_str.trim())?;
if bytes.len() != 32 {
bail!(
"X-Encryption-Key must be 64 hex chars (32 bytes), got {} bytes",
@@ -76,21 +76,14 @@ pub fn extract_key_from_hex(hex_str: &str) -> Result<[u8; 32]> {
// ─── Public hex helpers ───────────────────────────────────────────────────────
pub mod hex {
use anyhow::{Result, bail};
use anyhow::Result;
pub fn encode_hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{:02x}", b)).collect()
}
pub fn decode_hex(s: &str) -> Result<Vec<u8>> {
let s = s.trim();
if !s.len().is_multiple_of(2) {
bail!("hex string has odd length");
}
(0..s.len())
.step_by(2)
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).map_err(|e| anyhow::anyhow!("{}", e)))
.collect()
Ok(::hex::decode(s.trim())?)
}
}

View File

@@ -243,6 +243,11 @@ pub async fn run(pool: &PgPool, params: AddParams<'_>, master_key: &[u8; 32]) ->
tracing::warn!(error = %e, "failed to snapshot entry history before upsert");
}
// Upsert the entry row. On conflict (existing entry with same user_id+folder+name),
// the entry columns are replaced wholesale. The old secret associations are torn down
// below within the same transaction, so the whole operation is atomic: if any step
// after this point fails, the transaction rolls back and the entry reverts to its
// pre-upsert state (including the version bump that happened in the DO UPDATE clause).
let entry_id: Uuid = if let Some(uid) = params.user_id {
sqlx::query_scalar(
r#"INSERT INTO entries (user_id, folder, type, name, notes, tags, metadata, version, updated_at)

View File

@@ -11,6 +11,7 @@ pub async fn list_for_user(
offset: i64,
) -> Result<Vec<AuditLogEntry>> {
let limit = limit.clamp(1, 200);
let offset = offset.max(0);
let rows = sqlx::query_as(
"SELECT id, user_id, action, folder, type, name, detail, created_at \

View File

@@ -31,6 +31,10 @@ pub struct DeleteParams<'a> {
pub user_id: Option<Uuid>,
}
/// Maximum number of entries that can be deleted in a single bulk operation.
/// Prevents accidental mass deletion when filters are too broad.
pub const MAX_BULK_DELETE: usize = 1000;
/// Delete a single entry by id (multi-tenant: `user_id` must match).
pub async fn delete_by_id(pool: &PgPool, entry_id: Uuid, user_id: Uuid) -> Result<DeleteResult> {
let mut tx = pool.begin().await?;
@@ -374,6 +378,16 @@ async fn delete_bulk(
}
let rows = q.fetch_all(&mut *tx).await?;
if rows.len() > MAX_BULK_DELETE {
tx.rollback().await?;
anyhow::bail!(
"Bulk delete would affect {} entries (limit: {}). \
Narrow your filters or delete entries individually.",
rows.len(),
MAX_BULK_DELETE,
);
}
let mut deleted = Vec::with_capacity(rows.len());
for row in &rows {
let entry_row: EntryRow = EntryRow {

View File

@@ -402,8 +402,8 @@ pub async fn run(
&mut tx,
params.user_id,
"update",
"",
"",
&row.folder,
&row.entry_type,
params.name,
serde_json::json!({
"add_tags": params.add_tags,

View File

@@ -1,6 +1,6 @@
[package]
name = "secrets-mcp"
version = "0.5.3"
version = "0.5.4"
edition.workspace = true
[[bin]]

View File

@@ -611,6 +611,10 @@ fn map_to_kv_strings(map: Map<String, Value>) -> Vec<String> {
/// contain `@` characters (e.g. `config:=@/etc/passwd`), the `:=` branch in
/// `parse_kv` treats the right-hand side as raw JSON and never performs file
/// reads. The `@` in such cases is just data, not a file reference.
///
/// For entries without `=` that contain `@`, we only reject them if the `@`
/// appears to be file-path syntax (i.e., the part after `@` starts with `/`,
/// `~`, or `.`). This avoids false positives on values like `user@example.com`.
fn contains_file_reference(entries: &[String]) -> Option<String> {
for entry in entries {
// key:=json — safe, skip before checking for `=`
@@ -625,12 +629,14 @@ fn contains_file_reference(entries: &[String]) -> Option<String> {
continue;
}
// key@path (no `=` present)
// parse_kv treats entries without `=` that contain `@` as file-read
// syntax (key@path). This includes strings like "user@example.com"
// if passed without a `=` separator — which is correct to reject here
// since the MCP server runs remotely and cannot read local files.
if entry.contains('@') {
return Some(entry.clone());
// Only reject if the `@` looks like file-path syntax: the segment after
// `@` starts with `/`, `~`, or `.`, which are common path prefixes.
// Values like "user@example.com" pass through safely.
if let Some((_, path_part)) = entry.split_once('@') {
let trimmed = path_part.trim_start();
if trimmed.starts_with('/') || trimmed.starts_with('~') || trimmed.starts_with('.') {
return Some(entry.clone());
}
}
}
None

View File

@@ -199,6 +199,15 @@ fn request_user_agent(headers: &HeaderMap) -> Option<String> {
.map(ToOwned::to_owned)
}
fn paginate(page: u32, total_count: i64, page_size: u32) -> (u32, u32, u32) {
let page_size = page_size.max(1);
let safe_total_count = u32::try_from(total_count.max(0)).unwrap_or(u32::MAX);
let total_pages = safe_total_count.div_ceil(page_size).max(1);
let current_page = page.max(1).min(total_pages);
let offset = (current_page - 1).saturating_mul(page_size);
(current_page, total_pages, offset)
}
// ── Routes ────────────────────────────────────────────────────────────────────
pub fn web_router() -> Router<AppState> {
@@ -605,8 +614,7 @@ async fn entries_page(
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
let page = q.page.unwrap_or(1).max(1);
let offset = (page - 1) * ENTRIES_PAGE_LIMIT;
let params = SearchParams {
let count_params = SearchParams {
folder: folder_filter.as_deref(),
entry_type: type_filter.as_deref(),
name: None,
@@ -615,18 +623,22 @@ async fn entries_page(
query: None,
sort: "updated",
limit: ENTRIES_PAGE_LIMIT,
offset,
offset: 0,
user_id: Some(user_id),
};
let total_count = count_entries(&state.pool, &params)
let total_count = count_entries(&state.pool, &count_params)
.await
.inspect_err(|e| tracing::warn!(error = %e, "count_entries failed for web entries page"))
.unwrap_or(0);
let total_pages = (total_count as u32).div_ceil(ENTRIES_PAGE_LIMIT).max(1);
let current_page = page.min(total_pages);
let (current_page, total_pages, offset) = paginate(page, total_count, ENTRIES_PAGE_LIMIT);
let rows = list_entries(&state.pool, params).await.map_err(|e| {
let list_params = SearchParams {
offset,
..count_params
};
let rows = list_entries(&state.pool, list_params).await.map_err(|e| {
tracing::error!(error = %e, "failed to load entries list for web");
StatusCode::INTERNAL_SERVER_ERROR
})?;
@@ -846,11 +858,8 @@ async fn audit_page(
StatusCode::INTERNAL_SERVER_ERROR
})?;
let total_pages = (total_count as u32)
.div_ceil(AUDIT_PAGE_LIMIT as u32)
.max(1);
let current_page = page.min(total_pages);
let actual_offset = ((current_page - 1) as i64) * AUDIT_PAGE_LIMIT;
let (current_page, total_pages, offset) = paginate(page, total_count, AUDIT_PAGE_LIMIT as u32);
let actual_offset = i64::from(offset);
let rows = list_for_user(&state.pool, user_id, AUDIT_PAGE_LIMIT, actual_offset)
.await
@@ -1751,4 +1760,29 @@ mod tests {
assert!(matches!(request_ui_lang(&headers), UiLang::ZhTw));
}
#[test]
fn paginate_clamps_page_before_computing_offset() {
let (current_page, total_pages, offset) = paginate(100, 12, 10);
assert_eq!(current_page, 2);
assert_eq!(total_pages, 2);
assert_eq!(offset, 10);
}
#[test]
fn paginate_handles_large_page_without_overflow() {
let (current_page, total_pages, offset) = paginate(u32::MAX, 1, ENTRIES_PAGE_LIMIT);
assert_eq!(current_page, 1);
assert_eq!(total_pages, 1);
assert_eq!(offset, 0);
}
#[test]
fn paginate_saturates_large_total_count() {
let (_, total_pages, _) = paginate(1, i64::MAX, ENTRIES_PAGE_LIMIT);
assert_eq!(total_pages, u32::MAX.div_ceil(ENTRIES_PAGE_LIMIT));
}
}

View File

@@ -584,6 +584,11 @@ var SECRET_TYPE_OPTIONS = JSON.parse(document.getElementById('secret-type-option
checkingSecretName: '检查中...',
secretNameAvailable: '名称可用',
secretNameTaken: '该名称已被使用',
secretNameInvalid: '名称不合法',
secretNameCheckError: '校验失败,请重试',
secretNameFixBeforeSave: '请先修复密文名称校验问题后再保存',
secretTypePlaceholder: '选择类型',
secretTypeInvalid: '类型不能为空',
prevPage: '上一页',
nextPage: '下一页',
},