fix: validate S3 browser download CORS
This commit is contained in:
11
docker/storage/cors.json
Normal file
11
docker/storage/cors.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -25,7 +25,7 @@ server {
|
||||
access_log off;
|
||||
|
||||
location / {
|
||||
limit_except GET HEAD {
|
||||
limit_except GET HEAD OPTIONS {
|
||||
deny all;
|
||||
}
|
||||
|
||||
|
||||
@@ -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` 和对象键。
|
||||
- 切换到第二端点后,新对象进入第二端点,第一端点历史对象仍可下载。
|
||||
|
||||
@@ -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"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user