diff --git a/pkg/download/engine/fetch_integration_test.go b/pkg/download/engine/fetch_integration_test.go index dbe40afa..85ffe16b 100644 --- a/pkg/download/engine/fetch_integration_test.go +++ b/pkg/download/engine/fetch_integration_test.go @@ -105,6 +105,95 @@ func TestXMLHttpRequestUsesFetch(t *testing.T) { } } +func TestExtensionRequestsSendExplicitCookie(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + _, _ = w.Write([]byte(request.Header.Get("Cookie"))) + })) + t.Cleanup(server.Close) + + type requestTest struct { + name string + script string + } + tests := []requestTest{ + {"fetch record", `const response = await fetch(url, {headers: {Cookie: cookie}}); return response.text();`}, + {"fetch Headers", `const response = await fetch(url, {headers: new Headers({cookie})}); return response.text();`}, + {"fetch pairs", `const response = await fetch(url, {headers: [["cOoKiE", cookie]]}); return response.text();`}, + {"fetch Request clone", `const request = new Request(url, {headers: {Cookie: cookie}}); const response = await fetch(request.clone()); return response.text();`}, + {"fetch Request set", `const request = new Request(url); request.headers.set("Cookie", cookie); const response = await fetch(request); return response.text();`}, + {"fetch Request append", `const request = new Request(url); request.headers.append("Cookie", cookie); const response = await fetch(request); return response.text();`}, + {"fetch no-cors mutations", `const request = new Request(url, {mode: "no-cors"}); request.headers.set("Cookie", "removed=1"); request.headers.delete("COOKIE"); if (request.headers.has("cookie")) throw new Error("Cookie was not deleted"); request.headers.append("Cookie", cookie); const response = await fetch(request.clone()); return response.text();`}, + } + for _, mode := range []string{"cors", "same-origin", "no-cors"} { + for _, credentials := range []string{"omit", "same-origin", "include"} { + tests = append(tests, requestTest{ + name: "fetch " + mode + " " + credentials, + script: fmt.Sprintf(`const response = await fetch(url, {headers: {Cookie: cookie}, mode: %q, credentials: %q}); return response.text();`, mode, credentials), + }) + } + } + for _, withCredentials := range []bool{false, true} { + tests = append(tests, requestTest{ + name: fmt.Sprintf("XHR withCredentials=%t", withCredentials), + script: fmt.Sprintf(`return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open("GET", url); + xhr.withCredentials = %t; + xhr.setRequestHeader("cOoKiE", cookie); + xhr.onload = () => resolve(xhr.responseText); + xhr.onerror = () => reject(new Error("XHR failed")); + xhr.send(); + });`, withCredentials), + }) + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + runtime := engine.NewEngine(nil) + t.Cleanup(runtime.Close) + const cookie = "sid=manual; token=provided" + value, err := runtime.RunString(fmt.Sprintf(`(async () => { + const url = %q; + const cookie = %q; + %s + })()`, server.URL, cookie, test.script)) + if err != nil { + t.Fatal(err) + } + if value != cookie { + t.Fatalf("server received Cookie %q, want %q", value, cookie) + } + }) + } +} + +func TestFetchExplicitCookieWithCookieJar(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) { + if request.URL.Path == "/seed" { + http.SetCookie(w, &http.Cookie{Name: "stored", Value: "jar", Path: "/"}) + } + _, _ = w.Write([]byte(request.Header.Get("Cookie"))) + })) + t.Cleanup(server.Close) + runtime := engine.NewEngine(nil) + t.Cleanup(runtime.Close) + value, err := runtime.RunString(fmt.Sprintf(`(async () => { + const url = %q; + await (await fetch(url + "/seed")).text(); + const automatic = await (await fetch(url)).text(); + const combined = await (await fetch(url, {headers: {Cookie: "manual=provided"}})).text(); + const omitted = await (await fetch(url, {credentials: "omit", headers: {Cookie: "manual=provided"}})).text(); + const empty = await (await fetch(url, {credentials: "omit"})).text(); + return JSON.stringify({automatic, combined, omitted, empty}); + })()`, server.URL)) + if err != nil { + t.Fatal(err) + } + const want = `{"automatic":"stored=jar","combined":"manual=provided; stored=jar","omitted":"manual=provided","empty":""}` + if value != want { + t.Fatalf("unexpected CookieJar result: got %#v want %s", value, want) + } +} + func TestFetchResponseAndRequestCloneHaveIndependentBodies(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { if request.Method == http.MethodPost { diff --git a/pkg/download/engine/inject/fetch/fetch.js b/pkg/download/engine/inject/fetch/fetch.js index b49ef59f..100fd7d2 100644 --- a/pkg/download/engine/inject/fetch/fetch.js +++ b/pkg/download/engine/inject/fetch/fetch.js @@ -195,6 +195,9 @@ function blockedByGuard(headers, name, value) { if (headers._guard === "immutable") throw new TypeError("Headers are immutable"); + // Extensions are privileged HTTP clients and may supply their own cookies, + // including in no-cors mode. Keep browser guards for other header names. + if (name === "cookie" && (headers._guard === "request" || headers._guard === "request-no-cors")) return false; if (headers._guard === "request") return forbiddenRequestHeader(name, value); if (headers._guard === "request-no-cors") { return forbiddenRequestHeader(name, value) || !noCORSSafelistedRequestHeader(name, value); @@ -244,7 +247,7 @@ delete(name) { name = normalizeHeaderName(name); if (this._guard === "immutable") throw new TypeError("Headers are immutable"); - if (this._guard === "request-no-cors") { + if (this._guard === "request-no-cors" && name !== "cookie") { if (forbiddenRequestHeader(name, "") || !["accept", "accept-language", "content-language", "content-type", "range"].includes(name)) return; this._values.delete(name); return; diff --git a/pkg/download/engine/testdata/wpt/UPSTREAM.md b/pkg/download/engine/testdata/wpt/UPSTREAM.md index e858ecb9..2e14b990 100644 --- a/pkg/download/engine/testdata/wpt/UPSTREAM.md +++ b/pkg/download/engine/testdata/wpt/UPSTREAM.md @@ -19,10 +19,17 @@ a claim that Gopeed is a browser. Browser-only suites that require a document, CORS origin enforcement or preflight, CSP, Mixed Content, Service Workers, browser HTTP cache, navigation, browser authentication UI, or WPT's multi-origin server infrastructure are intentionally excluded. Gopeed also -keeps one extension-specific behavior: `redirect: "manual"` exposes the HTTP +keeps extension-specific behavior: `redirect: "manual"` exposes the HTTP redirect response so download extensions can inspect `Location`, instead of returning a browser `opaqueredirect` response. +Extensions may also supply an explicit `Cookie` request header, including in +`no-cors` mode. The runner excludes the single upstream assertion that treats +`Cookie` as an invalid request header; Gopeed integration tests instead verify +that fetch and XMLHttpRequest send it to the server. `credentials: "omit"` +disables automatic CookieJar cookies but preserves an explicit header. Other +request header guards remain covered by the unchanged upstream tests. + `wpt_harness.js` is a small Goja adapter maintained by Gopeed; it is not copied from WPT. Test logic below `fetch/` is unchanged; the checked-in copy may only normalize a final newline. diff --git a/pkg/download/engine/wpt_fetch_test.go b/pkg/download/engine/wpt_fetch_test.go index 86111d10..f929f598 100644 --- a/pkg/download/engine/wpt_fetch_test.go +++ b/pkg/download/engine/wpt_fetch_test.go @@ -110,7 +110,19 @@ func runWPTFile(t *testing.T, files embed.FS, name, setup string) { } runtime := engine.NewEngine(nil) t.Cleanup(runtime.Close) - value, err := runtime.RunString(setup + "\n" + wptHarness + "\n" + wptFetchUtils + "\n" + wptRequestError + "\n" + string(source) + "\n__wptFinish();") + adaptation := "" + if name == "testdata/wpt/fetch/api/request/request-headers.any.js" { + // Gopeed extensions intentionally allow explicit Cookie request headers. + // Keep the upstream file unchanged and exclude only its browser restriction. + adaptation = ` + const upstreamTest = globalThis.test; + globalThis.test = function (callback, name) { + if (name === 'Adding invalid request header "Cookie: KO"') return; + upstreamTest(callback, name); + }; + ` + } + value, err := runtime.RunString(setup + "\n" + wptHarness + "\n" + wptFetchUtils + "\n" + wptRequestError + "\n" + adaptation + "\n" + string(source) + "\n__wptFinish();") if err != nil { t.Fatal(err) }