周日下午准备往自己的文件服务传个东西,拖进去,进度条刚跳出来就红了。打开 DevTools 一看,满屏红字:
Access to XMLHttpRequest at 'https://xxx.r2.cloudflarestorage.com/...'from origin 'https://file.lishuyu.app' has been blocked by CORS policy:Response to preflight request doesn't pass access control check:No 'Access-Control-Allow-Origin' header is present on the requested resource.CORS。老朋友了。
架构背景
先交代一下这套文件服务的结构。三个独立管理的部分:
- 前端
files-web:一个 SPA,部署在file.lishuyu.app,通过 Caddy 反代 - 后端
oss:FastAPI 写的对象存储服务,挂在api.lishuyu.app/oss,负责元数据管理和生成 presigned URL - 存储 Cloudflare R2:实际存文件的地方,通过 S3 兼容 API 对接
上传流程是这样的:
浏览器 → POST /oss/{bucket}/multipart/init → 后端创建 multipart upload,返回 upload_id浏览器 → GET /oss/{bucket}/multipart/{id}/part/{n} → 后端返回 presigned URL浏览器 → PUT presigned URL → 直接传到 R2(不经过后端)浏览器 → POST /oss/{bucket}/multipart/{id}/complete → 后端合并分片关键在第三步:浏览器拿着 presigned URL 直接往 R2 发 PUT 请求。这时候 R2 会检查请求的 Origin 头是否在 bucket 的 CORS 白名单里。presigned URL 只解决了鉴权问题,CORS 是另一回事——它是 R2 bucket 层面的策略,和签名完全无关。
排查
看错误信息,Origin: https://file.lishuyu.app,R2 那边没返回 Access-Control-Allow-Origin。
那 CORS 白名单里到底写了什么?翻后端代码:
_DEFAULT_BROWSER_CORS_ORIGINS = ( "https://files.lishuyu.app", # ← 复数 "https://readme.lishuyu.app", "https://readme.lishuyu.top",)files.lishuyu.app。复数。带 s。
但前端现在跑在 file.lishuyu.app。单数。不带 s。
一个字母的差别,CORS 不认。
为什么域名会变
这要往前倒几周。最初文件服务前端部署在 files.lishuyu.app。后来我觉得 file 比 files 更简洁,就把域名改了。改域名的时候,Cloudflare 这边自动处理了 DNS CNAME,旧域名 files.lishuyu.app 设了 301 重定向到 file.lishuyu.app。
从用户视角看,一切正常:访问旧域名会自动跳转到新域名,页面加载没问题。
但 CORS 不走 301 重定向。浏览器发 preflight OPTIONS 请求时,用的是当前页面的 origin,也就是 https://file.lishuyu.app。R2 bucket 的 CORS 白名单里只有 https://files.lishuyu.app。origin 不匹配,直接拒绝。
301 重定向解决了”人能不能看到页面”的问题,但没有解决”页面能不能跨域请求 R2”的问题。
为什么没发现
因为改域名那天,我只测了”页面能不能正常打开”。能打开,完事。
文件列表能看到——那走的是后端 API(api.lishuyu.app/oss),不涉及 CORS。文件下载能下——如果是 public 文件,后端返回 302 重定向到 R2 presigned URL,浏览器跟着跳转就行,也不涉及 CORS。
唯独上传,浏览器要直接往 R2 发 PUT 请求,才会触发 CORS 检查。而我改域名那天没测上传。
更深层的原因是:这三个组件(前端域名、后端 CORS 配置、R2 bucket 策略)分散在不同的代码和配置里。改了域名,脑子里想的是”DNS 和重定向搞定了”,根本没联想到”还有一个 Python 文件里硬编码了旧域名”。
修复
两行改动。
第一,修正拼写:
_DEFAULT_BROWSER_CORS_ORIGINS = ( "https://file.lishuyu.app", # files → file "https://readme.lishuyu.app", "https://readme.lishuyu.top",)第二,把 CORS 配置从”按需触发”改成”启动时就应用”。之前 ensure_bucket(里面包含 CORS 配置)只在创建 bucket 或发起 multipart upload 时才被调用。如果只是改了代码重新部署,CORS 规则不会立即更新,要等下一次上传才生效。把它移到 FastAPI 的 lifespan 里:
@asynccontextmanagerasync def lifespan(app: FastAPI): # ... db init, r2 client init ... from oss.r2 import ensure_bucket ensure_bucket(app.state.r2, _R2_BUCKET) # 启动时就应用 CORS logger.info("oss started, db=%s", resolved) yield conn.close()ensure_bucket 里的 _ensure_browser_cors 会调用 S3 的 PutBucketCors API,把 CORS 规则写到 R2 bucket 上。这个操作是幂等的,每次启动都跑一遍没有副作用。
推到 main 分支,deployer 通过 GitHub webhook 自动拉代码重新部署,服务重启后 CORS 立即生效。
教训
这个 bug 的本质不是 CORS 配置写错了,而是配置分散在多个独立管理的地方,改了一处忘了另一处。
域名在 Cloudflare DNS 里配。前端部署配置在 service.yaml 里。CORS 白名单硬编码在后端 Python 代码里。R2 bucket 的实际 CORS 规则通过 S3 API 动态写入。四个地方,四个”真相来源”,没有任何机制保证它们一致。
这不是什么罕见的架构问题。微服务、前后端分离、云存储——现代 web 应用基本都是这种结构。每一层都有自己的配置,每一层都可能因为别的层改了什么而悄悄 break。
几个可以做的事情:
测试覆盖完整路径。 改域名不只是”页面能打开”。要测到实际的业务功能——上传、下载、分享链接。特别是涉及跨域请求的场景。
CORS origin 不要硬编码。 这次的修复虽然还是硬编码,但也支持了环境变量覆盖(OSS_BROWSER_CORS_ORIGINS)。更好的做法是从统一配置源读取,或者至少在前端部署配置里声明 origin,后端从同一个 manifest 读。
301 重定向不等于完全迁移。 重定向让人类用户无感,但机器请求(CORS preflight、webhook callback、OAuth redirect URI)不一定跟着走。每次改域名都该列个 checklist:DNS、SSL、重定向、CORS、OAuth callback、webhook URL、硬编码引用。
一个字母的差别,藏了两周才暴露。下次改域名,记得 grep 一下旧域名在代码库里还出现在哪。