diff --git a/internal/panel/panel.go b/internal/panel/panel.go index 626c878..909bd9c 100644 --- a/internal/panel/panel.go +++ b/internal/panel/panel.go @@ -43,6 +43,12 @@ type handler struct { } func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + // WeChat/QQ in-app browsers cannot run WebAuthn, so steer their document + // navigations to a "open in your system browser" interstitial before the SPA + // (which is built around passkey enrollment) ever loads. See webview.go. + if guardInAppWebView(w, r) { + return + } switch { case r.URL.Path == "/healthz" || r.URL.Path == "/readyz" || strings.HasPrefix(r.URL.Path, "/api/"): h.api.ServeHTTP(w, r) diff --git a/internal/panel/webview.go b/internal/panel/webview.go new file mode 100644 index 0000000..b62df37 --- /dev/null +++ b/internal/panel/webview.go @@ -0,0 +1,172 @@ +package panel + +import ( + "html/template" + "net/http" + "strings" +) + +// In-app webview guard (spec §B onboarding; passkey/WebAuthn is unusable inside the +// WeChat and QQ in-app browsers). A player onboards by opening console. +// — often by scanning the console QR with a phone. If the phone's WeChat or QQ scanner +// opens the link, it loads inside that app's WebView, where a WebAuthn ceremony +// silently fails: the player would hit a dead end at the one step (passkey enrollment) +// the console is built around. +// +// So this guard intercepts the top-level HTML navigation from those WebViews and serves +// an interstitial that steers the player to their SYSTEM browser instead of letting the +// broken SPA load. It lives here in the Go static server — NOT in the panel SPA — so the +// frontend is untouched and every navigation that reaches the console (scanned or +// clicked) is covered at one seam. +// +// It is a guide, not a wall: a "continue anyway" link sets an ack cookie so a determined +// user (or a false-positive UA) is never hard-blocked. Only document navigations are +// touched — API calls, /config.json, health probes and asset requests pass straight +// through, so an acknowledged SPA still loads its scripts normally. + +// webViewAckCookie records that the visitor chose to proceed past the interstitial, so +// subsequent full page loads in the same WebView are not interrupted again. +const webViewAckCookie = "felis_ua_ack" + +// isInAppWebView reports whether the User-Agent is a WeChat or QQ in-app browser. WeChat +// WebViews carry "MicroMessenger"; QQ's in-app browser carries "MQQBrowser" and the QQ +// chat WebView carries a " QQ/" token. Matched case-insensitively. Standalone +// real browsers (Chrome, Safari, Firefox, even the standalone QQ Browser app) are not +// matched — the target is specifically the chat-app WebViews where passkey breaks. +func isInAppWebView(ua string) bool { + if ua == "" { + return false + } + l := strings.ToLower(ua) + return strings.Contains(l, "micromessenger") || // WeChat WebView + strings.Contains(l, "mqqbrowser") || // QQ in-app browser + strings.Contains(l, " qq/") // QQ chat WebView token +} + +// guardInAppWebView serves the "open in your system browser" interstitial when a WeChat +// or QQ WebView makes a top-level HTML navigation to the console, returning true when it +// handled the request. It returns false — letting the normal SPA/static path run — for +// non-navigations, non-WebView agents, the API/config/health paths, and once the visitor +// has acknowledged (via the ack cookie or the ua_ack escape hatch, which also plants the +// cookie so the acknowledgement sticks across reloads). +func guardInAppWebView(w http.ResponseWriter, r *http.Request) bool { + if r.Method != http.MethodGet { + return false + } + p := r.URL.Path + if p == "/healthz" || p == "/readyz" || p == "/config.json" || strings.HasPrefix(p, "/api/") { + return false + } + // Only intercept an actual HTML document navigation, never asset/XHR requests + // (those do not send Accept: text/html), so an acknowledged SPA loads normally. + if !strings.Contains(r.Header.Get("Accept"), "text/html") { + return false + } + if !isInAppWebView(r.Header.Get("User-Agent")) { + return false + } + if _, err := r.Cookie(webViewAckCookie); err == nil { + return false // already acknowledged + } + if r.URL.Query().Get("ua_ack") == "1" { + // The "continue anyway" path: remember the choice and let the SPA load. + http.SetCookie(w, &http.Cookie{ + Name: webViewAckCookie, + Value: "1", + Path: "/", + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + MaxAge: 3600, + }) + return false + } + serveWebViewInterstitial(w, r) + return true +} + +// serveWebViewInterstitial writes the guidance page (HTTP 200, self-contained, no external +// assets or JS so it renders inside a restricted WebView and triggers no dialogs). +func serveWebViewInterstitial(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("X-Content-Type-Options", "nosniff") + w.WriteHeader(http.StatusOK) + _ = webViewInterstitialTmpl.Execute(w, struct { + URL string + AckURL string + }{ + URL: externalURL(r), + AckURL: ackURL(r), + }) +} + +// externalURL rebuilds the absolute URL the visitor should paste into a system browser, +// with the internal ua_ack hint stripped. The scheme prefers X-Forwarded-Proto (the edge +// terminates TLS upstream), then the request's own TLS, defaulting to https — the console +// is an https origin. +func externalURL(r *http.Request) string { + scheme := "https" + if proto := r.Header.Get("X-Forwarded-Proto"); proto != "" { + scheme = proto + } else if r.TLS == nil { + scheme = "https" + } + u := *r.URL + q := u.Query() + q.Del("ua_ack") + u.RawQuery = q.Encode() + return scheme + "://" + r.Host + u.RequestURI() +} + +// ackURL is the same location with the ua_ack escape hatch set — a relative link (same +// origin), so it works regardless of the external host. +func ackURL(r *http.Request) string { + u := *r.URL + q := u.Query() + q.Set("ua_ack", "1") + u.RawQuery = q.Encode() + return u.RequestURI() +} + +// webViewInterstitialTmpl is the guidance page. html/template escapes the reflected URL +// values (defusing a reflected-XSS via a crafted path/query) in both text and URL/href +// contexts. +var webViewInterstitialTmpl = template.Must(template.New("webview").Parse(` + + + + +请用系统浏览器打开 / Open in your browser + + + +
+

