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。
This commit is contained in:
HomeDash Bot
2026-09-11 19:04:38 +08:00
parent da196abcb5
commit 22c405d2a0
2 changed files with 196 additions and 0 deletions

View File

@ -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 {

View File

@ -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)
}
}