脱敏 VIAPI 临时桶上传错误体,避免泄漏 STS 临时凭证

OSS 签名类错误(如 SignatureDoesNotMatch)会在错误体里回显 StringToSign
(含 x-oss-security-token 明文)、StringToSignBytes(十六进制)和 SignatureProvided。
原实现把该错误体裸拼进 MattingError 消息,随后经 aliyun_matting 进入
external_api_call_failure 审计的 errorMessage/rawExcerpt 与 OTLP 日志,
泄漏 STS 临时凭证。

- 上传非 2xx 分支改用 sanitize_oss_upload_error_body:剥掉 StringToSign /
  StringToSignBytes / SignatureProvided 三个元素,并把已知 security_token
  在正文任何位置的出现替换为 ***,保留 <Code> 供诊断后再记录。
- 新增 redact_xml_element(无正则依赖)与单测覆盖脱敏效果。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 02:49:30 +00:00
parent d3c3823c43
commit cf7f75cb6f
+67 -1
View File
@@ -448,10 +448,12 @@ impl MattingClient {
let status = response.status();
if !status.is_success() {
let body = response.text().await.unwrap_or_default();
// OSS 签名类错误体会回显 StringToSign(含 x-oss-security-token 明文)、StringToSignBytes
// 和 SignatureProvided;脱敏后再记录,避免 STS 临时凭证进审计元数据与日志。
return Err(MattingError::Upstream(format!(
"上传 VIAPI 临时桶失败(HTTP {}):{}",
status.as_u16(),
truncate_for_log(&body)
sanitize_oss_upload_error_body(&body, &sts.security_token)
)));
}
@@ -685,6 +687,47 @@ fn sanitize_result_download_error_message(message: String) -> String {
message
}
/// 脱敏 OSS 上传错误体:OSS 签名类错误会回显 StringToSign(含 `x-oss-security-token` 明文)、
/// StringToSignBytes(其十六进制)和 SignatureProvided(签名串)。剥掉这三个元素,并把已知的
/// STS 临时凭证在正文任何位置的出现替换为 `***`,保留 `<Code>` 等可诊断信息后再记录。
fn sanitize_oss_upload_error_body(body: &str, security_token: &str) -> String {
let mut sanitized = body.to_string();
for tag in ["StringToSign", "StringToSignBytes", "SignatureProvided"] {
sanitized = redact_xml_element(&sanitized, tag);
}
let security_token = security_token.trim();
if !security_token.is_empty() {
sanitized = sanitized.replace(security_token, "***");
}
truncate_for_log(&sanitized)
}
/// 把 `<tag>…</tag>` 的内容替换成 `[redacted]`(tag 内容可跨行);只有开标签无闭标签时丢弃其后全部内容。
fn redact_xml_element(text: &str, tag: &str) -> String {
let open = format!("<{tag}>");
let close = format!("</{tag}>");
let mut result = String::with_capacity(text.len());
let mut rest = text;
while let Some(start) = rest.find(&open) {
result.push_str(&rest[..start]);
result.push_str(&open);
result.push_str("[redacted]");
let after_open = start + open.len();
match rest[after_open..].find(&close) {
Some(rel_end) => {
result.push_str(&close);
rest = &rest[after_open + rel_end + close.len()..];
}
None => {
rest = "";
break;
}
}
}
result.push_str(rest);
result
}
fn insert_header(
headers: &mut reqwest::header::HeaderMap,
name: &'static str,
@@ -839,6 +882,29 @@ mod tests {
);
}
#[test]
fn oss_upload_error_body_redacts_signature_material_and_token() {
let token = "CAISabcSECRETtoken123";
let body = format!(
"<Error><Code>SignatureDoesNotMatch</Code>\
<Message>mismatch for {token}</Message>\
<SignatureProvided>sigSECRET==</SignatureProvided>\
<StringToSignBytes>50 55 54 0a</StringToSignBytes>\
<StringToSign>PUT\nx-oss-security-token:{token}\n/bucket/key</StringToSign></Error>"
);
let sanitized = sanitize_oss_upload_error_body(&body, token);
// 敏感串全部消失:StringToSign 明文、其中的 token、十六进制、签名串、以及 Message 里的 token 明文。
assert!(!sanitized.contains(token), "STS token 不能残留(含元素外的明文)");
assert!(!sanitized.contains("x-oss-security-token:CAIS"), "StringToSign 明文不能残留");
assert!(!sanitized.contains("sigSECRET"), "SignatureProvided 不能残留");
assert!(!sanitized.contains("50 55 54 0a"), "StringToSignBytes 不能残留");
// 可诊断信息保留。
assert!(sanitized.contains("SignatureDoesNotMatch"), "OSS Code 应保留供诊断");
assert!(sanitized.contains("[redacted]"), "签名材料元素应被脱敏为 [redacted]");
}
fn noise_image(width: u32, height: u32) -> image::DynamicImage {
// 不易压缩的伪随机噪声,保证 PNG 体积随像素数量增长,可触发降尺寸循环。
let mut img = image::RgbaImage::new(width, height);