fix oss bucket-prefixed signing

This commit is contained in:
2026-05-08 14:20:56 +08:00
parent b08127031c
commit 20d5121c6c
3 changed files with 120 additions and 58 deletions

View File

@@ -14,7 +14,13 @@
- 关联:相关文件、文档、提交或 Issue
```
---
## OSS V4 签名时间和 bucket/object_key 兼容
- 现象:使用 `xushi-dev/generated-square-hole-assets/.../image.png` 这类带 bucket 前缀的对象路径做私有读签名时,请求可能被判定为不在 `generated-*` 白名单下;小时小于 10 时还可能出现 `OSS V4 签名时间格式化失败` 或服务端签名格式错误。
- 原因:旧逻辑直接把完整路径当 `object_key` 校验,未剥离当前配置 bucket同时依赖 `time::Time::to_string()` 再去掉冒号,小时小于 10 时输出不稳定补零。
- 处理:在 `platform-oss` 内统一用 `normalize_object_key_for_bucket` 先剥离当前 bucket 前缀,再做 object_key 白名单校验OSS V4 `x-oss-date` 使用固定宽度 `yyyyMMdd'T'HHmmss'Z'` 格式化。
- 验证:运行 `cd server-rs && cargo test -p platform-oss -- --nocapture`,并覆盖 `xushi-dev/generated-square-hole-assets/square-hole-session-546d881972684be2980a2a882cd0cc71/square-hole-profile-134411276ce1469cbe398f946a25d7f8/square-hole-shape-image/rabbit-option/asset-1777979289912039/image.png`
- 关联:`server-rs/crates/platform-oss/src/lib.rs``server-rs/crates/platform-oss/README.md`
## 中文乱码与编码风险

View File

@@ -34,6 +34,8 @@
1. 当前产品口径为服务器上传 AI 生成资源、Web 端只负责读取。
2. 因此 `STS` 不作为默认上传主链,`api-server` 只暴露禁用式 contract避免浏览器拿到 OSS 写权限。
3. 服务端生成资源应优先复用 `OssClient::put_object`,上传成功后再走对象确认链路写入 `asset_object`
4. 读签名和 `HEAD Object` 的入参可以兼容 `bucket/object_key` 形式;例如 `xushi-dev/generated-square-hole-assets/.../image.png` 会先剥离当前配置 bucket再参与 OSS V4 canonical request。若 bucket 与当前配置不一致,仍按普通 object_key 校验并拒绝不在 `generated-*` 前缀下的路径。
5. OSS V4 `x-oss-date` 必须固定为 `yyyyMMdd'T'HHmmss'Z'`,不能依赖 `time::Time::to_string()`;后者在小时小于 10 时可能输出非补零时间,导致签名格式错误。
## 3. 边界约束

View File

@@ -419,8 +419,11 @@ impl OssClient {
let policy = serde_json::to_string(&policy_json)
.map_err(|error| OssError::SerializePolicy(format!("序列化 policy 失败:{error}")))?;
let encoded_policy = BASE64_STANDARD.encode(policy.as_bytes());
let signature =
sign_v4_content(&self.config.access_key_secret, &signature_scope, &encoded_policy)?;
let signature = sign_v4_content(
&self.config.access_key_secret,
&signature_scope,
&encoded_policy,
)?;
Ok(OssPostObjectResponse {
signature_version: "v4",
@@ -465,7 +468,7 @@ impl OssClient {
));
}
let object_key = normalize_object_key(&request.object_key)?;
let object_key = normalize_object_key_for_bucket(&self.config.bucket, &request.object_key)?;
let expires_at = OffsetDateTime::now_utc()
.checked_add(Duration::seconds(i64::try_from(expire_seconds).map_err(
|_| OssError::InvalidRequest("expireSeconds 超出可支持范围".to_string()),
@@ -492,11 +495,8 @@ impl OssClient {
let canonical_uri = build_v4_canonical_uri(&self.config.bucket, Some(&object_key));
let object_url_path = format!("/{}", encode_url_path(&object_key));
let additional_headers = "host";
let canonical_headers = format!(
"host:{}.{}\n",
self.config.bucket(),
self.config.endpoint()
);
let canonical_headers =
format!("host:{}.{}\n", self.config.bucket(), self.config.endpoint());
let canonical_query = build_canonical_query_string(&query);
let canonical_request = build_v4_canonical_request(
Method::GET.as_str(),
@@ -506,10 +506,16 @@ impl OssClient {
additional_headers,
OSS_UNSIGNED_PAYLOAD,
);
let string_to_sign =
build_v4_string_to_sign(query["x-oss-date"].as_str(), &signature_scope, &canonical_request);
let signature =
sign_v4_content(&self.config.access_key_secret, &signature_scope, &string_to_sign)?;
let string_to_sign = build_v4_string_to_sign(
query["x-oss-date"].as_str(),
&signature_scope,
&canonical_request,
);
let signature = sign_v4_content(
&self.config.access_key_secret,
&signature_scope,
&string_to_sign,
)?;
query.insert("x-oss-signature".to_string(), signature);
let signed_url = format!(
"{}{}?{}",
@@ -535,7 +541,7 @@ impl OssClient {
client: &reqwest::Client,
request: OssHeadObjectRequest,
) -> Result<OssHeadObjectResponse, OssError> {
let object_key = normalize_object_key(&request.object_key)?;
let object_key = normalize_object_key_for_bucket(&self.config.bucket, &request.object_key)?;
let target_url = build_object_url(&self.config.bucket, &self.config.endpoint, &object_key)
.map_err(|error| OssError::Request(format!("构造 OSS 对象 URL 失败:{error}")))?;
let response = send_signed_request(
@@ -758,6 +764,17 @@ fn build_object_key(
parts.join("/")
}
fn normalize_object_key_for_bucket(bucket: &str, raw: &str) -> Result<String, OssError> {
let normalized = raw.trim().trim_start_matches('/').trim().to_string();
let bucket_prefix = format!("{}/", bucket.trim().trim_matches('/'));
let object_key = normalized
.strip_prefix(&bucket_prefix)
.unwrap_or(normalized.as_str())
.to_string();
normalize_object_key(&object_key)
}
fn normalize_object_key(raw: &str) -> Result<String, OssError> {
let normalized = raw.trim().trim_start_matches('/').trim().to_string();
if normalized.is_empty() {
@@ -1036,8 +1053,13 @@ fn signed_request_builder(
additional_headers,
&body_sha256,
);
let string_to_sign = build_v4_string_to_sign(&signed_at_text, &signature_scope, &canonical_request);
let signature = sign_v4_content(config.access_key_secret(), &signature_scope, &string_to_sign)?;
let string_to_sign =
build_v4_string_to_sign(&signed_at_text, &signature_scope, &canonical_request);
let signature = sign_v4_content(
config.access_key_secret(),
&signature_scope,
&string_to_sign,
)?;
let mut builder = client
.request(method, target_url)
.header("x-oss-content-sha256", body_sha256)
@@ -1065,33 +1087,23 @@ fn signed_request_builder(
}
fn build_v4_signature_scope(endpoint: &str, signed_at: OffsetDateTime) -> Result<String, OssError> {
let date = signed_at
.date()
.to_string()
.replace('-', "");
let date = signed_at.date().to_string().replace('-', "");
let region = extract_oss_region(endpoint)?;
Ok(format!("{date}/{region}/{OSS_V4_SERVICE}/{OSS_V4_REQUEST}"))
}
fn build_v4_signature_date(signed_at: OffsetDateTime) -> Result<String, OssError> {
let date = signed_at
.date()
.to_string()
.replace('-', "");
let time = signed_at
.time()
.to_string()
.split('.')
.next()
.unwrap_or("00:00:00")
.replace(':', "");
if time.len() != 6 {
return Err(OssError::Sign("OSS V4 签名时间格式化失败".to_string()));
}
Ok(format!("{date}T{time}Z"))
// 中文注释time::Time 的 Display 在小时小于 10 时不会稳定补零OSS V4 必须使用固定宽度 UTC 时间。
Ok(format!(
"{:04}{:02}{:02}T{:02}{:02}{:02}Z",
signed_at.year(),
u8::from(signed_at.month()),
signed_at.day(),
signed_at.hour(),
signed_at.minute(),
signed_at.second()
))
}
fn build_v4_canonical_uri(bucket: &str, object_key: Option<&str>) -> String {
@@ -1116,9 +1128,7 @@ fn extract_oss_region(endpoint: &str) -> Result<String, OssError> {
.map(str::to_string)
.filter(|region| !region.is_empty())
.ok_or_else(|| {
OssError::InvalidConfig(format!(
"OSS endpoint 无法解析 region当前值{endpoint}"
))
OssError::InvalidConfig(format!("OSS endpoint 无法解析 region当前值{endpoint}"))
})
}
@@ -1131,7 +1141,10 @@ fn sign_v4_content(
Ok(hex_sha256_hmac(&signing_key, content.as_bytes()))
}
fn build_v4_signing_key(access_key_secret: &str, signature_scope: &str) -> Result<Vec<u8>, OssError> {
fn build_v4_signing_key(
access_key_secret: &str,
signature_scope: &str,
) -> Result<Vec<u8>, OssError> {
let mut parts = signature_scope.split('/');
let date = parts
.next()
@@ -1160,8 +1173,7 @@ fn hmac_sha256_raw(key: &[u8], content: &str) -> Result<Vec<u8>, OssError> {
}
fn hex_sha256_hmac(key: &[u8], content: &[u8]) -> String {
let mut signer = HmacSha256::new_from_slice(key)
.expect("HMAC-SHA256 accepts keys of any size");
let mut signer = HmacSha256::new_from_slice(key).expect("HMAC-SHA256 accepts keys of any size");
signer.update(content);
hex_lower(&signer.finalize().into_bytes())
}
@@ -1213,7 +1225,13 @@ fn build_v4_canonical_headers(headers: &BTreeMap<String, String>) -> String {
fn build_canonical_query_string(params: &BTreeMap<String, String>) -> String {
params
.iter()
.map(|(key, value)| format!("{}={}", encode_url_query_value(key), encode_url_query_value(value)))
.map(|(key, value)| {
format!(
"{}={}",
encode_url_query_value(key),
encode_url_query_value(value)
)
})
.collect::<Vec<_>>()
.join("&")
}
@@ -1327,18 +1345,19 @@ mod tests {
response.form_fields.signature_version,
OSS_V4_ALGORITHM.to_string()
);
assert!(response
.form_fields
.credential
.starts_with("test-access-key-id/"));
assert!(response
.form_fields
.credential
.ends_with("/cn-shanghai/oss/aliyun_v4_request"));
assert_eq!(
response.form_fields.date.len(),
"20260507T120000Z".len()
assert!(
response
.form_fields
.credential
.starts_with("test-access-key-id/")
);
assert!(
response
.form_fields
.credential
.ends_with("/cn-shanghai/oss/aliyun_v4_request")
);
assert_eq!(response.form_fields.date.len(), "20260507T120000Z".len());
assert_eq!(
response.form_fields.metadata.get("x-oss-meta-asset-kind"),
Some(&"character-visual".to_string())
@@ -1441,13 +1460,48 @@ mod tests {
.signed_url
.contains("x-oss-signature-version=OSS4-HMAC-SHA256")
);
assert!(response
.signed_url
.contains("x-oss-credential=test-access-key-id%2F"));
assert!(
response
.signed_url
.contains("x-oss-credential=test-access-key-id%2F")
);
assert!(response.signed_url.contains("&x-oss-expires=300"));
assert!(response.signed_url.contains("&x-oss-signature="));
}
#[test]
fn sign_get_object_url_accepts_bucket_prefixed_square_hole_key() {
let client = OssClient::new(
OssConfig::new(
"xushi-dev".to_string(),
"oss-cn-shanghai.aliyuncs.com".to_string(),
"test-access-key-id".to_string(),
"test-access-key-secret".to_string(),
DEFAULT_READ_EXPIRE_SECONDS,
DEFAULT_POST_EXPIRE_SECONDS,
DEFAULT_POST_MAX_SIZE_BYTES,
DEFAULT_SUCCESS_ACTION_STATUS,
)
.expect("OSS config should be valid"),
);
let response = client
.sign_get_object_url(OssSignedGetObjectUrlRequest {
object_key: "xushi-dev/generated-square-hole-assets/square-hole-session-546d881972684be2980a2a882cd0cc71/square-hole-profile-134411276ce1469cbe398f946a25d7f8/square-hole-shape-image/rabbit-option/asset-1777979289912039/image.png".to_string(),
expire_seconds: Some(300),
})
.expect("bucket-prefixed square hole key should build signed url");
assert_eq!(response.bucket, "xushi-dev".to_string());
assert_eq!(
response.object_key,
"generated-square-hole-assets/square-hole-session-546d881972684be2980a2a882cd0cc71/square-hole-profile-134411276ce1469cbe398f946a25d7f8/square-hole-shape-image/rabbit-option/asset-1777979289912039/image.png".to_string()
);
assert!(response
.signed_url
.starts_with("https://xushi-dev.oss-cn-shanghai.aliyuncs.com/generated-square-hole-assets/square-hole-session-546d881972684be2980a2a882cd0cc71/square-hole-profile-134411276ce1469cbe398f946a25d7f8/square-hole-shape-image/rabbit-option/asset-1777979289912039/image.png?"));
}
#[test]
fn sign_get_object_url_rejects_unsupported_prefix() {
let client = build_client();