From 2ea2111420e501fec37e0ea0c4bcd3cd8c866403 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A7=9A=E7=AB=A0=E6=B5=A9?= Date: Sun, 18 Oct 2026 15:45:09 +0800 Subject: [PATCH] =?UTF-8?q?fix(web):=20=E4=BF=9D=E7=95=99=E4=BB=A3?= =?UTF-8?q?=E7=90=86=E7=AB=AF=E5=8F=A3=E4=BB=A5=E4=BF=AE=E5=A4=8D=E5=90=8C?= =?UTF-8?q?=E6=BA=90=E8=B4=A8=E9=87=8F=E6=9F=A5=E8=AF=A2=E8=AF=AF=E6=8B=92?= =?UTF-8?q?=E7=BB=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nginx 使用原始 Host 和端口转发,不修改后端跨站防护。补充无 Fetch Metadata 的 LAN/Tailscale 同源和异源、异端口、转发头伪造回归。 验证:配置回归先因丢失端口失败后通过;HTTP竞态、真实隔离Nginx正反例及完整make verify均通过。部署前真实入口无Metadata为403,同源Metadata对照可过;源库及人工复核无写入。 --- CHANGELOG.md | 2 + deploy/nginx.conf | 4 +- internal/httpapi/qa_proxy_authority_test.go | 62 +++++++++++++++++++++ 3 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 internal/httpapi/qa_proxy_authority_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 3539a56..efafebb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,8 @@ ### Fixed +- 修复通过带端口的 HTTP 地址访问问答运营时,正常同源操作被误报“拒绝跨站配置请求”:网页代理保留原始 Host 及端口,使缺少 Fetch Metadata 的浏览器也能正确执行 Origin 校验。保留后端跨站、异端口、伪造转发头与缺失操作意图的拒绝保护。 + - **登记钉钉知识库失败(HTTP 409 `space_registration_conflict`)的真实原因与修复**:`source_connections.name` 有唯一约束,而早期路径登记的钉钉来源(`base_url` 为空、id 与 dws 现在推导的确定性 id 不同)被**归档后仍占着名字槽位** ⇒ 重新登记时 INSERT 撞唯一约束;处理器又把**所有**存储层错误都翻译成同一个 409,真实原因被吞掉(线上只能看到一个无细节的错误码)。现在:① 同名来源**已归档**时,把它改名腾位(`…(已下架 · <短ID>)`,归档行不显示、数据与审计一行不动);② 同名来源**仍在使用**时返回 `space_name_conflict` + 可行动提示(先「去掉来源」);③ 同一登记键指向别的空间且该行仍在用时返回 `space_identity_conflict`(已归档的行则允许就地改写 —— 这就是"去掉后再用 dws 重新获取");④ 其余内部故障改回 **500** `space_registration_failed`,不再伪装成冲突;前端优先显示后端 `hint`,并为这两个新码补了中文说明。 - 前端入口与静态资源的缓存语义("新功能看起来没上线"与"页面偶尔裸奔/空白"的根因): diff --git a/deploy/nginx.conf b/deploy/nginx.conf index d7697b3..ef165e1 100644 --- a/deploy/nginx.conf +++ b/deploy/nginx.conf @@ -14,7 +14,7 @@ server { set $api_upstream http://api:8080; proxy_pass $api_upstream; proxy_http_version 1.1; - proxy_set_header Host $host; + proxy_set_header Host $http_host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } @@ -22,7 +22,7 @@ server { location = /health { set $health_upstream http://api:8080; proxy_pass $health_upstream/health; - proxy_set_header Host $host; + proxy_set_header Host $http_host; } # 带内容哈希的构建产物:可以长期缓存(换版本必然换文件名);旧哈希文件已被新构建替换 diff --git a/internal/httpapi/qa_proxy_authority_test.go b/internal/httpapi/qa_proxy_authority_test.go new file mode 100644 index 0000000..332bb1a --- /dev/null +++ b/internal/httpapi/qa_proxy_authority_test.go @@ -0,0 +1,62 @@ +package httpapi + +import ( + "net/http/httptest" + "os" + "regexp" + "testing" +) + +// HTTP LAN/tailnet origins may omit Fetch Metadata. nginx must preserve the +// external authority including its port so Origin can be checked without it. +func TestQAProxyPreservesBrowserAuthority(t *testing.T) { + config, err := os.ReadFile("../../deploy/nginx.conf") + if err != nil { + t.Fatal(err) + } + hosts := regexp.MustCompile(`(?m)^\s*proxy_set_header\s+Host\s+([^;]+);`).FindAllStringSubmatch(string(config), -1) + if len(hosts) < 2 { + t.Fatal("API and health proxy Host directives must both be explicit") + } + for _, host := range hosts { + if host[1] != "$http_host" { + t.Fatalf("proxy drops browser authority/port: got %q, want $http_host", host[1]) + } + } +} + +func TestQAIntentWithoutFetchMetadataChecksExactAuthority(t *testing.T) { + for _, tc := range []struct { + name, host, origin, forwarded, site, intent string + allowed bool + }{ + {"LAN same authority", "192.168.50.134:8088", "http://192.168.50.134:8088", "", "", "qa-quality", true}, + {"tailnet same authority", "100.92.49.78:8088", "http://100.92.49.78:8088", "", "", "qa-quality", true}, + {"proxy stripped port", "192.168.50.134", "http://192.168.50.134:8088", "", "", "qa-quality", false}, + {"foreign origin", "192.168.50.134:8088", "http://foreign.invalid:8088", "", "", "qa-quality", false}, + {"different port", "192.168.50.134:8088", "http://192.168.50.134:8089", "", "", "qa-quality", false}, + {"forwarded header cannot grant", "192.168.50.134:8088", "http://foreign.invalid:8088", "foreign.invalid:8088", "", "qa-quality", false}, + {"opaque origin", "192.168.50.134:8088", "null", "", "", "qa-quality", false}, + {"explicit cross site", "192.168.50.134:8088", "http://192.168.50.134:8088", "", "cross-site", "qa-quality", false}, + {"missing intent", "192.168.50.134:8088", "http://192.168.50.134:8088", "", "", "", false}, + } { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest("POST", "http://"+tc.host+"/api/v1/qa-ops/quality/query", nil) + req.Header.Set("Origin", tc.origin) + req.Header.Set("X-Governance-Action", tc.intent) + if tc.forwarded != "" { + req.Header.Set("X-Forwarded-Host", tc.forwarded) + } + if tc.site != "" { + req.Header.Set("Sec-Fetch-Site", tc.site) + } + rec := httptest.NewRecorder() + if got := authorizeQAIntent(rec, req, "qa-quality"); got != tc.allowed { + t.Fatalf("authorized=%v want %v, HTTP=%d", got, tc.allowed, rec.Code) + } + if !tc.allowed && rec.Code != 403 { + t.Fatalf("rejected authority should be 403, got %d", rec.Code) + } + }) + } +}