请在系统浏览器中打开

+

你正在微信 / QQ 的内置浏览器中打开本页面。通行密钥(Passkey)在内置浏览器中无法使用,请改用系统浏览器完成登录。

+

复制下面的网址,粘贴到 Safari / Chrome 等系统浏览器:

+ {{.URL}} +
    +
  1. 微信:点右上角「⋯」→「在浏览器打开」
  2. +
  3. QQ:点右上角「⋯」→「用浏览器打开」
  4. +
+
+

Open in your system browser

+

You opened this page inside the WeChat / QQ in-app browser, where passkeys (WebAuthn) do not work. Copy the address above into Safari, Chrome, or another system browser to finish signing in.

+ 仍要在此继续 / Continue here anyway +
+ +`)) diff --git a/internal/panel/webview_test.go b/internal/panel/webview_test.go new file mode 100644 index 0000000..b376df0 --- /dev/null +++ b/internal/panel/webview_test.go @@ -0,0 +1,159 @@ +package panel + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestIsInAppWebView(t *testing.T) { + cases := []struct { + name string + ua string + want bool + }{ + {"wechat", "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0) AppleWebKit/605 MicroMessenger/8.0.30(0x18001e2f) NetType/WIFI", true}, + {"wechat-android", "Mozilla/5.0 (Linux; Android 13) MicroMessenger/8.0.40 Mobile", true}, + {"qq-inapp", "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0) AppleWebKit/605 QQ/8.9.68 V1_IPH", true}, + {"qq-mqqbrowser", "Mozilla/5.0 (Linux; Android 12) MQQBrowser/13.6 Mobile Safari/537.36", true}, + {"plain-chrome", "Mozilla/5.0 (Windows NT 10.0) AppleWebKit/537.36 Chrome/120 Safari/537.36", false}, + {"mobile-safari", "Mozilla/5.0 (iPhone; CPU iPhone OS 16_0) AppleWebKit/605 Version/16 Mobile Safari/604", false}, + {"empty", "", false}, + // "QQ" only counts as the chat-webview token " QQ/"; a bare substring must not + // trip the guard (avoid false positives on unrelated agents). + {"qqbrowser-standalone", "Mozilla/5.0 (Linux; Android 12) QQBrowser/13.6", false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := isInAppWebView(c.ua); got != c.want { + t.Fatalf("isInAppWebView(%q) = %v, want %v", c.ua, got, c.want) + } + }) + } +} + +// newHandler builds a panel handler with an api stub that fails the test if the guard +// ever leaks a WebView navigation through to it. +func newPanelHandler(t *testing.T) http.Handler { + t.Helper() + api := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusTeapot) + }) + return Handler(api, "example.test") +} + +func TestGuardServesInterstitialForWeChatNavigation(t *testing.T) { + h := newPanelHandler(t) + req := httptest.NewRequest(http.MethodGet, "/onboarding", nil) + req.Header.Set("Accept", "text/html,application/xhtml+xml") + req.Header.Set("User-Agent", "MicroMessenger/8.0.30") + + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", w.Code) + } + body := w.Body.String() + // It must be the interstitial, NOT the SPA index (which contains "Felis"). + if !strings.Contains(body, "系统浏览器") || !strings.Contains(body, "Passkey") { + t.Fatalf("body is not the interstitial: %q", body) + } + if strings.Contains(w.Header().Get("Content-Type"), "text/html") == false { + t.Fatalf("content-type = %q", w.Header().Get("Content-Type")) + } + // The absolute URL to copy must reflect the request host+path (https default). + if !strings.Contains(body, "https://example.com/onboarding") && !strings.Contains(body, "example.com/onboarding") { + // httptest default host is example.com + t.Fatalf("interstitial missing external URL, body: %q", body) + } +} + +func TestGuardReflectedURLIsEscaped(t *testing.T) { + h := newPanelHandler(t) + // A crafted path/query must be HTML-escaped in the reflected URL, not injected raw. + req := httptest.NewRequest(http.MethodGet, "/x?q=%22%3E%3Cscript%3Ealert(1)%3C/script%3E", nil) + req.Header.Set("Accept", "text/html") + req.Header.Set("User-Agent", "MicroMessenger") + + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + + if strings.Contains(w.Body.String(), "") { + t.Fatalf("reflected URL was not escaped: %q", w.Body.String()) + } +} + +func TestGuardPassesThroughNonWebView(t *testing.T) { + h := newPanelHandler(t) + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Accept", "text/html") + req.Header.Set("User-Agent", "Mozilla/5.0 Chrome/120 Safari/537.36") + + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + + if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "Felis") { + t.Fatalf("normal browser did not get the SPA: %d %q", w.Code, w.Body.String()) + } +} + +func TestGuardIgnoresAssetAndAPIRequests(t *testing.T) { + h := newPanelHandler(t) + + // An asset request from the SAME WebView (no Accept: text/html) must pass through, + // so an acknowledged SPA can still load its scripts. + req := httptest.NewRequest(http.MethodGet, "/config.json", nil) + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "MicroMessenger") + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != http.StatusOK || strings.Contains(w.Body.String(), "系统浏览器") { + t.Fatalf("config.json was intercepted: %d", w.Code) + } + + // An API call from a WebView must reach the api handler, not the interstitial. + req = httptest.NewRequest(http.MethodGet, "/api/v1/me", nil) + req.Header.Set("Accept", "text/html") // even if it claims html + req.Header.Set("User-Agent", "MicroMessenger") + w = httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != http.StatusTeapot { + t.Fatalf("api call intercepted: %d", w.Code) + } +} + +func TestGuardHonorsAcknowledgement(t *testing.T) { + h := newPanelHandler(t) + + // The ua_ack escape hatch: it must plant the ack cookie AND serve the SPA. + req := httptest.NewRequest(http.MethodGet, "/onboarding?ua_ack=1", nil) + req.Header.Set("Accept", "text/html") + req.Header.Set("User-Agent", "MicroMessenger") + w := httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "Felis") { + t.Fatalf("ua_ack did not serve the SPA: %d %q", w.Code, w.Body.String()) + } + var acked bool + for _, c := range w.Result().Cookies() { + if c.Name == webViewAckCookie && c.Value == "1" { + acked = true + } + } + if !acked { + t.Fatalf("ua_ack did not set the ack cookie") + } + + // A subsequent navigation carrying the ack cookie is not interrupted. + req = httptest.NewRequest(http.MethodGet, "/onboarding", nil) + req.Header.Set("Accept", "text/html") + req.Header.Set("User-Agent", "MicroMessenger") + req.AddCookie(&http.Cookie{Name: webViewAckCookie, Value: "1"}) + w = httptest.NewRecorder() + h.ServeHTTP(w, req) + if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "Felis") { + t.Fatalf("ack cookie was not honored: %d %q", w.Code, w.Body.String()) + } +}