diff --git a/example/tests/gm_xhr_cookie_test.js b/example/tests/gm_xhr_cookie_test.js new file mode 100644 index 000000000..f473c66b0 --- /dev/null +++ b/example/tests/gm_xhr_cookie_test.js @@ -0,0 +1,545 @@ +// ==UserScript== +// @name GM_xmlhttpRequest cookie 覆盖测试 - tampermonkey/tampermonkey#2754 #2829 +// @namespace tm-gmxhr-cookie-test +// @version 1.0.3 +// @description 验证 GM_xmlhttpRequest 的 cookie 参数语义:脚本指定的名称完全覆盖,未指定的名称原样保留(含同名多值场景) +// @match https://mockhttp.org/*?GM_XHR_COOKIE_TEST_SC +// @grant GM_xmlhttpRequest +// @connect mockhttp.org +// @noframes +// ==/UserScript== + +(async function () { + "use strict"; + + console.log( + "%c=== GM_xmlhttpRequest cookie 覆盖测试开始 ===", + "color: blue; font-size: 16px; font-weight: bold;" + ); + + const MOCKHTTP = "https://mockhttp.org"; + + const testResults = { + passed: 0, + failed: 0, + total: 0, + }; + + async function test(name, fn) { + testResults.total++; + + try { + await fn(); + testResults.passed++; + console.log(`%c✓ ${name}`, "color: green;"); + return true; + } catch (error) { + testResults.failed++; + console.error(`%c✗ ${name}`, "color: red;", error); + return false; + } + } + + function assert(expected, actual, message) { + if (expected !== actual) { + const valueInfo = + `期望 ${JSON.stringify(expected)}, ` + + `实际 ${JSON.stringify(actual)}`; + + throw new Error( + message + ? `${message} - ${valueInfo}` + : `断言失败: ${valueInfo}` + ); + } + } + + function assertTrue(condition, message) { + if (!condition) { + throw new Error(message || "断言失败: 期望条件为真"); + } + } + + function gmRequest(details) { + return new Promise((resolve, reject) => { + GM_xmlhttpRequest({ + ...details, + + onload: (response) => { + if (response.status >= 200 && response.status < 300) { + resolve(response); + } else { + reject( + new Error( + `HTTP 请求失败: ${response.status} ${ + response.statusText || "" + }` + ) + ); + } + }, + + onerror: (response) => { + reject( + new Error( + `GM_xmlhttpRequest 网络错误: ${ + response?.error || + response?.statusText || + "未知错误" + }` + ) + ); + }, + + ontimeout: () => { + reject(new Error("GM_xmlhttpRequest 请求超时")); + }, + }); + }); + } + + /** + * 解析 mockhttp.org/headers 响应。 + */ + function getResponseHeadersBody(response) { + let body; + + try { + body = JSON.parse(response.responseText); + } catch (error) { + throw new Error( + `mockhttp.org/headers 返回的内容不是有效 JSON: ${ + response.responseText + }` + ); + } + + if (!body || typeof body !== "object" || Array.isArray(body)) { + throw new Error( + `mockhttp.org/headers 返回了非预期响应: ${ + response.responseText + }` + ); + } + + if ( + body.headers && + typeof body.headers === "object" && + !Array.isArray(body.headers) + ) { + return body.headers; + } + + return body; + } + + function getHeaderCaseInsensitive(headers, headerName) { + const targetName = headerName.toLowerCase(); + + for (const [name, value] of Object.entries(headers)) { + if (name.toLowerCase() === targetName) { + return value; + } + } + + return undefined; + } + + function getCookieHeader(response) { + const headers = getResponseHeadersBody(response); + const cookieHeader = getHeaderCaseInsensitive( + headers, + "cookie" + ); + + if (cookieHeader == null) { + return ""; + } + + if (Array.isArray(cookieHeader)) { + return cookieHeader.join("; "); + } + + return String(cookieHeader); + } + + /** + * 解析 Cookie 请求头字符串为 name -> value[] 的多重映射。 + * + * 同名 Cookie 多值时保留全部值,不假设顺序。 + */ + function parseCookieMultiMap(cookieHeader) { + const map = new Map(); + + for (const part of String(cookieHeader || "").split(";")) { + const trimmed = part.trim(); + + if (!trimmed) { + continue; + } + + const separatorIndex = trimmed.indexOf("="); + + if (separatorIndex === -1) { + continue; + } + + const name = trimmed + .slice(0, separatorIndex) + .trim(); + + const value = trimmed + .slice(separatorIndex + 1) + .trim(); + + if (!name) { + continue; + } + + if (!map.has(name)) { + map.set(name, []); + } + + map.get(name).push(value); + } + + return map; + } + + function assertCookieValues( + map, + name, + expectedValues, + message + ) { + const actual = (map.get(name) || []).slice().sort(); + const expected = expectedValues.slice().sort(); + + assert( + JSON.stringify(expected), + JSON.stringify(actual), + message || `cookie "${name}" 的值集合` + ); + } + + // ---------- Cookie 辅助函数 ---------- + // + // 借由不同的 path 属性,在同一名称下制造多值 Cookie。 + // 浏览器允许同名 Cookie 因 path 不同而共存。 + // + // path=/ 和 path=/headers 都会匹配: + // https://mockhttp.org/headers + const ROOT = "/"; + const SUB = "/headers"; + + function setCookie(name, value, path) { + document.cookie = + `${name}=${value}; path=${path}; SameSite=Lax`; + } + + function clearCookie(name, path) { + document.cookie = + `${name}=; path=${path}; max-age=0; SameSite=Lax`; + } + + // 测试 Cookie 名称使用 mBS 格式: + // + // B = 浏览器已有的同名 Cookie 数量:0、1、2 + // S = GM_xmlhttpRequest cookie 参数指定的值数量: + // 0 表示未指定,1 表示单值,2 表示多值 + const NAMES = [ + "m00", + "m01", + "m02", + "m10", + "m11", + "m12", + "m20", + "m21", + "m22", + ]; + + function resetCookies() { + for (const name of NAMES) { + clearCookie(name, ROOT); + clearCookie(name, SUB); + } + + clearCookie("data", ROOT); + clearCookie("data", SUB); + + clearCookie("data1", ROOT); + clearCookie("data1", SUB); + + clearCookie("data2", ROOT); + clearCookie("data2", SUB); + } + + function setupCookies() { + // 浏览器已有 1 个同名值:只在 path=/ 设置。 + setCookie("m10", "old", ROOT); + setCookie("m11", "old", ROOT); + setCookie("m12", "old", ROOT); + + // 浏览器已有 2 个同名值: + // path=/ 与 path=/headers 各设置一个。 + setCookie("m20", "old1", ROOT); + setCookie("m20", "old2", SUB); + + setCookie("m21", "old1", ROOT); + setCookie("m21", "old2", SUB); + + setCookie("m22", "old1", ROOT); + setCookie("m22", "old2", SUB); + } + + resetCookies(); + setupCookies(); + + try { + // ============ TM #2754 复现 ============ + console.log( + "\n%c--- TM #2754: 同名 cookie 应覆盖而非追加 ---", + "color: orange; font-weight: bold;" + ); + + await test( + "document.cookie=data=1,GM_xhr cookie: data=2 时应只送出 data=2", + async () => { + setCookie("data", "1", ROOT); + + try { + const response = await gmRequest({ + method: "GET", + url: `${MOCKHTTP}/headers`, + cookie: "data=2", + timeout: 15000, + }); + + const cookieHeader = getCookieHeader(response); + const cookieMap = + parseCookieMultiMap(cookieHeader); + + assertCookieValues( + cookieMap, + "data", + ["2"], + "data 应只保留脚本指定的值" + ); + } finally { + clearCookie("data", ROOT); + } + } + ); + + // ============ TM #2829 复现 ============ + console.log( + "\n%c--- TM #2829: 多个不同名 cookie 不应被截断 ---", + "color: orange; font-weight: bold;" + ); + + await test( + 'GM_xhr cookie: "data1=1; data2=2" 应两者都送出,而非只剩第一个', + async () => { + const response = await gmRequest({ + method: "GET", + url: `${MOCKHTTP}/headers`, + cookie: "data1=1; data2=2", + timeout: 15000, + }); + + const cookieHeader = getCookieHeader(response); + const cookieMap = + parseCookieMultiMap(cookieHeader); + + assertCookieValues( + cookieMap, + "data1", + ["1"], + "data1 应存在" + ); + + assertCookieValues( + cookieMap, + "data2", + ["2"], + "data2 不应被截断丢失" + ); + } + ); + + // ============ 完整矩阵 ============ + console.log( + "\n%c--- 完整矩阵:浏览器已有(0/1/2) × 脚本指定(0/1/2) ---", + "color: orange; font-weight: bold;" + ); + + let lastCookieMap = null; + + const matrixRequestPassed = await test( + "发送带完整矩阵 cookie 参数的请求", + async () => { + const customCookie = [ + "m01=new", + "m02=new1", + "m02=new2", + "m11=new", + "m12=new1", + "m12=new2", + "m21=new", + "m22=new1", + "m22=new2", + ].join("; "); + + const response = await gmRequest({ + method: "GET", + url: `${MOCKHTTP}/headers`, + cookie: customCookie, + timeout: 15000, + }); + + const cookieHeader = getCookieHeader(response); + + lastCookieMap = + parseCookieMultiMap(cookieHeader); + + assertTrue( + cookieHeader.length > 0, + "应收到 Cookie header" + ); + + assertTrue( + lastCookieMap.size > 0, + "Cookie header 应能成功解析" + ); + } + ); + + if (matrixRequestPassed && lastCookieMap) { + await test( + "m00:浏览器无、脚本未指定 → 不应出现", + () => { + assertCookieValues(lastCookieMap, "m00", []); + } + ); + + await test( + "m01:浏览器无、脚本指定单值 → 应为脚本值", + () => { + assertCookieValues( + lastCookieMap, + "m01", + ["new"] + ); + } + ); + + await test( + "m02:浏览器无、脚本指定多值 → 应为脚本两个值", + () => { + assertCookieValues(lastCookieMap, "m02", [ + "new1", + "new2", + ]); + } + ); + + await test( + "m10:浏览器单值、脚本未指定 → 应保留浏览器原值", + () => { + assertCookieValues( + lastCookieMap, + "m10", + ["old"] + ); + } + ); + + await test( + "m11:浏览器单值、脚本指定单值 → 应覆盖为脚本值", + () => { + assertCookieValues( + lastCookieMap, + "m11", + ["new"] + ); + } + ); + + await test( + "m12:浏览器单值、脚本指定多值 → 应覆盖为脚本两个值", + () => { + assertCookieValues(lastCookieMap, "m12", [ + "new1", + "new2", + ]); + } + ); + + await test( + "m20:浏览器多值(同名不同path)、脚本未指定 → 应保留浏览器全部值", + () => { + assertCookieValues(lastCookieMap, "m20", [ + "old1", + "old2", + ]); + } + ); + + await test( + "m21:浏览器多值、脚本指定单值 → 应完全覆盖为脚本单一值", + () => { + assertCookieValues( + lastCookieMap, + "m21", + ["new"] + ); + } + ); + + await test( + "m22:浏览器多值、脚本指定多值 → 应完全覆盖为脚本两个值", + () => { + assertCookieValues(lastCookieMap, "m22", [ + "new1", + "new2", + ]); + } + ); + } else { + console.warn( + "%c完整矩阵请求失败,跳过依赖该请求结果的矩阵断言。", + "color: orange; font-weight: bold;" + ); + } + } finally { + resetCookies(); + } + + // ============ 输出测试结果 ============ + console.log( + "\n%c=== 测试完成 ===", + "color: blue; font-size: 16px; font-weight: bold;" + ); + + console.log( + `%c总计: ${testResults.total} | ` + + `通过: ${testResults.passed} | ` + + `失败: ${testResults.failed}`, + testResults.failed === 0 + ? "color: green; font-weight: bold;" + : "color: red; font-weight: bold;" + ); + + if (testResults.failed === 0) { + console.log( + "%c🎉 所有测试通过!", + "color: green; font-size: 14px; font-weight: bold;" + ); + } else { + console.log( + "%c⚠️ 部分测试失败,请检查上面的错误信息", + "color: red; font-size: 14px; font-weight: bold;" + ); + } +})(); diff --git a/src/app/service/service_worker/gm_api/gm_api.test.ts b/src/app/service/service_worker/gm_api/gm_api.test.ts index 04aafd41a..63b937997 100644 --- a/src/app/service/service_worker/gm_api/gm_api.test.ts +++ b/src/app/service/service_worker/gm_api/gm_api.test.ts @@ -1,7 +1,12 @@ import { describe, it, expect, vi } from "vitest"; import { type IGetSender } from "@Packages/message/server"; import { type ExtMessageSender } from "@Packages/message/types"; -import GMApi, { ConnectMatch, getConnectMatched, getExtensionSiteAccessOriginPattern } from "./gm_api"; +import GMApi, { + ConnectMatch, + getConnectMatched, + getExtensionSiteAccessOriginPattern, + mergeCookieHeader, +} from "./gm_api"; import { PermissionVerifyApiGet, type ConfirmParam } from "../permission_verify"; import type { GMApiRequest } from "../types"; // 触发所有 GM API 装饰器注册(与 gm_api.ts 中的 import 保持同步) @@ -140,6 +145,108 @@ describe("window.focus", () => { }); }); +describe.concurrent("mergeCookieHeader(GM_xmlhttpRequest 非 anonymous 的 cookie 合并)", () => { + it.concurrent("同名 cookie 应以脚本自定义值覆盖已有 cookie,而非追加(TM #2754)", () => { + const result = mergeCookieHeader("data=2", [{ name: "data", value: "1" }]); + expect(result).toBe("data=2"); + }); + + it.concurrent("不同名的已有 cookie 应全部保留,不因覆盖逻辑被截断(TM #2829)", () => { + const result = mergeCookieHeader(undefined, [ + { name: "data1", value: "1" }, + { name: "data2", value: "2" }, + ]); + expect(result).toBe("data1=1; data2=2"); + }); + + it.concurrent("同名覆盖与不同名保留应可同时生效", () => { + const result = mergeCookieHeader("data1=9", [ + { name: "data1", value: "1" }, + { name: "data2", value: "2" }, + ]); + expect(result).toBe("data1=9; data2=2"); + }); + + it.concurrent("脚本自定义多个 cookie 且无已有 cookie 时应原样透传", () => { + const result = mergeCookieHeader("data1=1; data2=2", []); + expect(result).toBe("data1=1; data2=2"); + }); + + it.concurrent("无自定义 cookie 也无已有 cookie 时应回传空字符串", () => { + expect(mergeCookieHeader(undefined, [])).toBe(""); + expect(mergeCookieHeader("", undefined)).toBe(""); + }); + + it.concurrent("已有同名 cookie 存在多个值时(如不同 domain/path),只要脚本指定了该名称就应全部覆盖", () => { + // cookie 名称在规范上允许因 domain/path 不同而以多值形式共存,但只要脚本明确指定了该名称, + // 意图就是完全接管该名称,浏览器原有的全部同名值都应被丢弃 + const result = mergeCookieHeader("attr1=new", [ + { name: "attr1", value: "old1" }, + { name: "attr1", value: "old2" }, + ]); + expect(result).toBe("attr1=new"); + }); + + it.concurrent("脚本指定的同名值本身也可以是多值,覆盖时应原样保留、不去重", () => { + const result = mergeCookieHeader("attr1=new1; attr1=new2", [ + { name: "attr1", value: "old1" }, + { name: "attr1", value: "old2" }, + ]); + expect(result).toBe("attr1=new1; attr1=new2"); + }); + + it.concurrent("未被脚本指定的 cookie 名称,无论浏览器原本是 0 个、1 个还是多个值都应保持不变", () => { + const result = mergeCookieHeader("other=x", [ + { name: "attr1", value: "old1" }, + { name: "attr1", value: "old2" }, + { name: "single", value: "s" }, + ]); + expect(result).toBe("other=x; attr1=old1; attr1=old2; single=s"); + }); +}); + +// 针对单一 cookie 名称 attr1:脚本是否指定(0/1/多个值)× 浏览器已有是否存在(0/1/多个值)共 3×3=9 种组合, +// 规则固定为:脚本指定则完全覆盖该名称(不论脚本给几个值、浏览器原本有几个值); +// 脚本未指定则完全保留浏览器原样(不论浏览器原本有几个值)。 +describe.concurrent("mergeCookieHeader 完整组合矩阵:脚本指定状态 × 浏览器已有状态(3×3=9)", () => { + const scriptCases = [ + { label: "脚本未指定 attr1", customCookie: undefined, expectedWhenNotOverridden: null }, + { label: "脚本指定 attr1 单一值", customCookie: "attr1=new", expectedWhenOverridden: "attr1=new" }, + { + label: "脚本指定 attr1 多个值", + customCookie: "attr1=new1; attr1=new2", + expectedWhenOverridden: "attr1=new1; attr1=new2", + }, + ] as const; + + const storedCases: { label: string; stored: { name: string; value: string }[]; expectedWhenKept: string }[] = [ + { label: "浏览器无 attr1", stored: [], expectedWhenKept: "" }, + { + label: "浏览器有 1 个 attr1", + stored: [{ name: "attr1", value: "old" }], + expectedWhenKept: "attr1=old", + }, + { + label: "浏览器有 2 个 attr1(同名多值,如不同 domain/path)", + stored: [ + { name: "attr1", value: "old1" }, + { name: "attr1", value: "old2" }, + ], + expectedWhenKept: "attr1=old1; attr1=old2", + }, + ]; + + for (const script of scriptCases) { + for (const stored of storedCases) { + it.concurrent(`${script.label} × ${stored.label}`, () => { + const result = mergeCookieHeader(script.customCookie, stored.stored); + const expected = "expectedWhenOverridden" in script ? script.expectedWhenOverridden : stored.expectedWhenKept; + expect(result).toBe(expected); + }); + } + } +}); + describe.concurrent("getExtensionSiteAccessOriginPattern", () => { it.concurrent("应生成不带端口的扩展站点访问权限 pattern", () => { expect(getExtensionSiteAccessOriginPattern(new URL("http://127.0.0.1:3000/get"))).toBe("http://127.0.0.1/*"); diff --git a/src/app/service/service_worker/gm_api/gm_api.ts b/src/app/service/service_worker/gm_api/gm_api.ts index d171c4421..99a5e2b68 100644 --- a/src/app/service/service_worker/gm_api/gm_api.ts +++ b/src/app/service/service_worker/gm_api/gm_api.ts @@ -201,6 +201,41 @@ export const checkHasUnsafeHeaders = (key: string) => { return false; }; +/** + * 合并脚本自定义 cookie 与网站本身存储的 cookie,供 GM_xmlhttpRequest 非 anonymous 请求使用。 + * TM兼容: + * - 某个 cookie 名称若未被脚本指定,保留浏览器该名称下原本的全部值(无论是 0 个、1 个还是多个,均不改动)。 + * - 某个 cookie 名称若被脚本指定,则完全以脚本指定的值覆盖该名称浏览器已有的全部值; + * 脚本指定的值本身也可以是同名多值,一并保留、不做去重。 + * @link https://github.com/Tampermonkey/tampermonkey/issues/2754 自定义 cookie 应覆盖同名的已有 cookie + * @link https://github.com/Tampermonkey/tampermonkey/issues/2829 覆盖逻辑不应截断其余的 cookie + */ +export const mergeCookieHeader = ( + customCookie: string | undefined, + storedCookies: { name: string; value: string }[] | undefined +): string => { + const customNames = new Set(); + const parts: string[] = []; + if (customCookie) { + for (const rawPart of customCookie.split(";")) { + const part = rawPart.trim(); + if (!part) continue; + const name = part.split("=", 1)[0].trim(); + if (!name) continue; + customNames.add(name); + parts.push(part); + } + } + if (storedCookies?.length) { + for (const { name, value } of storedCookies) { + if (!customNames.has(name)) { + parts.push(`${name}=${value}`); + } + } + } + return parts.join("; "); +}; + export enum ConnectMatch { NONE = 0, // 没有匹配 ALL = 1, // 遇到 "*" 通配符 @@ -726,12 +761,7 @@ export default class GMApi { }); } } else { - if (cookie) { - // 否则正常携带cookie header - headers["cookie"] = cookie; - } - - // 追加该网站本身存储的cookie + // 该网站本身存储的cookie const tabId = sender.getExtMessageSender().tabId; let storeId: string | undefined; if (tabId !== -1 && typeof tabId === "number") { @@ -749,11 +779,10 @@ export default class GMApi { partitionKey: stripUndefined(params.cookiePartition), }) ); - // 追加cookie - if (cookies?.length) { - const v = cookies.map((c) => `${c.name}=${c.value}`).join("; "); - const u = `${headers["cookie"] || ""}`.trim(); - headers["cookie"] = u ? `${u}${!u.endsWith(";") ? "; " : " "}${v}` : v; + // 合并cookie:脚本自定义的cookie覆盖同名的已有cookie,不同名的cookie全部保留 + const merged = mergeCookieHeader(cookie, cookies); + if (merged) { + headers["cookie"] = merged; } }