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:
@ -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 {
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user