From a79e9ea16576f77e218cf15c8df053ca1816f3d5 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 18 Jul 2026 02:49:48 +0900 Subject: [PATCH 1/7] =?UTF-8?q?=F0=9F=90=9B=20=E4=BF=AE=E5=A4=8D=20GM=5Fxm?= =?UTF-8?q?lhttpRequest=20=E8=87=AA=E5=AE=9A=E4=B9=89=20cookie=20=E8=BF=BD?= =?UTF-8?q?=E5=8A=A0=E8=80=8C=E9=9D=9E=E8=A6=86=E7=9B=96=E5=90=8C=E5=90=8D?= =?UTF-8?q?=20cookie?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TM兼容: 脚本传入的 cookie 参数(非 anonymous)此前会与浏览器已存储的同名 cookie 直接拼接发送(如 data=1; data=2;),与 Tampermonkey 的覆盖语义不一致 (tampermonkey/tampermonkey#2754)。改为按 cookie 名称合并:脚本自定义值覆盖 同名的已有 cookie,不同名的 cookie 全部保留,避免重蹈 TM 修复该问题时引入的 多 cookie 截断回归 (tampermonkey/tampermonkey#2829)。 Co-Authored-By: Claude Sonnet 5 --- .../service_worker/gm_api/gm_api.test.ts | 40 +++++++++++++++- .../service/service_worker/gm_api/gm_api.ts | 48 ++++++++++++++----- 2 files changed, 76 insertions(+), 12 deletions(-) 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..8d2281266 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,39 @@ 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(""); + }); +}); + 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..c6c965d3b 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,38 @@ export const checkHasUnsafeHeaders = (key: string) => { return false; }; +/** + * 合并脚本自定义 cookie 与网站本身存储的 cookie,供 GM_xmlhttpRequest 非 anonymous 请求使用。 + * TM兼容: 同名 cookie 以脚本自定义值为准(覆盖而非追加),不同名的 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 names = 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; + names.add(name); + parts.push(part); + } + } + if (storedCookies?.length) { + for (const { name, value } of storedCookies) { + if (!names.has(name)) { + parts.push(`${name}=${value}`); + } + } + } + return parts.join("; "); +}; + export enum ConnectMatch { NONE = 0, // 没有匹配 ALL = 1, // 遇到 "*" 通配符 @@ -726,12 +758,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 +776,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; } } From 8d42a55bff3792eb7b252fa8fe25b222c33e375e Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 18 Jul 2026 02:59:34 +0900 Subject: [PATCH 2/7] =?UTF-8?q?=F0=9F=90=9B=20=E4=BF=AE=E5=A4=8D=20mergeCo?= =?UTF-8?q?okieHeader=20=E5=9C=A8=E5=90=8C=E5=90=8D=E5=A4=9A=E5=80=BC=20co?= =?UTF-8?q?okie=20=E6=97=B6=E7=9A=84=E8=A6=86=E7=9B=96=E5=88=A4=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cookie 名称在规范上允许因 domain/path 不同而以多值形式共存(例如同一请求 URL 同时匹配到两个不同 scope、但同名的已有 cookie)。此前的覆盖逻辑只要 脚本自定义了该名称就会丢弃全部已有同名值,会把这种合法的多值场景错误地 压缩成单一值。 改为:仅当已有同名 cookie 唯一存在一个时才视为可被脚本自定义值覆盖; 存在多个同名值时无法判断脚本想覆盖哪一个,改为全部保留、脚本自定义值 附加在前面,不丢弃任何一个已有值。 Co-Authored-By: Claude Sonnet 5 --- .../service_worker/gm_api/gm_api.test.ts | 10 ++++++++++ .../service/service_worker/gm_api/gm_api.ts | 19 ++++++++++++++----- 2 files changed, 24 insertions(+), 5 deletions(-) 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 8d2281266..0005ad256 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 @@ -176,6 +176,16 @@ describe.concurrent("mergeCookieHeader(GM_xmlhttpRequest 非 anonymous 的 coo 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; attr1=old1; attr1=old2"); + }); }); describe.concurrent("getExtensionSiteAccessOriginPattern", () => { 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 c6c965d3b..9ddc34a81 100644 --- a/src/app/service/service_worker/gm_api/gm_api.ts +++ b/src/app/service/service_worker/gm_api/gm_api.ts @@ -203,15 +203,19 @@ export const checkHasUnsafeHeaders = (key: string) => { /** * 合并脚本自定义 cookie 与网站本身存储的 cookie,供 GM_xmlhttpRequest 非 anonymous 请求使用。 - * TM兼容: 同名 cookie 以脚本自定义值为准(覆盖而非追加),不同名的 cookie 全部保留。 - * @link https://github.com/Tampermonkey/tampermonkey/issues/2754 自定义 cookie 应覆盖同名的已有 cookie + * TM兼容: + * - 同名的已有 cookie 只有唯一一个时,以脚本自定义值覆盖(而非追加)。 + * - 同名的已有 cookie 存在多个时(例如不同 domain/path 的多值 cookie),无法判断脚本想覆盖哪一个, + * 故全部保留,脚本自定义值改为附加,不丢弃任何一个已有值。 + * - 不同名的 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 names = new Set(); + const customNames = new Set(); const parts: string[] = []; if (customCookie) { for (const rawPart of customCookie.split(";")) { @@ -219,13 +223,18 @@ export const mergeCookieHeader = ( if (!part) continue; const name = part.split("=", 1)[0].trim(); if (!name) continue; - names.add(name); + customNames.add(name); parts.push(part); } } if (storedCookies?.length) { + const nameCounts = new Map(); + for (const { name } of storedCookies) { + nameCounts.set(name, (nameCounts.get(name) || 0) + 1); + } for (const { name, value } of storedCookies) { - if (!names.has(name)) { + const isUniqueOverride = customNames.has(name) && nameCounts.get(name) === 1; + if (!isUniqueOverride) { parts.push(`${name}=${value}`); } } From b05805bc2e9d3fc519cc55480eb281e709ec302d Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 18 Jul 2026 03:03:10 +0900 Subject: [PATCH 3/7] =?UTF-8?q?=F0=9F=90=9B=20=E7=AE=80=E5=8C=96=20mergeCo?= =?UTF-8?q?okieHeader=EF=BC=9A=E4=BB=A5=E5=90=8D=E7=A7=B0=E6=98=AF?= =?UTF-8?q?=E5=90=A6=E8=A2=AB=E8=84=9A=E6=9C=AC=E6=8C=87=E5=AE=9A=E4=B8=BA?= =?UTF-8?q?=E5=94=AF=E4=B8=80=E5=88=A4=E6=96=AD=E4=BE=9D=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 改回更简单直接的规则:某个 cookie 名称只要被脚本的 cookie 参数指定, 就完全覆盖浏览器该名称下的全部已有值(无论原本是 1 个还是多个); 未被指定的名称则完全保留原样(无论是 0 个、1 个还是多个)。脚本指定 的值本身也可以是同名多值,原样保留、不做去重。 上一版按「已有同名 cookie 是否恰好唯一一个」区分覆盖/附加的做法过于 复杂,且与「脚本明确指定即完全接管该名称」的直觉不符。 Co-Authored-By: Claude Sonnet 5 --- .../service_worker/gm_api/gm_api.test.ts | 25 ++++++++++++++++--- .../service/service_worker/gm_api/gm_api.ts | 16 ++++-------- 2 files changed, 26 insertions(+), 15 deletions(-) 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 0005ad256..f1698ea8b 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 @@ -177,14 +177,31 @@ describe.concurrent("mergeCookieHeader(GM_xmlhttpRequest 非 anonymous 的 coo expect(mergeCookieHeader("", undefined)).toBe(""); }); - it.concurrent("已有同名 cookie 存在多个值时(如不同 domain/path),脚本自定义值应附加而非覆盖任何一个", () => { - // cookie 名称在规范上允许因 domain/path 不同而以多值形式共存,此时无法判断脚本想覆盖哪一个, - // 故全部保留已有值,脚本自定义值改为附加 + 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; attr1=old1; attr1=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"); }); }); 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 9ddc34a81..99a5e2b68 100644 --- a/src/app/service/service_worker/gm_api/gm_api.ts +++ b/src/app/service/service_worker/gm_api/gm_api.ts @@ -204,11 +204,10 @@ export const checkHasUnsafeHeaders = (key: string) => { /** * 合并脚本自定义 cookie 与网站本身存储的 cookie,供 GM_xmlhttpRequest 非 anonymous 请求使用。 * TM兼容: - * - 同名的已有 cookie 只有唯一一个时,以脚本自定义值覆盖(而非追加)。 - * - 同名的已有 cookie 存在多个时(例如不同 domain/path 的多值 cookie),无法判断脚本想覆盖哪一个, - * 故全部保留,脚本自定义值改为附加,不丢弃任何一个已有值。 - * - 不同名的 cookie 全部保留,不因覆盖逻辑被截断。 - * @link https://github.com/Tampermonkey/tampermonkey/issues/2754 自定义 cookie 应覆盖同名的已有 cookie(唯一时) + * - 某个 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 = ( @@ -228,13 +227,8 @@ export const mergeCookieHeader = ( } } if (storedCookies?.length) { - const nameCounts = new Map(); - for (const { name } of storedCookies) { - nameCounts.set(name, (nameCounts.get(name) || 0) + 1); - } for (const { name, value } of storedCookies) { - const isUniqueOverride = customNames.has(name) && nameCounts.get(name) === 1; - if (!isUniqueOverride) { + if (!customNames.has(name)) { parts.push(`${name}=${value}`); } } From e3b2a7ddf885369d294dac805379271052fde7b8 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 18 Jul 2026 03:07:23 +0900 Subject: [PATCH 4/7] =?UTF-8?q?=E2=9C=85=20=E4=B8=BA=20mergeCookieHeader?= =?UTF-8?q?=20=E6=B7=BB=E5=8A=A0=E8=84=9A=E6=9C=AC=C3=97=E6=B5=8F=E8=A7=88?= =?UTF-8?q?=E5=99=A8=E7=8A=B6=E6=80=81=E7=9A=84=E5=AE=8C=E6=95=B4=203x3=20?= =?UTF-8?q?=E7=BB=84=E5=90=88=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 针对单一 cookie 名称,脚本指定状态(未指定/单一值/多个值)与浏览器已有 状态(无/单个/多个)各有 3 种,共 3×3=9 种组合,逐一断言覆盖/保留行为 符合预期规则,避免遗漏边界组合。 Co-Authored-By: Claude Sonnet 5 --- .../service_worker/gm_api/gm_api.test.ts | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) 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 f1698ea8b..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 @@ -205,6 +205,48 @@ describe.concurrent("mergeCookieHeader(GM_xmlhttpRequest 非 anonymous 的 coo }); }); +// 针对单一 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/*"); From 53e88d79995041a9e956bafec829cee5b66b100b Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 18 Jul 2026 03:49:32 +0900 Subject: [PATCH 5/7] =?UTF-8?q?=E2=9C=85=20=E6=96=B0=E5=A2=9E=20GM=5Fxmlht?= =?UTF-8?q?tpRequest=20cookie=20=E8=A6=86=E7=9B=96=E8=A1=8C=E4=B8=BA?= =?UTF-8?q?=E7=9A=84=E6=89=8B=E5=8A=A8/=E6=9C=BA=E5=88=B6=E5=8C=96?= =?UTF-8?q?=E9=AA=8C=E8=AF=81=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 沿用 example/tests/inject_content_test.js 的自动执行 + console.log 汇总 (总计/通过/失败)风格,支持机制化检查(docs/verification.md 描述的 in-page self-test pattern)。 覆盖: - tampermonkey/tampermonkey#2754 的字面复现(data=1 被 cookie: 'data=2' 完全覆盖,而非拼接成 data=1; data=2) - tampermonkey/tampermonkey#2829 的字面复现(cookie: 'data1=1; data2=2' 两个不同名 cookie 都应送出,不应被截断成只剩 data1) - 浏览器已有状态(无/单值/同名多值)× 脚本指定状态(未指定/单值/多值) 的完整 3x3 矩阵,通过 document.cookie 以不同 path 属性制造同名多值 cookie 来验证,并对照 gm_api.test.ts 中的 mergeCookieHeader 单元测试 Testing source references: https://github.com/scriptscat/scriptcat/pull/1604#issuecomment-5006347298 Co-Authored-By: Claude Sonnet 5 --- example/tests/gm_xhr_cookie_test.js | 205 ++++++++++++++++++++++++++++ 1 file changed, 205 insertions(+) create mode 100644 example/tests/gm_xhr_cookie_test.js diff --git a/example/tests/gm_xhr_cookie_test.js b/example/tests/gm_xhr_cookie_test.js new file mode 100644 index 000000000..780409b88 --- /dev/null +++ b/example/tests/gm_xhr_cookie_test.js @@ -0,0 +1,205 @@ +// ==UserScript== +// @name GM_xmlhttpRequest cookie 覆盖测试 - tampermonkey/tampermonkey#2754 #2829 +// @namespace tm-gmxhr-cookie-test +// @version 1.0.0 +// @description 验证 GM_xmlhttpRequest 的 cookie 参数语义:脚本指定的名称完全覆盖,未指定的名称原样保留(含同名多值场景) +// @match https://httpbun.com/*?GM_XHR_COOKIE_TEST_SC +// @grant GM_xmlhttpRequest +// @connect httpbun.com +// @noframes +// ==/UserScript== + +(async function () { + "use strict"; + + console.log("%c=== GM_xmlhttpRequest cookie 覆盖测试开始 ===", "color: blue; font-size: 16px; font-weight: bold;"); + + const HB = "https://httpbun.com"; + + 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: (res) => resolve(res), + onerror: (res) => reject(res), + }); + }); + } + + // 解析 Cookie 请求头字符串为 name -> value[] 的多重映射(同名多值时保留全部,不假设顺序) + function parseCookieMultiMap(cookieHeader) { + const map = new Map(); + for (const part of `${cookieHeader || ""}`.split(";")) { + const trimmed = part.trim(); + if (!trimmed) continue; + const idx = trimmed.indexOf("="); + if (idx === -1) continue; + const name = trimmed.slice(0, idx).trim(); + const value = trimmed.slice(idx + 1).trim(); + 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://httpbun.com/headers 这个请求 + const ROOT = "/"; + const SUB = "/headers"; + function setCookie(name, value, path) { + document.cookie = `${name}=${value}; path=${path};`; + } + function clearCookie(name, path) { + document.cookie = `${name}=; path=${path}; max-age=0;`; + } + + // 测试用的 cookie 名称:mBS,B = 浏览器已有同名 cookie 数量(0/1/2),S = 脚本 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("data1", ROOT); + clearCookie("data2", ROOT); + } + + 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(); + + // ============ TM #2754 复现:同名 cookie 应覆盖而非追加 ============ + 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); + const res = await gmRequest({ method: "GET", url: `${HB}/headers`, cookie: "data=2" }); + const body = JSON.parse(res.responseText); + const cookieHeader = body.headers.Cookie || body.headers.cookie; + const map = parseCookieMultiMap(cookieHeader); + assertCookieValues(map, "data", ["2"], "data 应只保留脚本指定的值"); + clearCookie("data", ROOT); + }); + + // ============ TM #2829 复现:多个不同名 cookie 不应被截断 ============ + console.log("\n%c--- TM #2829: 多个不同名 cookie 不应被截断 ---", "color: orange; font-weight: bold;"); + + await test('GM_xhr cookie: "data1=1; data2=2" 应两者都送出,而非只剩第一个', async () => { + const res = await gmRequest({ method: "GET", url: `${HB}/headers`, cookie: "data1=1; data2=2" }); + const body = JSON.parse(res.responseText); + const cookieHeader = body.headers.Cookie || body.headers.cookie; + const map = parseCookieMultiMap(cookieHeader); + assertCookieValues(map, "data1", ["1"], "data1 应存在"); + assertCookieValues(map, "data2", ["2"], "data2 不应被截断丢失"); + }); + + // ============ 完整矩阵:浏览器已有状态(0/1/2) × 脚本指定状态(0/1/2) ============ + console.log("\n%c--- 完整矩阵:浏览器已有(0/1/2) × 脚本指定(0/1/2) ---", "color: orange; font-weight: bold;"); + + let lastCookieMap = new Map(); + + 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 res = await gmRequest({ method: "GET", url: `${HB}/headers`, cookie: customCookie }); + const body = JSON.parse(res.responseText); + const cookieHeader = body.headers.Cookie || body.headers.cookie; + lastCookieMap = parseCookieMultiMap(cookieHeader); + assertTrue(lastCookieMap.size > 0, "应收到 Cookie header"); + }); + + 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"]); + }); + + 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;"); + } +})(); From ae9601f32c15827836900a18e0ffc2890c396af0 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 18 Jul 2026 04:04:39 +0900 Subject: [PATCH 6/7] Use `oof.ooo` for this testing. --- example/tests/gm_xhr_cookie_test.js | 480 ++++++++++++++++++++++------ 1 file changed, 386 insertions(+), 94 deletions(-) diff --git a/example/tests/gm_xhr_cookie_test.js b/example/tests/gm_xhr_cookie_test.js index 780409b88..a429f30d0 100644 --- a/example/tests/gm_xhr_cookie_test.js +++ b/example/tests/gm_xhr_cookie_test.js @@ -1,25 +1,33 @@ // ==UserScript== // @name GM_xmlhttpRequest cookie 覆盖测试 - tampermonkey/tampermonkey#2754 #2829 // @namespace tm-gmxhr-cookie-test -// @version 1.0.0 +// @version 1.0.2 // @description 验证 GM_xmlhttpRequest 的 cookie 参数语义:脚本指定的名称完全覆盖,未指定的名称原样保留(含同名多值场景) -// @match https://httpbun.com/*?GM_XHR_COOKIE_TEST_SC +// @match https://oof.ooo/*?GM_XHR_COOKIE_TEST_SC // @grant GM_xmlhttpRequest -// @connect httpbun.com +// @connect oof.ooo // @noframes // ==/UserScript== (async function () { "use strict"; - console.log("%c=== GM_xmlhttpRequest cookie 覆盖测试开始 ===", "color: blue; font-size: 16px; font-weight: bold;"); + console.log( + "%c=== GM_xmlhttpRequest cookie 覆盖测试开始 ===", + "color: blue; font-size: 16px; font-weight: bold;" + ); - const HB = "https://httpbun.com"; + const OOF = "https://oof.ooo"; - const testResults = { passed: 0, failed: 0, total: 0 }; + const testResults = { + passed: 0, + failed: 0, + total: 0, + }; async function test(name, fn) { testResults.total++; + try { await fn(); testResults.passed++; @@ -34,8 +42,15 @@ function assert(expected, actual, message) { if (expected !== actual) { - const valueInfo = `期望 ${JSON.stringify(expected)}, 实际 ${JSON.stringify(actual)}`; - throw new Error(message ? `${message} - ${valueInfo}` : `断言失败: ${valueInfo}`); + const valueInfo = + `期望 ${JSON.stringify(expected)}, ` + + `实际 ${JSON.stringify(actual)}`; + + throw new Error( + message + ? `${message} - ${valueInfo}` + : `断言失败: ${valueInfo}` + ); } } @@ -49,69 +64,209 @@ return new Promise((resolve, reject) => { GM_xmlhttpRequest({ ...details, - onload: (res) => resolve(res), - onerror: (res) => reject(res), + + 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 请求超时")); + }, }); }); } - // 解析 Cookie 请求头字符串为 name -> value[] 的多重映射(同名多值时保留全部,不假设顺序) + /** + * 解析 /headers 响应。 + */ + function getResponseHeadersBody(response) { + let body; + + try { + body = JSON.parse(response.responseText); + } catch (error) { + throw new Error( + `oof.ooo /headers 返回的内容不是有效 JSON: ${response.responseText}` + ); + } + + if (!body || typeof body !== "object" || Array.isArray(body)) { + throw new Error( + `oof.ooo /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 `${cookieHeader || ""}`.split(";")) { + + for (const part of String(cookieHeader || "").split(";")) { const trimmed = part.trim(); - if (!trimmed) continue; - const idx = trimmed.indexOf("="); - if (idx === -1) continue; - const name = trimmed.slice(0, idx).trim(); - const value = trimmed.slice(idx + 1).trim(); - if (!map.has(name)) map.set(name, []); + + 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}" 的值集合`); + + assert( + JSON.stringify(expected), + JSON.stringify(actual), + message || `cookie "${name}" 的值集合` + ); } // ---------- Cookie 辅助函数 ---------- - // 借由不同的 path 属性在同一名称下制造「多值」cookie:浏览器规范允许同名 cookie 因 path 不同而共存, - // 且 path=/ 与 path=/headers 都会匹配到 https://httpbun.com/headers 这个请求 + // + // 借由不同的 path 属性,在同一名称下制造多值 Cookie。 + // 浏览器允许同名 Cookie 因 path 不同而共存。 + // + // path=/ 和 path=/headers 都会匹配: + // https://oof.ooo/headers const ROOT = "/"; const SUB = "/headers"; + function setCookie(name, value, path) { - document.cookie = `${name}=${value}; path=${path};`; + document.cookie = `${name}=${value}; path=${path}; SameSite=Lax`; } + function clearCookie(name, path) { - document.cookie = `${name}=; path=${path}; max-age=0;`; + document.cookie = + `${name}=; path=${path}; max-age=0; SameSite=Lax`; } - // 测试用的 cookie 名称:mBS,B = 浏览器已有同名 cookie 数量(0/1/2),S = 脚本 cookie 参数指定的值数量(0=未指定/1/2) - const NAMES = ["m00", "m01", "m02", "m10", "m11", "m12", "m20", "m21", "m22"]; + // 测试 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=/ 设置 + // 浏览器已有 1 个同名值:只在 path=/ 设置。 setCookie("m10", "old", ROOT); setCookie("m11", "old", ROOT); setCookie("m12", "old", ROOT); - // 浏览器已有 2 个同名值:path=/ 与 path=/headers 各设置一个 + + // 浏览器已有 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); } @@ -119,87 +274,224 @@ resetCookies(); setupCookies(); - // ============ TM #2754 复现:同名 cookie 应覆盖而非追加 ============ - console.log("\n%c--- TM #2754: 同名 cookie 应覆盖而非追加 ---", "color: orange; font-weight: bold;"); + 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); - const res = await gmRequest({ method: "GET", url: `${HB}/headers`, cookie: "data=2" }); - const body = JSON.parse(res.responseText); - const cookieHeader = body.headers.Cookie || body.headers.cookie; - const map = parseCookieMultiMap(cookieHeader); - assertCookieValues(map, "data", ["2"], "data 应只保留脚本指定的值"); - clearCookie("data", ROOT); - }); + await test( + "document.cookie=data=1,GM_xhr cookie: data=2 时应只送出 data=2", + async () => { + setCookie("data", "1", ROOT); - // ============ TM #2829 复现:多个不同名 cookie 不应被截断 ============ - console.log("\n%c--- TM #2829: 多个不同名 cookie 不应被截断 ---", "color: orange; font-weight: bold;"); + try { + const response = await gmRequest({ + method: "GET", + url: `${OOF}/headers`, + cookie: "data=2", + timeout: 15000, + }); - await test('GM_xhr cookie: "data1=1; data2=2" 应两者都送出,而非只剩第一个', async () => { - const res = await gmRequest({ method: "GET", url: `${HB}/headers`, cookie: "data1=1; data2=2" }); - const body = JSON.parse(res.responseText); - const cookieHeader = body.headers.Cookie || body.headers.cookie; - const map = parseCookieMultiMap(cookieHeader); - assertCookieValues(map, "data1", ["1"], "data1 应存在"); - assertCookieValues(map, "data2", ["2"], "data2 不应被截断丢失"); - }); + const cookieHeader = getCookieHeader(response); + const cookieMap = parseCookieMultiMap(cookieHeader); - // ============ 完整矩阵:浏览器已有状态(0/1/2) × 脚本指定状态(0/1/2) ============ - console.log("\n%c--- 完整矩阵:浏览器已有(0/1/2) × 脚本指定(0/1/2) ---", "color: orange; font-weight: bold;"); + 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: `${OOF}/headers`, + cookie: "data1=1; data2=2", + timeout: 15000, + }); + + const cookieHeader = getCookieHeader(response); + const cookieMap = parseCookieMultiMap(cookieHeader); - let lastCookieMap = new Map(); + assertCookieValues( + cookieMap, + "data1", + ["1"], + "data1 应存在" + ); - 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( - "; " + assertCookieValues( + cookieMap, + "data2", + ["2"], + "data2 不应被截断丢失" + ); + } ); - const res = await gmRequest({ method: "GET", url: `${HB}/headers`, cookie: customCookie }); - const body = JSON.parse(res.responseText); - const cookieHeader = body.headers.Cookie || body.headers.cookie; - lastCookieMap = parseCookieMultiMap(cookieHeader); - assertTrue(lastCookieMap.size > 0, "应收到 Cookie header"); - }); - - 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"]); - }); - resetCookies(); + // ============ 完整矩阵 ============ + 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: `${OOF}/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;" + "\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;"); + console.log( + "%c🎉 所有测试通过!", + "color: green; font-size: 14px; font-weight: bold;" + ); } else { - console.log("%c⚠️ 部分测试失败,请检查上面的错误信息", "color: red; font-size: 14px; font-weight: bold;"); + console.log( + "%c⚠️ 部分测试失败,请检查上面的错误信息", + "color: red; font-size: 14px; font-weight: bold;" + ); } })(); From 98a0993d087df70ebe531f8387e3dfa4e338d636 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sat, 18 Jul 2026 06:36:42 +0900 Subject: [PATCH 7/7] change to mockhttp.org --- example/tests/gm_xhr_cookie_test.js | 102 ++++++++++++++++++++-------- 1 file changed, 75 insertions(+), 27 deletions(-) diff --git a/example/tests/gm_xhr_cookie_test.js b/example/tests/gm_xhr_cookie_test.js index a429f30d0..f473c66b0 100644 --- a/example/tests/gm_xhr_cookie_test.js +++ b/example/tests/gm_xhr_cookie_test.js @@ -1,11 +1,11 @@ // ==UserScript== // @name GM_xmlhttpRequest cookie 覆盖测试 - tampermonkey/tampermonkey#2754 #2829 // @namespace tm-gmxhr-cookie-test -// @version 1.0.2 +// @version 1.0.3 // @description 验证 GM_xmlhttpRequest 的 cookie 参数语义:脚本指定的名称完全覆盖,未指定的名称原样保留(含同名多值场景) -// @match https://oof.ooo/*?GM_XHR_COOKIE_TEST_SC +// @match https://mockhttp.org/*?GM_XHR_COOKIE_TEST_SC // @grant GM_xmlhttpRequest -// @connect oof.ooo +// @connect mockhttp.org // @noframes // ==/UserScript== @@ -17,7 +17,7 @@ "color: blue; font-size: 16px; font-weight: bold;" ); - const OOF = "https://oof.ooo"; + const MOCKHTTP = "https://mockhttp.org"; const testResults = { passed: 0, @@ -71,7 +71,9 @@ } else { reject( new Error( - `HTTP 请求失败: ${response.status} ${response.statusText || ""}` + `HTTP 请求失败: ${response.status} ${ + response.statusText || "" + }` ) ); } @@ -81,7 +83,9 @@ reject( new Error( `GM_xmlhttpRequest 网络错误: ${ - response?.error || response?.statusText || "未知错误" + response?.error || + response?.statusText || + "未知错误" }` ) ); @@ -95,7 +99,7 @@ } /** - * 解析 /headers 响应。 + * 解析 mockhttp.org/headers 响应。 */ function getResponseHeadersBody(response) { let body; @@ -104,13 +108,17 @@ body = JSON.parse(response.responseText); } catch (error) { throw new Error( - `oof.ooo /headers 返回的内容不是有效 JSON: ${response.responseText}` + `mockhttp.org/headers 返回的内容不是有效 JSON: ${ + response.responseText + }` ); } if (!body || typeof body !== "object" || Array.isArray(body)) { throw new Error( - `oof.ooo /headers 返回了非预期响应: ${response.responseText}` + `mockhttp.org/headers 返回了非预期响应: ${ + response.responseText + }` ); } @@ -139,7 +147,10 @@ function getCookieHeader(response) { const headers = getResponseHeadersBody(response); - const cookieHeader = getHeaderCaseInsensitive(headers, "cookie"); + const cookieHeader = getHeaderCaseInsensitive( + headers, + "cookie" + ); if (cookieHeader == null) { return ""; @@ -173,8 +184,13 @@ continue; } - const name = trimmed.slice(0, separatorIndex).trim(); - const value = trimmed.slice(separatorIndex + 1).trim(); + const name = trimmed + .slice(0, separatorIndex) + .trim(); + + const value = trimmed + .slice(separatorIndex + 1) + .trim(); if (!name) { continue; @@ -190,7 +206,12 @@ return map; } - function assertCookieValues(map, name, expectedValues, message) { + function assertCookieValues( + map, + name, + expectedValues, + message + ) { const actual = (map.get(name) || []).slice().sort(); const expected = expectedValues.slice().sort(); @@ -207,12 +228,13 @@ // 浏览器允许同名 Cookie 因 path 不同而共存。 // // path=/ 和 path=/headers 都会匹配: - // https://oof.ooo/headers + // https://mockhttp.org/headers const ROOT = "/"; const SUB = "/headers"; function setCookie(name, value, path) { - document.cookie = `${name}=${value}; path=${path}; SameSite=Lax`; + document.cookie = + `${name}=${value}; path=${path}; SameSite=Lax`; } function clearCookie(name, path) { @@ -289,13 +311,14 @@ try { const response = await gmRequest({ method: "GET", - url: `${OOF}/headers`, + url: `${MOCKHTTP}/headers`, cookie: "data=2", timeout: 15000, }); const cookieHeader = getCookieHeader(response); - const cookieMap = parseCookieMultiMap(cookieHeader); + const cookieMap = + parseCookieMultiMap(cookieHeader); assertCookieValues( cookieMap, @@ -320,13 +343,14 @@ async () => { const response = await gmRequest({ method: "GET", - url: `${OOF}/headers`, + url: `${MOCKHTTP}/headers`, cookie: "data1=1; data2=2", timeout: 15000, }); const cookieHeader = getCookieHeader(response); - const cookieMap = parseCookieMultiMap(cookieHeader); + const cookieMap = + parseCookieMultiMap(cookieHeader); assertCookieValues( cookieMap, @@ -369,17 +393,25 @@ const response = await gmRequest({ method: "GET", - url: `${OOF}/headers`, + url: `${MOCKHTTP}/headers`, cookie: customCookie, timeout: 15000, }); const cookieHeader = getCookieHeader(response); - lastCookieMap = parseCookieMultiMap(cookieHeader); + lastCookieMap = + parseCookieMultiMap(cookieHeader); - assertTrue(cookieHeader.length > 0, "应收到 Cookie header"); - assertTrue(lastCookieMap.size > 0, "Cookie header 应能成功解析"); + assertTrue( + cookieHeader.length > 0, + "应收到 Cookie header" + ); + + assertTrue( + lastCookieMap.size > 0, + "Cookie header 应能成功解析" + ); } ); @@ -394,7 +426,11 @@ await test( "m01:浏览器无、脚本指定单值 → 应为脚本值", () => { - assertCookieValues(lastCookieMap, "m01", ["new"]); + assertCookieValues( + lastCookieMap, + "m01", + ["new"] + ); } ); @@ -411,14 +447,22 @@ await test( "m10:浏览器单值、脚本未指定 → 应保留浏览器原值", () => { - assertCookieValues(lastCookieMap, "m10", ["old"]); + assertCookieValues( + lastCookieMap, + "m10", + ["old"] + ); } ); await test( "m11:浏览器单值、脚本指定单值 → 应覆盖为脚本值", () => { - assertCookieValues(lastCookieMap, "m11", ["new"]); + assertCookieValues( + lastCookieMap, + "m11", + ["new"] + ); } ); @@ -445,7 +489,11 @@ await test( "m21:浏览器多值、脚本指定单值 → 应完全覆盖为脚本单一值", () => { - assertCookieValues(lastCookieMap, "m21", ["new"]); + assertCookieValues( + lastCookieMap, + "m21", + ["new"] + ); } );