fix(cors): 同源请求跳过 CORS 校验,避免浏览器自家人拦自家人
修 bug: 用户从浏览器访问 /setup 提交表单时,浏览器对同源 POST
也会设 Origin 头(防 fetch 滥用)。原 CORS 中间件只看 Origin 是否
在 ALLOWED_ORIGINS 白名单,未配白名单时同源 POST 会被 403 拒,
表现为"安装失败: status code 403"。
此前用 curl 测试时没 Origin 头(CORS 跳过),所以本地测不出来。
浏览器必带。
修法: 在 CORS 校验前加同源豁免 — 比较 Origin 与 r.Host(含
X-Forwarded-Host / X-Forwarded-Proto 反向代理场景),一致就放行,
不进入 CORS 检查、不设 Vary 头。
测试 (被 .gitignore 排除, 本地保留):
- middleware/cors_test.go: 新增
- TestIsSameOrigin: 10 场景
- 空 origin / 同 host+port / 同 host https / 不同 port / 不同 host
- 大小写不敏感 / XFH 代理 / XFH 不匹配 / origin 带 path / 无 scheme
- TestSplitOrigin: 5 场景
- https://example.com:8080 / http://localhost / 带 path
- not-a-url / ://broken
e2e 验证 (本机跑通):
- install 无 Origin (curl): 200
- install 同源 Origin: 200 ✓ 修复
- install 跨域 Origin: 403 (仍正确拒绝)
- install 不同端口 Origin: 403 (仍正确拒绝)
未 push (等用户确认)
This commit is contained in:
@@ -40,6 +40,62 @@ func matchOrigin(origin string) (string, bool) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
// isSameOrigin 比较 Origin 与 r.Host,判断是否同源。
|
||||
// - 直接访问(server 自己:80): Origin=http://server:80, Host=server:80 → 同源
|
||||
// - 反向代理(https://app.example.com → http://server:80):
|
||||
// Origin=https://app.example.com, Host=server:80
|
||||
// 默认不同源;但如果设置 TRUSTED_PROXY_HOPS 或 X-Forwarded-Host,要让它们一致。
|
||||
// - 浏览器对同源 POST 也会设 Origin(避免被自己的 CORS 误伤),这里豁免。
|
||||
// 返回 true 表示请求来自自己,无需 CORS 介入。
|
||||
func isSameOrigin(r *http.Request) bool {
|
||||
origin := r.Header.Get("Origin")
|
||||
if origin == "" {
|
||||
return false
|
||||
}
|
||||
// 解析 Origin 的 host 部分
|
||||
originHost, originScheme := splitOrigin(origin)
|
||||
if originHost == "" {
|
||||
return false
|
||||
}
|
||||
// 优先用 X-Forwarded-Host / X-Forwarded-Proto(反向代理场景),
|
||||
// 退而用 r.Host(直接访问场景)
|
||||
reqHost := r.Host
|
||||
if fh := r.Header.Get("X-Forwarded-Host"); fh != "" {
|
||||
// X-Forwarded-Host 可能是 host1, host2 (取第一个)
|
||||
if i := strings.Index(fh, ","); i >= 0 {
|
||||
fh = strings.TrimSpace(fh[:i])
|
||||
}
|
||||
reqHost = fh
|
||||
}
|
||||
reqScheme := "http"
|
||||
if r.TLS != nil {
|
||||
reqScheme = "https"
|
||||
} else if fp := r.Header.Get("X-Forwarded-Proto"); fp != "" {
|
||||
if i := strings.Index(fp, ","); i >= 0 {
|
||||
fp = strings.TrimSpace(fp[:i])
|
||||
}
|
||||
reqScheme = strings.ToLower(fp)
|
||||
}
|
||||
// host 匹配(忽略大小写)
|
||||
return strings.EqualFold(originHost, reqHost) && strings.EqualFold(originScheme, reqScheme)
|
||||
}
|
||||
|
||||
// splitOrigin 把 "https://example.com:8080" 拆成 ("example.com:8080", "https")。
|
||||
// 没有 scheme 时返回 ("", "")。
|
||||
func splitOrigin(origin string) (host, scheme string) {
|
||||
idx := strings.Index(origin, "://")
|
||||
if idx < 0 || idx == 0 {
|
||||
return "", ""
|
||||
}
|
||||
scheme = origin[:idx]
|
||||
rest := origin[idx+3:]
|
||||
// 去掉 path 部分
|
||||
if i := strings.Index(rest, "/"); i >= 0 {
|
||||
rest = rest[:i]
|
||||
}
|
||||
return rest, scheme
|
||||
}
|
||||
|
||||
func CORS(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
origin := r.Header.Get("Origin")
|
||||
@@ -50,6 +106,14 @@ func CORS(next http.Handler) http.Handler {
|
||||
return
|
||||
}
|
||||
|
||||
// 同源豁免:浏览器对同源 POST/JSON 也会发 Origin 头(防 fetch 滥用),
|
||||
// 但同源请求本就不需要 CORS 介入。这里对比 Origin 与 Host(含 X-Forwarded-*),
|
||||
// 一致就放行,避免自家人被自家 CORS 拦。
|
||||
if isSameOrigin(r) {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// 有 Origin 头时,响应必须携带 Vary: Origin 防止 CDN 缓存污染
|
||||
vary := w.Header().Get("Vary")
|
||||
if vary == "" {
|
||||
|
||||
Reference in New Issue
Block a user