真实验证(干净环境练兵)抓到:POST /admin/api/gateway/routes 会把整个
GatewayConfig 反序列化为零值后保存,因此
- 只提交单个路由对象 → {"routes":0,"status":"saved"}:**静默清空整张路由表**
- 提交 {"routes":[…] } → 其余字段被重置为默认(实测入口端口 13080 → 8081,
TLS/认证地址一并丢失),随后 apply 生成的配置与预期完全不符
对 agent 尤其危险:照文档提交一条路由就会改掉入口端口与所有其它路由。
修复:
- 只补路由表:以现有配置为基准,仅替换 Routes,其它字段原样保留
- 请求体必须是 {"routes":[...]};缺该键 → 400 并说明正确形状(不再静默变空表)
- 非空表要清空必须显式传 force:true,否则 400 拒绝
- 测试:单个对象被拒;数组提交后 listenPort/TLS/认证地址/证书路径全部保留;
清空被拒、force 允许
- README:修正路由示例为完整数组形状,补"替换整张表"与 /admin/api 前缀说明
341 lines
12 KiB
Go
341 lines
12 KiB
Go
package main
|
||
|
||
import (
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
func TestBeianFooter(t *testing.T) {
|
||
cases := []struct {
|
||
name, icp, gongan string
|
||
wantParts []string
|
||
wantEmpty bool
|
||
}{
|
||
{name: "both", icp: "京ICP备00000000号-1", gongan: "京公网安备00000000000000号",
|
||
wantParts: []string{"class=\"navi-footer\"", "beian.miit.gov.cn", "京ICP备00000000号-1", "navi-footer-sep", "beian.mps.gov.cn", "京公网安备00000000000000号"}},
|
||
{name: "only icp", icp: " 京ICP备12345678号 ", wantParts: []string{"class=\"navi-footer\"", "京ICP备12345678号"}},
|
||
{name: "only gongan", gongan: "沪公网安备31010102002555号", wantParts: []string{"class=\"navi-footer\"", "沪公网安备31010102002555号"}},
|
||
{name: "blank", icp: " ", gongan: "", wantEmpty: true},
|
||
}
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
got := beianFooter(tc.icp, tc.gongan)
|
||
if tc.wantEmpty {
|
||
if got != "" {
|
||
t.Fatalf("want empty footer, got %q", got)
|
||
}
|
||
return
|
||
}
|
||
for _, p := range tc.wantParts {
|
||
if !strings.Contains(got, p) {
|
||
t.Fatalf("footer %q missing %q", got, p)
|
||
}
|
||
}
|
||
if strings.Contains(got, " ") {
|
||
t.Fatalf("footer keeps stray whitespace: %q", got)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// 备案号里若混入引号/尖括号,必须被转义,不能原样拼进 HTML。
|
||
func TestBeianFooterEscapesInput(t *testing.T) {
|
||
got := beianFooter(`"><script>alert(1)</script>`, "")
|
||
if strings.Contains(got, "<script>") {
|
||
t.Fatalf("unescaped input leaked into footer: %q", got)
|
||
}
|
||
if !strings.Contains(got, "<script>") && !strings.Contains(got, """) {
|
||
t.Fatalf("expected escaped output, got %q", got)
|
||
}
|
||
}
|
||
|
||
func TestMapString(t *testing.T) {
|
||
m := map[string]any{"a": "x", "b": 1, "c": nil}
|
||
if got := mapString(m, "a"); got != "x" {
|
||
t.Fatalf("mapString(a) = %q; want x", got)
|
||
}
|
||
if got := mapString(m, "b"); got != "" {
|
||
t.Fatalf("mapString(b) = %q; want empty for non-string", got)
|
||
}
|
||
if got := mapString(m, "missing"); got != "" {
|
||
t.Fatalf("mapString(missing) = %q; want empty", got)
|
||
}
|
||
}
|
||
|
||
func TestFrameBlockReason(t *testing.T) {
|
||
cases := []struct {
|
||
name string
|
||
xfo string
|
||
csp string
|
||
want string
|
||
}{
|
||
{"无任何头 → 可内嵌", "", "", ""},
|
||
{"XFO SAMEORIGIN 拒绝", "SAMEORIGIN", "", "X-Frame-Options: SAMEORIGIN"},
|
||
{"XFO DENY 拒绝", "DENY", "", "X-Frame-Options: DENY"},
|
||
{"CSP frame-ancestors 'self' 拒绝", "", "default-src 'self'; frame-ancestors 'self'", "frame-ancestors 'self'"},
|
||
{"CSP frame-ancestors * 放行", "", "frame-ancestors *", ""},
|
||
{"CSP 其它指令不影响", "", "default-src 'self'; img-src https:", ""},
|
||
}
|
||
for _, c := range cases {
|
||
h := http.Header{}
|
||
if c.xfo != "" {
|
||
h.Set("X-Frame-Options", c.xfo)
|
||
}
|
||
if c.csp != "" {
|
||
h.Set("Content-Security-Policy", c.csp)
|
||
}
|
||
if got := frameBlockReason(h); got != c.want {
|
||
t.Errorf("%s: frameBlockReason() = %q; want %q", c.name, got, c.want)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestFrameableVerdict(t *testing.T) {
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
switch r.URL.Path {
|
||
case "/ok":
|
||
w.WriteHeader(http.StatusOK)
|
||
case "/xfo":
|
||
w.Header().Set("X-Frame-Options", "SAMEORIGIN")
|
||
w.WriteHeader(http.StatusOK)
|
||
case "/waf":
|
||
// WAF 拦截:403 且不带任何 X-Frame-Options —— 这是之前误判的根源
|
||
w.WriteHeader(http.StatusForbidden)
|
||
case "/csp":
|
||
w.Header().Set("Content-Security-Policy", "frame-ancestors 'self'")
|
||
w.WriteHeader(http.StatusOK)
|
||
case "/redir":
|
||
http.Redirect(w, r, "https://example.invalid/login", http.StatusFound)
|
||
case "/echo-cookie":
|
||
if r.Header.Get("Cookie") == "" {
|
||
w.WriteHeader(http.StatusForbidden)
|
||
return
|
||
}
|
||
w.Header().Set("X-Frame-Options", "SAMEORIGIN")
|
||
w.WriteHeader(http.StatusOK)
|
||
}
|
||
}))
|
||
defer srv.Close()
|
||
|
||
cases := []struct {
|
||
path string
|
||
frameable bool
|
||
conclusive bool
|
||
wantReasonPart string
|
||
}{
|
||
{"/ok", true, true, ""},
|
||
{"/xfo", false, true, "SAMEORIGIN"},
|
||
{"/csp", false, true, "frame-ancestors"},
|
||
{"/waf", true, false, "HTTP 403"},
|
||
{"/echo-cookie", false, true, "SAMEORIGIN"},
|
||
}
|
||
for _, c := range cases {
|
||
got := frameableVerdict(srv.URL+c.path, "portal_session=abc")
|
||
if got.Frameable != c.frameable || got.Conclusive != c.conclusive {
|
||
t.Errorf("%s: got frameable=%v conclusive=%v; want %v/%v",
|
||
c.path, got.Frameable, got.Conclusive, c.frameable, c.conclusive)
|
||
}
|
||
if c.wantReasonPart != "" && !strings.Contains(got.Reason, c.wantReasonPart) {
|
||
t.Errorf("%s: reason = %q; want containing %q", c.path, got.Reason, c.wantReasonPart)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestFrameableVerdictWithoutCookieIsInconclusive(t *testing.T) {
|
||
// 门户后面的应用对未登录请求会 302 到登录页;带了 Cookie 才拿到真正的响应头。
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
if r.Header.Get("Cookie") == "" {
|
||
w.WriteHeader(http.StatusForbidden)
|
||
return
|
||
}
|
||
w.Header().Set("X-Frame-Options", "SAMEORIGIN")
|
||
w.WriteHeader(http.StatusOK)
|
||
}))
|
||
defer srv.Close()
|
||
|
||
withCookie := frameableVerdict(srv.URL, "portal_session=abc")
|
||
if withCookie.Frameable || !withCookie.Conclusive {
|
||
t.Fatalf("带 Cookie 应判为确定不可内嵌,got %+v", withCookie)
|
||
}
|
||
without := frameableVerdict(srv.URL, "")
|
||
if without.Conclusive {
|
||
t.Fatalf("无 Cookie 应判为无法确认,got %+v", without)
|
||
}
|
||
}
|
||
|
||
// 分组增删改:历史分组受保护;删非空分组必须显式 force。
|
||
func TestSectionHandlers(t *testing.T) {
|
||
dir := t.TempDir()
|
||
oldConf := confFile
|
||
confFile = filepath.Join(dir, "conf.yml")
|
||
defer func() { confFile = oldConf }()
|
||
|
||
orig := `appConfig: {}
|
||
pageInfo: {}
|
||
sections:
|
||
- name: 常用服务
|
||
icon: fas fa-star
|
||
items:
|
||
- title: A
|
||
url: https://a.example
|
||
- name: 历史访问
|
||
icon: fas fa-history
|
||
items: []
|
||
`
|
||
if err := os.WriteFile(confFile, []byte(orig), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
call := func(h http.HandlerFunc, method, target, body string) *httptest.ResponseRecorder {
|
||
req := httptest.NewRequest(method, target, strings.NewReader(body))
|
||
rec := httptest.NewRecorder()
|
||
h(rec, req)
|
||
return rec
|
||
}
|
||
|
||
// 新增
|
||
if rec := call(handleAddSection, http.MethodPost, "/api/section", `{"name":"新分组"}`); rec.Code != 200 {
|
||
t.Fatalf("新增分组应成功,got %d %s", rec.Code, rec.Body.String())
|
||
}
|
||
// 重名
|
||
if rec := call(handleAddSection, http.MethodPost, "/api/section", `{"name":"新分组"}`); rec.Code != 409 {
|
||
t.Fatalf("同名分组应 409,got %d", rec.Code)
|
||
}
|
||
// 不能手工创建历史分组
|
||
if rec := call(handleAddSection, http.MethodPost, "/api/section", `{"name":"历史访问"}`); rec.Code != 409 {
|
||
t.Fatalf("手工创建历史分组应 409,got %d", rec.Code)
|
||
}
|
||
// 改名
|
||
if rec := call(handleUpdateSection, http.MethodPost, "/api/section/update", `{"index":2,"name":"改名后"}`); rec.Code != 200 {
|
||
t.Fatalf("改名应成功,got %d %s", rec.Code, rec.Body.String())
|
||
}
|
||
// 历史分组不能改名 / 删除
|
||
if rec := call(handleUpdateSection, http.MethodPost, "/api/section/update", `{"index":1,"name":"X"}`); rec.Code != 409 {
|
||
t.Fatalf("改历史分组应 409,got %d", rec.Code)
|
||
}
|
||
if rec := call(handleDeleteSection, http.MethodDelete, "/api/section", `{"index":1}`); rec.Code != 409 {
|
||
t.Fatalf("删历史分组应 409,got %d", rec.Code)
|
||
}
|
||
// 非空分组不给 force 应 409
|
||
if rec := call(handleDeleteSection, http.MethodDelete, "/api/section", `{"index":0}`); rec.Code != 409 {
|
||
t.Fatalf("删非空分组应 409,got %d", rec.Code)
|
||
}
|
||
// 给 force 才删
|
||
if rec := call(handleDeleteSection, http.MethodDelete, "/api/section?force=1", `{"index":0}`); rec.Code != 200 {
|
||
t.Fatalf("force 删除应成功,got %d %s", rec.Code, rec.Body.String())
|
||
}
|
||
|
||
cfg, err := loadConfig()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
names := make([]string, 0, len(cfg.Sections))
|
||
for _, s := range cfg.Sections {
|
||
names = append(names, s.Name)
|
||
}
|
||
want := []string{"历史访问", "改名后"}
|
||
if len(names) != len(want) || names[0] != want[0] || names[1] != want[1] {
|
||
t.Fatalf("删除/改名后分组 = %v; want %v", names, want)
|
||
}
|
||
}
|
||
|
||
// 深色模式必须换一层深色遮罩,否则亮色遮罩会把壁纸洗白。
|
||
func TestApplyWallpaperBlockEmitsDarkScrim(t *testing.T) {
|
||
out := applyWallpaperBlock("", "/wallpapers/w.jpg", 0.5)
|
||
if !strings.Contains(out, "linear-gradient(rgba(255,255,255,0.50)") {
|
||
t.Fatalf("缺少亮色遮罩:\n%s", out)
|
||
}
|
||
if !strings.Contains(out, "html.navi-dark body.navi-dark") ||
|
||
!strings.Contains(out, "rgba(6,12,24,0.65)") {
|
||
t.Fatalf("缺少深色遮罩规则:\n%s", out)
|
||
}
|
||
if strings.Count(out, "/wallpapers/w.jpg") != 2 {
|
||
t.Fatalf("深浅两种模式都应引用同一张壁纸:\n%s", out)
|
||
}
|
||
// 移除壁纸时应把两种规则一起清掉
|
||
if got := applyWallpaperBlock(out, "", 0.5); strings.Contains(got, "navi-dark body") {
|
||
t.Fatalf("移除壁纸后仍残留深色规则:\n%s", got)
|
||
}
|
||
}
|
||
|
||
// 路由表为空而现有配置含路由时必须拒绝(防止静默清空入口路由)。
|
||
func TestApplyRefusesEmptyRoutesOverExisting(t *testing.T) {
|
||
dir := t.TempDir()
|
||
oldC, oldG := confFile, gatewayFile
|
||
defer func() { confFile, gatewayFile = oldC, oldG }()
|
||
confFile = filepath.Join(dir, "conf.yml")
|
||
gatewayFile = filepath.Join(dir, "gateway.yml")
|
||
if err := os.WriteFile(gatewayFile, []byte("routes: []\n"), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
// 造一份"已有路由"的生成产物
|
||
conf := gatewayFileFor()
|
||
if err := os.MkdirAll(filepath.Dir(conf), 0o755); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := os.WriteFile(conf, []byte("server {\n server_name a.com;\n}\n"), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
rec := httptest.NewRecorder()
|
||
handleGatewayApply(rec, httptest.NewRequest(http.MethodPost, "/api/gateway/apply", nil))
|
||
if rec.Code != 400 {
|
||
t.Fatalf("应拒绝并返回 400,实际 %d:%s", rec.Code, rec.Body.String())
|
||
}
|
||
if !strings.Contains(rec.Body.String(), "拒绝覆盖") {
|
||
t.Fatalf("错误信息应说明原因:%s", rec.Body.String())
|
||
}
|
||
}
|
||
|
||
// 提交路由表不得改动入口端口/TLS/认证地址等其它配置(旧实现会重置为默认)。
|
||
func TestGatewayRoutesPostPreservesOtherConfig(t *testing.T) {
|
||
dir := t.TempDir()
|
||
oldC, oldG := confFile, gatewayFile
|
||
defer func() { confFile, gatewayFile = oldC, oldG }()
|
||
confFile = filepath.Join(dir, "conf.yml")
|
||
gatewayFile = filepath.Join(dir, "gateway.yml")
|
||
// 该接口同时会同步 Dashy 导航数据,需要一份 conf.yml
|
||
if err := os.WriteFile(confFile, []byte("pageInfo: {}\nsections: []\n"), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
seed := "listenPort: 13080\nlistenTlsPort: 18443\nredirectPort: 13081\nlistenHttps: false\n" +
|
||
"certFile: /etc/nginx/certs/fullchain.pem\nkeyFile: /etc/nginx/certs/privkey.pem\n" +
|
||
"authUrl: http://127.0.0.1:3005/auth/verify\nloginUrl: https://login.example.com/login\n" +
|
||
"routes:\n - host: a.example.com\n upstream: http://127.0.0.1:3000\n"
|
||
if err := os.WriteFile(gatewayFile, []byte(seed), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
post := func(body string) *httptest.ResponseRecorder {
|
||
rec := httptest.NewRecorder()
|
||
handleGatewayRoutes(rec, httptest.NewRequest(http.MethodPost, "/api/gateway/routes", strings.NewReader(body)))
|
||
return rec
|
||
}
|
||
// ① 只给单个路由对象(旧接口会当成"整份配置"并把其它字段清零)
|
||
if rec := post(`{"host":"b.example.com","upstream":"http://127.0.0.1:4000"}`); rec.Code != 400 {
|
||
t.Fatalf("单个对象应被拒绝并提示,实际 %d: %s", rec.Code, rec.Body.String())
|
||
}
|
||
// ② 正确形状:数组
|
||
if rec := post(`{"routes":[{"host":"b.example.com","upstream":"http://127.0.0.1:4000"}]}`); rec.Code != 200 {
|
||
t.Fatalf("数组提交应成功,实际 %d: %s", rec.Code, rec.Body.String())
|
||
}
|
||
got, _ := os.ReadFile(gatewayFile)
|
||
body := string(got)
|
||
for _, want := range []string{"listenPort: 13080", "listenTlsPort: 18443", "redirectPort: 13081",
|
||
"certFile: /etc/nginx/certs/fullchain.pem", "authUrl: http://127.0.0.1:3005/auth/verify",
|
||
"loginUrl: https://login.example.com/login", "host: b.example.com"} {
|
||
if !strings.Contains(body, want) {
|
||
t.Fatalf("保存后丢失 %q:\n%s", want, body)
|
||
}
|
||
}
|
||
// ③ 防误清空
|
||
if rec := post(`{"routes":[]}`); rec.Code != 400 {
|
||
t.Fatalf("清空应被拒绝,实际 %d", rec.Code)
|
||
}
|
||
if rec := post(`{"routes":[],"force":true}`); rec.Code != 200 {
|
||
t.Fatalf("force 清空应被允许,实际 %d: %s", rec.Code, rec.Body.String())
|
||
}
|
||
}
|