Nginx 使用原始 Host 和端口转发,不修改后端跨站防护。补充无 Fetch Metadata 的 LAN/Tailscale 同源和异源、异端口、转发头伪造回归。 验证:配置回归先因丢失端口失败后通过;HTTP竞态、真实隔离Nginx正反例及完整make verify均通过。部署前真实入口无Metadata为403,同源Metadata对照可过;源库及人工复核无写入。
This commit is contained in:
@@ -33,6 +33,8 @@
|
|||||||
|
|
||||||
### Fixed
|
### 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`,并为这两个新码补了中文说明。
|
- **登记钉钉知识库失败(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`,并为这两个新码补了中文说明。
|
||||||
|
|
||||||
- 前端入口与静态资源的缓存语义("新功能看起来没上线"与"页面偶尔裸奔/空白"的根因):
|
- 前端入口与静态资源的缓存语义("新功能看起来没上线"与"页面偶尔裸奔/空白"的根因):
|
||||||
|
|||||||
+2
-2
@@ -14,7 +14,7 @@ server {
|
|||||||
set $api_upstream http://api:8080;
|
set $api_upstream http://api:8080;
|
||||||
proxy_pass $api_upstream;
|
proxy_pass $api_upstream;
|
||||||
proxy_http_version 1.1;
|
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-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
}
|
}
|
||||||
@@ -22,7 +22,7 @@ server {
|
|||||||
location = /health {
|
location = /health {
|
||||||
set $health_upstream http://api:8080;
|
set $health_upstream http://api:8080;
|
||||||
proxy_pass $health_upstream/health;
|
proxy_pass $health_upstream/health;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $http_host;
|
||||||
}
|
}
|
||||||
|
|
||||||
# 带内容哈希的构建产物:可以长期缓存(换版本必然换文件名);旧哈希文件已被新构建替换
|
# 带内容哈希的构建产物:可以长期缓存(换版本必然换文件名);旧哈希文件已被新构建替换
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user