fix: validate S3 browser download CORS

This commit is contained in:
237899745
2026-07-25 16:53:15 +08:00
parent 9716fc2115
commit 091a0cee75
4 changed files with 80 additions and 1 deletions

11
docker/storage/cors.json Normal file
View File

@@ -0,0 +1,11 @@
{
"CORSRules": [
{
"AllowedOrigins": ["https://tp.workyai.cn"],
"AllowedMethods": ["GET", "HEAD"],
"AllowedHeaders": ["*"],
"ExposeHeaders": ["Content-Disposition", "Content-Length", "Content-Type", "ETag"],
"MaxAgeSeconds": 3600
}
]
}

View File

@@ -25,7 +25,7 @@ server {
access_log off;
location / {
limit_except GET HEAD {
limit_except GET HEAD OPTIONS {
deny all;
}

View File

@@ -96,6 +96,8 @@ Worker 每 5 分钟按 `expires_at` 精确删除对象,删除成功后才删
Access Key 和 Secret Key 使用项目现有 AES-256-GCM 机制加密入库,页面只显示掩码。保存后先执行“全链路测试”,再点“验证并启用”。后端激活前会再次执行 `HeadBucket + 内部 PutObject/GetObject + 公网预签名 GET + 内部 DeleteObject`。编辑现有端点时先对候选配置执行同等测试,并抽查历史对象是否仍可访问;测试通过后才保存,活动状态不变。删除采用软删除,立即停止新写入并隐藏端点,但保留历史对象所需的加密配置;历史关联清空 30 天后再彻底移除。若删除活动端点,新文件自动回退应用服务器本地,直到启用其他 S3。
浏览器通过公网预签名地址直接读取对象,因此 Bucket 必须允许应用站点跨域 GET/HEAD。将 `docker/storage/cors.json` 中的站点域名改成实际应用域名后,通过兼容 S3 的管理工具执行 `PutBucketCors`。后台全链路测试会携带 `PUBLIC_BASE_URL` 的 Origin并拒绝缺失或不匹配的 `Access-Control-Allow-Origin`,避免对象存在但浏览器无法下载。
## 7. 后续部署顺序(本次不执行)
1. 为下载域名添加 DNS确认 119 的 80/443 可用,并建立 WireGuard。
@@ -118,12 +120,17 @@ aws --endpoint-url http://127.0.0.1:3900 \
s3api put-bucket-lifecycle-configuration \
--bucket imageforge-results \
--lifecycle-configuration file://lifecycle.json
aws --endpoint-url http://127.0.0.1:3900 \
s3api put-bucket-cors \
--bucket imageforge-results \
--cors-configuration file://cors.json
```
## 8. 上线验收
- 管理端测试必须完成读、写、删Bucket 内不能残留健康检查对象。
- 普通下载接口先返回 `307``Location` 指向下载域名且有效期约 300 秒。
- 携带应用站点 `Origin` 请求预签名地址时返回匹配的 `Access-Control-Allow-Origin`,浏览器可读取完整对象。
- 未授权用户无法取得签名 URL任务过期后应用下载接口返回 404。
- 单文件与批任务数据库均记录正确的 `storage_endpoint_id` 和对象键。
- 切换到第二端点后,新对象进入第二端点,第一端点历史对象仍可下载。

View File

@@ -16,6 +16,7 @@ use sqlx::FromRow;
use std::path::{Path, PathBuf};
use std::time::Duration;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use url::Url;
use uuid::Uuid;
const MULTIPART_THRESHOLD: u64 = 64 * 1024 * 1024;
@@ -480,11 +481,13 @@ pub async fn test_endpoint(state: &AppState, endpoint: &StorageEndpoint) -> Resu
)
.await
.map_err(|err| storage_error("生成公网下载测试地址失败", err))?;
let browser_origin = browser_origin(&state.config.public_base_url)?;
let response = reqwest::Client::builder()
.timeout(Duration::from_secs(15))
.build()
.map_err(|err| storage_error("创建公网下载测试客户端失败", err))?
.get(signed.uri().to_string())
.header(reqwest::header::ORIGIN, &browser_origin)
.header(reqwest::header::USER_AGENT, "ImageForge-Storage-Check/1.0")
.send()
.await
@@ -496,6 +499,20 @@ pub async fn test_endpoint(state: &AppState, endpoint: &StorageEndpoint) -> Resu
format!("公网 Endpoint 下载测试返回 HTTP {status}"),
));
}
let allowed_origin = response
.headers()
.get(reqwest::header::ACCESS_CONTROL_ALLOW_ORIGIN)
.and_then(|value| value.to_str().ok());
if !cors_allows_origin(allowed_origin, &browser_origin) {
return Err(AppError::new(
ErrorCode::StorageUnavailable,
"公网 Endpoint 未允许站点跨域下载",
)
.with_source(format!(
"origin={browser_origin}, allow_origin={}",
allowed_origin.unwrap_or("missing")
)));
}
let public_bytes = response.bytes().await.map_err(|err| {
AppError::new(ErrorCode::StorageUnavailable, "读取公网下载测试响应失败")
.with_source(format!("response body error: {}", err.is_timeout()))
@@ -735,6 +752,24 @@ fn public_test_request_error(endpoint: &StorageEndpoint, err: reqwest::Error) ->
))
}
fn browser_origin(public_base_url: &str) -> Result<String, AppError> {
let parsed = Url::parse(public_base_url).map_err(|err| {
AppError::new(ErrorCode::Internal, "站点公网地址配置错误").with_source(err)
})?;
let origin = parsed.origin().ascii_serialization();
if origin == "null" {
return Err(AppError::new(
ErrorCode::Internal,
"站点公网地址不能用于跨域测试",
));
}
Ok(origin)
}
fn cors_allows_origin(allowed_origin: Option<&str>, expected_origin: &str) -> bool {
allowed_origin.is_some_and(|value| value == "*" || value == expected_origin)
}
#[derive(Clone, Copy)]
enum EndpointKind {
Internal,
@@ -795,4 +830,30 @@ mod tests {
assert!(uri.contains("X-Amz-Expires=300"));
assert!(uri.contains("X-Amz-Signature="));
}
#[test]
fn browser_origin_ignores_path_and_default_port() {
assert_eq!(
browser_origin("https://tp.workyai.cn/dashboard").unwrap(),
"https://tp.workyai.cn"
);
assert_eq!(
browser_origin("http://localhost:5173/anything").unwrap(),
"http://localhost:5173"
);
}
#[test]
fn cors_requires_the_configured_site_origin() {
assert!(cors_allows_origin(
Some("https://tp.workyai.cn"),
"https://tp.workyai.cn"
));
assert!(cors_allows_origin(Some("*"), "https://tp.workyai.cn"));
assert!(!cors_allows_origin(None, "https://tp.workyai.cn"));
assert!(!cors_allows_origin(
Some("https://other.example.com"),
"https://tp.workyai.cn"
));
}
}