From 1bed8d8ed01740d7438db25876c4669c46ef6fe2 Mon Sep 17 00:00:00 2001 From: "3371392206@qq.com" <3371392206@qq.com> Date: Sun, 30 Aug 2026 00:00:21 +0800 Subject: [PATCH] =?UTF-8?q?fix(cors):=20=E5=90=8C=E6=BA=90=E8=AF=B7?= =?UTF-8?q?=E6=B1=82=E8=B7=B3=E8=BF=87=20CORS=20=E6=A0=A1=E9=AA=8C,?= =?UTF-8?q?=E9=81=BF=E5=85=8D=E6=B5=8F=E8=A7=88=E5=99=A8=E8=87=AA=E5=AE=B6?= =?UTF-8?q?=E4=BA=BA=E6=8B=A6=E8=87=AA=E5=AE=B6=E4=BA=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修 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 (等用户确认) --- middleware/cors.go | 64 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/middleware/cors.go b/middleware/cors.go index ce859d8..c453c51 100644 --- a/middleware/cors.go +++ b/middleware/cors.go @@ -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 == "" {