diff --git a/internal/webview/proxy/server_test.go b/internal/webview/proxy/server_test.go index d30ff863..217de9e8 100644 --- a/internal/webview/proxy/server_test.go +++ b/internal/webview/proxy/server_test.go @@ -4,11 +4,13 @@ import ( "bufio" "context" "crypto/tls" + "errors" "io" "net" "net/http" "net/http/httptest" "net/url" + "strings" "sync/atomic" "testing" "time" @@ -16,6 +18,59 @@ import ( "github.com/armon/go-socks5" ) +func TestRejectedProxyRequests(t *testing.T) { + secret := "upstream-password" + for _, tc := range []struct { + name, method, target string + status int + }{ + {"relative URL", "GET", "/relative", 400}, + {"unsupported URL", "GET", "ftp://example.com/file", 400}, + {"invalid CONNECT", "CONNECT", "example.com", 400}, + {"HTTP selection failure", "GET", "http://example.com/", 502}, + {"CONNECT selection failure", "CONNECT", "example.com:443", 502}, + } { + t.Run(tc.name, func(t *testing.T) { + s := &Server{selectProxy: func(*http.Request) (*url.URL, error) { return nil, errors.New(secret) }} + s.transport = &http.Transport{Proxy: s.selectProxy} + defer s.transport.CloseIdleConnections() + response := httptest.NewRecorder() + s.ServeHTTP(response, httptest.NewRequest(tc.method, tc.target, nil)) + if response.Code != tc.status || strings.Contains(response.Body.String(), secret) { + t.Fatalf("unexpected proxy rejection: %d %q", response.Code, response.Body.String()) + } + }) + } +} + +func TestTunnelRejectsUnsafeUpstreams(t *testing.T) { + rejected := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "authentication required", http.StatusProxyAuthRequired) + })) + defer rejected.Close() + secure := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Error("untrusted TLS proxy received a request") + })) + defer secure.Close() + for _, upstream := range []string{"ftp://127.0.0.1", rejected.URL, secure.URL} { + t.Run(upstream, func(t *testing.T) { + selected, err := url.Parse(upstream) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + conn, err := dialTunnel(ctx, "example.com:443", selected) + if conn != nil { + conn.Close() + } + if err == nil { + t.Fatal("unsafe upstream unexpectedly accepted") + } + }) + } +} + func TestRoutesAndAuthentication(t *testing.T) { origin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Header.Get("Proxy-Authorization") != "" { diff --git a/ui/flutter/third_party/README.md b/ui/flutter/third_party/README.md index 9a0d8cc6..b250c9c8 100644 --- a/ui/flutter/third_party/README.md +++ b/ui/flutter/third_party/README.md @@ -18,6 +18,8 @@ Local native changes are limited to: setting and attach the profile before navigation. - `MyCookieManager`: route cookie operations to the requested profile, capturing the store per operation so asynchronous calls cannot cross profiles. +- macOS `FlutterWebViewController`: weakly register profile views so uninstall + can release their WKWebViews even when Flutter retains a platform-view wrapper. `lib/app/rpc/webview_profile.dart` uses the existing plugin method channel and serializes these internal settings. No extension JavaScript API is added. diff --git a/ui/flutter/third_party/flutter_inappwebview_macos/macos/Classes/GopeedProfiles.swift b/ui/flutter/third_party/flutter_inappwebview_macos/macos/Classes/GopeedProfiles.swift index ca0602f5..8d5ab9b1 100644 --- a/ui/flutter/third_party/flutter_inappwebview_macos/macos/Classes/GopeedProfiles.swift +++ b/ui/flutter/third_party/flutter_inappwebview_macos/macos/Classes/GopeedProfiles.swift @@ -7,8 +7,25 @@ import FlutterMacOS final class GopeedProfiles { private static var proxyURLs: [String: String] = [:] private static var stores: [String: WKWebsiteDataStore] = [:] + private static var views: [String: NSHashTable] = [:] static func store(_ identifier: String) -> WKWebsiteDataStore? { stores[identifier] } + static func register(_ view: FlutterWebViewController, identifier: String) { + let profileViews = views[identifier] ?? NSHashTable.weakObjects() + profileViews.add(view) + views[identifier] = profileViews + } + + private static func disposeViews(_ identifier: String) { + // Flutter may retain a removed platform-view wrapper beyond the frame + // boundary. Release its WKWebView now, before deleting the named store. + // The host has already closed all pages belonging to this profile. + let profileViews = views.removeValue(forKey: identifier) + for view in profileViews?.allObjects ?? [] { + view.dispose(removeFromSuperview: true) + } + } + static func prepare(arguments: NSDictionary?, result: @escaping FlutterResult) { guard #available(macOS 14.0, iOS 17.0, *), let identifier = arguments?["gopeedProfileId"] as? String, @@ -45,6 +62,7 @@ final class GopeedProfiles { } // Deleting a named store alone can leave in-process cookie caches. // Clear every website data type before releasing/removing the store. + disposeViews(identifier) let store = stores[identifier] ?? WKWebsiteDataStore(forIdentifier: uuid) store.removeData(ofTypes: WKWebsiteDataStore.allWebsiteDataTypes(), modifiedSince: Date.distantPast) { stores.removeValue(forKey: identifier) diff --git a/ui/flutter/third_party/flutter_inappwebview_macos/macos/Classes/InAppWebView/FlutterWebViewController.swift b/ui/flutter/third_party/flutter_inappwebview_macos/macos/Classes/InAppWebView/FlutterWebViewController.swift index 2caa19c6..7bbb22f6 100755 --- a/ui/flutter/third_party/flutter_inappwebview_macos/macos/Classes/InAppWebView/FlutterWebViewController.swift +++ b/ui/flutter/third_party/flutter_inappwebview_macos/macos/Classes/InAppWebView/FlutterWebViewController.swift @@ -68,6 +68,9 @@ public class FlutterWebViewController: NSView, Disposable { webView!.settings = settings webView!.prepare() webView!.windowCreated = true + if let identifier = initialSettings["gopeedProfileId"] as? String { + GopeedProfiles.register(self, identifier: identifier) + } } required init?(coder nsCoder: NSCoder) {