Isolate extension WebView profiles and follow host proxy settings

This commit is contained in:
Levi committed 2026-09-14 00:30:52 +08:00
1 parent cee5f3336e
commit a0e84a75b1
556 files changed
+71097 -31

No files matched your search

+10 -2
View File
@@ -21,6 +21,7 @@ import (
"github.com/GopeedLab/gopeed/internal/controller"
"github.com/GopeedLab/gopeed/internal/fetcher"
"github.com/GopeedLab/gopeed/internal/logger"
webviewproxy "github.com/GopeedLab/gopeed/internal/webview/proxy"
"github.com/GopeedLab/gopeed/pkg/base"
"github.com/GopeedLab/gopeed/pkg/protocol/http"
"github.com/GopeedLab/gopeed/pkg/util"
@@ -97,8 +98,10 @@ type Progress struct {
}
type Downloader struct {
Logger *logger.Logger
ExtensionLogger *logger.Logger
webviewProxyLock sync.Mutex
webviewProxy *webviewproxy.Server
Logger *logger.Logger
ExtensionLogger *logger.Logger
cfg *DownloaderConfig
fetcherCache map[string]fetcher.Fetcher
@@ -1114,6 +1117,11 @@ func (d *Downloader) doDelete(task *Task, force bool) (err error) {
func (d *Downloader) Close() error {
d.closed.Store(true)
d.webviewProxyLock.Lock()
if d.webviewProxy != nil {
_ = d.webviewProxy.Close()
}
d.webviewProxyLock.Unlock()
closeArr := []func() error{
d.pauseAll,
+6
View File
@@ -68,6 +68,9 @@ type IsAvailableResult struct {
}
type PageOpenParams struct {
ProfileID string `json:"profileId,omitempty"`
DataPath string `json:"dataPath,omitempty"`
ProxyURL string `json:"proxyUrl,omitempty"`
Headless bool `json:"headless,omitempty"`
Debug bool `json:"debug,omitempty"`
Title string `json:"title,omitempty"`
@@ -78,6 +81,9 @@ type PageOpenParams struct {
func NewPageOpenParams(opts OpenOptions) PageOpenParams {
return PageOpenParams{
ProfileID: opts.ProfileID,
DataPath: opts.DataPath,
ProxyURL: opts.ProxyURL,
Headless: opts.Headless,
Debug: opts.Debug,
Title: opts.Title,
+4
View File
@@ -29,6 +29,10 @@ type Page interface {
}
type OpenOptions struct {
// Host-owned fields are never parsed from extension JavaScript options.
ProfileID string
DataPath string
ProxyURL string
Headless bool
Debug bool
Title string
@@ -336,3 +336,15 @@ func (p *fakePage) Close() error {
p.closed = true
return nil
}
func TestExtensionCannotSupplyHostProfileOrProxy(t *testing.T) {
opener := &fakeOpener{page: &fakePage{}}
runtime := NewRuntime(opener, true)
_, err := runtime.Open(map[string]any{"profileId": "other-extension", "dataPath": "/tmp/other-profile", "proxyUrl": "http://example.com:1234", "ProfileID": "other"})
if err != nil {
t.Fatal(err)
}
if opener.opts.ProfileID != "" || opener.opts.DataPath != "" || opener.opts.ProxyURL != "" {
t.Fatalf("extension controlled host settings: %+v", opener.opts)
}
}
+1 -1
View File
@@ -365,7 +365,7 @@ func doTrigger[T any](d *Downloader, event ActivationEvent, req *base.Request, c
engine, session := d.newExtensionEngine()
defer session.CloseIfIdle()
gopeed.Runtime = &InstanceRuntime{
WebView: d.newExtensionWebViewRuntime(session),
WebView: d.newExtensionWebViewRuntime(session, ext.buildIdentity()),
}
err = injectGopeed(engine.Runtime, gopeed)
if err != nil {
+1 -1
View File
@@ -30,7 +30,7 @@ func (d *Downloader) NewExtensionEngine(ext *Extension, settings map[string]any)
identity: ext.buildIdentity(),
},
Runtime: &InstanceRuntime{
WebView: d.newExtensionWebViewRuntime(session),
WebView: d.newExtensionWebViewRuntime(session, ext.buildIdentity()),
},
}
if err := injectGopeed(engine.Runtime, gopeed); err != nil {
+2 -2
View File
@@ -127,13 +127,13 @@ func (d *Downloader) newExtensionEngine() (*engine.Engine, *engineSession) {
return e, session
}
func (d *Downloader) newExtensionWebViewRuntime(session *engineSession) *enginewebview.Runtime {
func (d *Downloader) newExtensionWebViewRuntime(session *engineSession, identity string) *enginewebview.Runtime {
var (
opener enginewebview.Opener
available bool
)
if provider := d.cfg.WebViewProvider; provider != nil && provider.IsAvailable() {
opener = provider
opener = &extensionWebViewOpener{downloader: d, provider: provider, identity: identity}
available = true
}
runtime := enginewebview.NewRuntime(opener, available)
+74
View File
@@ -0,0 +1,74 @@
package download
import (
"crypto/sha256"
"fmt"
"github.com/GopeedLab/gopeed/pkg/base"
"net/http"
"net/url"
"path/filepath"
"runtime"
webviewproxy "github.com/GopeedLab/gopeed/internal/webview/proxy"
enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview"
)
// extensionWebViewOpener binds browser state to the owning extension. Neither
// identity, filesystem paths nor proxy settings are accepted from JavaScript.
type extensionWebViewOpener struct {
downloader *Downloader
provider enginewebview.Provider
identity string
}
func (o *extensionWebViewOpener) Open(opts enginewebview.OpenOptions) (enginewebview.Page, error) {
if o.identity == "" {
return nil, fmt.Errorf("webview extension identity is required")
}
sum := sha256.Sum256([]byte(o.identity))
opts.ProfileID = fmt.Sprintf("%x-%x-%x-%x-%x", sum[:4], sum[4:6], sum[6:8], sum[8:10], sum[10:16])
path, err := filepath.Abs(filepath.Join(o.downloader.cfg.StorageDir, "webview", fmt.Sprintf("%x", sum[:])))
if err != nil {
return nil, err
}
opts.DataPath = path
opts.ProxyURL, err = o.downloader.webviewProxyURL()
if err != nil {
return nil, err
}
return o.provider.Open(opts)
}
func (d *Downloader) webviewProxyURL() (string, error) {
d.webviewProxyLock.Lock()
defer d.webviewProxyLock.Unlock()
if d.closed.Load() {
return "", fmt.Errorf("downloader is closed")
}
if d.webviewProxy == nil {
server, err := webviewproxy.New(func(r *http.Request) (*url.URL, error) {
if d.blob != nil && d.blob.IsURL(r.URL.String()) {
return nil, nil
}
d.configLock.RLock()
var cfg base.DownloaderProxyConfig
if d.cfg.Proxy != nil {
cfg = *d.cfg.Proxy
}
d.configLock.RUnlock()
handler := cfg.ToHandler()
if handler == nil {
return nil, nil
}
return handler(r)
})
if err != nil {
return "", err
}
d.webviewProxy = server
}
if runtime.GOOS == "darwin" || runtime.GOOS == "ios" {
return d.webviewProxy.SOCKSURL(), nil
}
return d.webviewProxy.URL(), nil
}
@@ -0,0 +1,51 @@
package download
import (
"path/filepath"
"sync"
"testing"
"github.com/GopeedLab/gopeed/pkg/base"
enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview"
)
type profileCaptureProvider struct{ options []enginewebview.OpenOptions }
func (*profileCaptureProvider) IsAvailable() bool { return true }
func (p *profileCaptureProvider) Open(opts enginewebview.OpenOptions) (enginewebview.Page, error) {
p.options = append(p.options, opts)
return fakeRuntimeWebViewPage{}, nil
}
func TestWebViewProfileBoundToExtension(t *testing.T) {
root := t.TempDir()
d := &Downloader{cfg: &DownloaderConfig{StorageDir: root, DownloaderStoreConfig: &base.DownloaderStoreConfig{}}, configLock: &sync.RWMutex{}}
t.Cleanup(func() {
if d.webviewProxy != nil {
d.webviewProxy.Close()
}
})
provider := &profileCaptureProvider{}
for _, id := range []string{"extension-a", "extension-b", "extension-a", "../../escape"} {
opener := &extensionWebViewOpener{downloader: d, provider: provider, identity: id}
_, err := opener.Open(enginewebview.OpenOptions{ProfileID: "forged", DataPath: "/tmp/forged", ProxyURL: "http://wrong:80"})
if err != nil {
t.Fatal(err)
}
}
a, b, reopen := provider.options[0], provider.options[1], provider.options[2]
if a.ProfileID == b.ProfileID || a.DataPath == b.DataPath {
t.Fatal("different extensions shared browser storage")
}
if a.ProfileID != reopen.ProfileID || a.DataPath != reopen.DataPath {
t.Fatal("same extension lost profile identity")
}
for _, opts := range provider.options {
if filepath.Dir(opts.DataPath) != filepath.Join(root, "webview") {
t.Fatalf("profile escaped storage root: %s", opts.DataPath)
}
if opts.ProxyURL != d.webviewProxy.URL() && opts.ProxyURL != d.webviewProxy.SOCKSURL() {
t.Fatal("host proxy was overridden")
}
}
}