Files
HomeDash/admin-server/main_test.go
HomeDash Bot 22c405d2a0 feat(admin): 新增可内嵌性三态探测 /admin/api/frameable
Dashy 的「工作空间」用 iframe 打开应用,被 X-Frame-Options /
CSP frame-ancestors 拒绝时浏览器只给一片空白,而且 JS 无法区分
「被拒绝」与「加载中」(onload 一样触发)。所以由服务端读响应头。

实测 24 个应用:19 个可内嵌;Gitea(含内网 IP) 与 docker.com 是
SAMEORIGIN,Dify 是 DENY。

三态而非布尔,因为探测本身会失真:
- conclusive=true  frameable=false → 有明确拒绝证据(XFO / frame-ancestors)
- conclusive=true  frameable=true  → 2xx 且无拒绝头
- conclusive=false                  → 探测被 WAF 拦成 403、或跳到了别的主机
  这类不给结论,前端照常尝试内嵌,但在工具条里说明原因并留出新标签页退路。

两个关键细节:
1) 转发浏览器自己的 Cookie。门户后面的应用对未登录请求会 302 到登录页
   (登录页没有 X-Frame-Options),不转发会话就会把「重定向到登录页」
   误判成「可内嵌」——这是线上真实发生过的误判。
2) 用普通浏览器 UA。自造的 "HomeDash embed check" UA 会被 WAF 当机器人拦成 403。

测试覆盖:无头/有 XFO/有 CSP frame-ancestors/WAF 403/带与不带 Cookie。
2026-09-11 19:04:38 +08:00

167 lines
5.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"net/http"
"net/http/httptest"
"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, "&lt;script&gt;") && !strings.Contains(got, "&#34;") {
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)
}
}