diff --git a/admin-server/main.go b/admin-server/main.go index c0361b9..1cb90b8 100644 --- a/admin-server/main.go +++ b/admin-server/main.go @@ -428,6 +428,98 @@ func jsonErr(w http.ResponseWriter, status int, msg string) { json.NewEncoder(w).Encode(map[string]string{"error": msg}) } +// ── 可内嵌性探测 ── +// +// Dashy 的「工作空间」用 iframe 打开应用。部分应用会带 X-Frame-Options 或 +// CSP frame-ancestors 拒绝被嵌,此时浏览器只给一片空白,而且 JS 无法区分 +// 「被拒绝」和「加载中」(onload 一样会触发)。所以由服务端先读响应头, +// 前端据此决定:能嵌就嵌,不能嵌就给出明确提示 + 新标签页按钮。 + +// frameBlockReason 判断响应头是否禁止被本站内嵌;允许时返回空串。 +func frameBlockReason(h http.Header) string { + if xfo := strings.TrimSpace(h.Get("X-Frame-Options")); xfo != "" { + return "X-Frame-Options: " + xfo + } + for _, part := range strings.Split(h.Get("Content-Security-Policy"), ";") { + p := strings.TrimSpace(part) + if !strings.HasPrefix(strings.ToLower(p), "frame-ancestors") { + continue + } + if strings.Contains(p, "*") { + return "" + } + return p + } + return "" +} + +// frameVerdict 是三态而不是布尔: +// 探测会被 WAF 拦成 403(响应里没有 X-Frame-Options),此时不能当成 +// “可内嵌”——否则浏览器照样拒绝,用户只能看到一片空白。 +// Conclusive=false 表示探测没拿到可信证据,前端会照常尝试内嵌, +// 但同时在工具条里说明原因并给出新标签页退路。 +type frameVerdict struct { + Frameable bool `json:"frameable"` + Conclusive bool `json:"conclusive"` + Reason string `json:"reason"` +} + +// browserUA 像普通浏览器:用自造 UA 会被 WAF 当机器人拦掉。 +const browserUA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + + "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36" + +func frameableVerdict(target, cookie string) frameVerdict { + u, err := url.Parse(target) + if err != nil || u.Host == "" { + return frameVerdict{Frameable: false, Conclusive: true, Reason: "地址无效"} + } + if u.Scheme != "http" && u.Scheme != "https" { + // /extension/install.html 这类站内相对地址天然同源,可直接内嵌 + return frameVerdict{Frameable: true, Conclusive: true} + } + req, err := http.NewRequest(http.MethodGet, target, nil) + if err != nil { + return frameVerdict{Frameable: false, Conclusive: true, Reason: "地址无效"} + } + req.Header.Set("User-Agent", browserUA) + req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") + // 关键:带上浏览器自己的 Cookie。门户后面的应用对未登录请求会 302 到 + // 登录页(无 X-Frame-Options),不转发会话就会把「重定向到登录页」 + // 误判成「可内嵌」。 + if cookie != "" { + req.Header.Set("Cookie", cookie) + } + client := &http.Client{Timeout: 8 * time.Second} + res, err := client.Do(req) + if err != nil { + return frameVerdict{Frameable: true, Conclusive: false, Reason: "探测未能连通"} + } + defer res.Body.Close() + io.Copy(io.Discard, io.LimitReader(res.Body, 8<<10)) + // 跳到了别的主机(例如登录页)→ 拿到的不是目标应用本身的响应头,不能当证据 + if res.Request != nil && res.Request.URL != nil && !strings.EqualFold(res.Request.URL.Host, u.Host) { + return frameVerdict{Frameable: true, Conclusive: false, + Reason: "被重定向到 " + res.Request.URL.Host} + } + // 响应头里有明确的拒绝证据 → 确定不可内嵌 + if reason := frameBlockReason(res.Header); reason != "" { + return frameVerdict{Frameable: false, Conclusive: true, Reason: reason} + } + if res.StatusCode < 200 || res.StatusCode > 299 { + return frameVerdict{Frameable: true, Conclusive: false, Reason: fmt.Sprintf("探测返回 HTTP %d", res.StatusCode)} + } + return frameVerdict{Frameable: true, Conclusive: true} +} + +func handleFrameable(w http.ResponseWriter, r *http.Request) { + target := r.URL.Query().Get("url") + if target == "" { + jsonErr(w, 400, "缺少 url 参数") + return + } + jsonResp(w, frameableVerdict(target, r.Header.Get("Cookie"))) +} + func handleSections(w http.ResponseWriter, r *http.Request) { mu.Lock() defer mu.Unlock() @@ -757,6 +849,7 @@ func main() { http.HandleFunc("/api/icon", cors(handleIconUpload)) http.HandleFunc("/api/wallpaper", cors(handleWallpaper)) http.HandleFunc("/api/sync-info", cors(handleSyncInfo)) + http.HandleFunc("/api/frameable", cors(handleFrameable)) // 历史记录管理:列表 / 清空全部 / 删除单条 http.HandleFunc("/api/history", cors(func(w http.ResponseWriter, r *http.Request) { switch r.Method { diff --git a/admin-server/main_test.go b/admin-server/main_test.go index 7487c5f..8a2010a 100644 --- a/admin-server/main_test.go +++ b/admin-server/main_test.go @@ -1,6 +1,8 @@ package main import ( + "net/http" + "net/http/httptest" "strings" "testing" ) @@ -61,3 +63,104 @@ func TestMapString(t *testing.T) { 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) + } +}