From 091a0cee750b494c921dc3f6c56e93675a6080e3 Mon Sep 17 00:00:00 2001 From: 237899745 <237899745@users.noreply.git.workyai.cn> Date: Sat, 25 Jul 2026 16:53:15 +0800 Subject: [PATCH] fix: validate S3 browser download CORS --- docker/storage/cors.json | 11 +++++ docker/storage/nginx-files.conf.example | 2 +- docs/s3-storage-plan.md | 7 +++ src/services/storage.rs | 61 +++++++++++++++++++++++++ 4 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 docker/storage/cors.json diff --git a/docker/storage/cors.json b/docker/storage/cors.json new file mode 100644 index 0000000..78c6fe9 --- /dev/null +++ b/docker/storage/cors.json @@ -0,0 +1,11 @@ +{ + "CORSRules": [ + { + "AllowedOrigins": ["https://tp.workyai.cn"], + "AllowedMethods": ["GET", "HEAD"], + "AllowedHeaders": ["*"], + "ExposeHeaders": ["Content-Disposition", "Content-Length", "Content-Type", "ETag"], + "MaxAgeSeconds": 3600 + } + ] +} diff --git a/docker/storage/nginx-files.conf.example b/docker/storage/nginx-files.conf.example index 73588f5..29edb39 100644 --- a/docker/storage/nginx-files.conf.example +++ b/docker/storage/nginx-files.conf.example @@ -25,7 +25,7 @@ server { access_log off; location / { - limit_except GET HEAD { + limit_except GET HEAD OPTIONS { deny all; } diff --git a/docs/s3-storage-plan.md b/docs/s3-storage-plan.md index 55ff942..ecafcb8 100644 --- a/docs/s3-storage-plan.md +++ b/docs/s3-storage-plan.md @@ -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` 和对象键。 - 切换到第二端点后,新对象进入第二端点,第一端点历史对象仍可下载。 diff --git a/src/services/storage.rs b/src/services/storage.rs index 7fa913a..f6338ee 100644 --- a/src/services/storage.rs +++ b/src/services/storage.rs @@ -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 { + 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" + )); + } }