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。
167 lines
5.3 KiB
Go
167 lines
5.3 KiB
Go
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, "<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)
|
||
}
|
||
}
|