diff --git a/AGENTS.md b/AGENTS.md index c46b70f4b..005dca2ba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -232,6 +232,13 @@ Service Worker (src/service_worker.ts) > SW → Offscreen uses `ServiceWorkerMessageSend` (`clients.matchAll()` + `postMessage`) on Chrome and > `EventPageOffscreenManager` on Firefox MV3; Offscreen replies to SW over `ExtensionMessage`. `WindowMessage` > is the Offscreen ↔ Sandbox channel. +> +> USER_SCRIPT content and MAIN inject runtimes normally use native extension channels directly to the SW. +> The `scripting` bundle is a document-start extension content script registered per matching frame. It runs a +> page-bridge runtime and is a supporting per-document helper rather than a separate service/background context in +> this five-context model. Those bridges carry the content bootstrap handoff, MAIN bootstrap/fallback and runtime update packets, +> synchronous DOM handles, and the whitelisted `external.Scriptcat` API. When MAIN GM RPC falls back through +> `PageMessage`, the scripting runtime validates its execution handle and grant before forwarding it to the SW. - **Service Worker** — central hub for script CRUD, Chrome APIs, permission verification, resource caching, and message routing. - **Content** — bridges SW and inject script. @@ -244,9 +251,13 @@ Sandbox. ### Message Passing (`packages/message/`) -`ExtensionMessage` (chrome.runtime — SW ↔ Content / Inject / Offscreen), `WindowMessage` (postMessage — Offscreen ↔ -Sandbox), `ServiceWorkerMessageSend` (`clients.matchAll()` + `postMessage` — SW → Offscreen on Chrome), -`CustomEventMessage` (CustomEvent — Content ↔ Inject), and `MessageQueue` (cross-context broadcast). +`ExtensionMessage` (chrome.runtime — SW ↔ Content / Inject / Offscreen), `PageMessage` (`window.postMessage` — +scripting ↔ Inject page bridge, including validated MAIN RPC fallback), `CustomEventMessage` (CustomEvent — +bootstrap handoff and DOM handles), +`WindowMessage` (`postMessage` — Offscreen ↔ Sandbox), `ServiceWorkerMessageSend` (`clients.matchAll()` + +`postMessage` — SW → Offscreen on Chrome), and `MessageQueue` (cross-context broadcast). Page-visible bridges do +not establish an authenticated extension origin; MAIN requests relayed through `PageMessage` must pass the +`PageRpcRegistry` checks before forwarding. ### Service & Data Layers diff --git a/docs/architecture.md b/docs/architecture.md index 06436d77f..58808bc79 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -77,6 +77,16 @@ Three ideas explain almost everything in the codebase: inject, and sandbox don't hold a MessageQueue instance. ``` +The diagram compresses the page-facing routes: USER_SCRIPT content and MAIN inject runtimes also connect directly +to the Service Worker through native extension channels on the preferred path. The `scripting` bundle is a +document-start extension content script registered per matching frame; it runs a page-bridge runtime and is a +supporting per-document helper rather than a separate service/background context in this five-context model. +`CustomEventMessage` carries the content bootstrap +handoff and synchronous DOM handles; `PageMessage` carries MAIN bootstrap/fallback traffic, runtime event/value +updates, and the whitelisted `external.Scriptcat` API. When MAIN GM RPC uses the page bridge fallback, the scripting +runtime validates the execution handle and grant before forwarding it to the Service Worker; the page bridge +itself is not an authenticated extension origin. + --- ## The Five Contexts (Process Model) @@ -86,8 +96,8 @@ Each context is a separate bundle (see [Build pipeline & manifest](./references/ | Context | Entry | Realm / capabilities | Bootstraps | |---|---|---|---| | **Service Worker** | [`src/service_worker.ts`](../src/service_worker.ts) | No DOM. Owns `chrome.*` privileged APIs, storage, permissions, routing. | `ExtensionMessage(true)` → `Server("serviceWorker")` + `MessageQueue` → `ServiceWorkerManager` | -| **Content** | [`src/content.ts`](../src/content.ts) | Isolated content-script world. Bridges SW and the page. | `CustomEventMessage` channel to inject + `Server("content")` → `ScriptRuntime` | -| **Inject** | [`src/inject.ts`](../src/inject.ts) | Page (`MAIN`) world. Has `unsafeWindow`; runs page userscripts. | `CustomEventMessage` to content + `Server("inject")` | +| **Content** | [`src/content.ts`](../src/content.ts) | `USER_SCRIPT` world. Receives a document bootstrap token through the page-side bridge, then uses a native extension channel for script loading, GM RPC, value updates, and callbacks. Dedicated USER_SCRIPT listeners are used when available; otherwise the regular port is token-bound. | `ExtensionMessage` + native callback port → `Server("content")` → `ScriptRuntime`; `CustomEventMessage` for bootstrap handoff and DOM handles | +| **Inject** | [`src/inject.ts`](../src/inject.ts) | Page (`MAIN`) world. Has `unsafeWindow`; runs page userscripts. | Native extension port for the preferred GM RPC path; `PageMessage` for bootstrap/fallback, whitelisted external API, and validated GM RPC fallback; `CustomEventMessage` for synchronous DOM handles | | **Offscreen** | [`src/offscreen.ts`](../src/offscreen.ts) | DOM-capable background page (Blobs, clipboard, DOM scraping, local storage). | `ExtensionMessage()` + `WindowMessage(window, sandbox)` → `OffscreenManager` | | **Sandbox** | [`src/sandbox.ts`](../src/sandbox.ts) | `sandbox`ed iframe inside offscreen. Evaluates background/scheduled scripts; runs cron. | `WindowMessage(window, parent)` + `Server("sandbox")` → `SandboxManager` | @@ -167,8 +177,9 @@ communication styles** over **several transports**. | Class | File | Connects | Underlying API | |---|---|---|---| -| `ExtensionMessage` | [`extension_message.ts`](../packages/message/extension_message.ts) | SW ↔ Content / Inject / Offscreen | `chrome.runtime.sendMessage` / `onConnect` (+ `onUserScript*` on Firefox) | -| `CustomEventMessage` | [`custom_event_message.ts`](../packages/message/custom_event_message.ts) | Content ↔ Inject | DOM `CustomEvent` dispatch (bypasses page tampering) | +| `ExtensionMessage` | [`extension_message.ts`](../packages/message/extension_message.ts) | SW ↔ Content / Inject / Offscreen | `chrome.runtime.sendMessage` / `onConnect`; browser-identified USER_SCRIPT messages are action-gated, and regular-port fallbacks are token-bound | +| `PageMessage` | [`page_message.ts`](../packages/message/page_message.ts) | `scripting` ↔ Inject | `window.postMessage`; page-visible MAIN bootstrap/fallback, runtime updates, whitelisted external API, and GM RPC fallback validated by `PageRpcRegistry` | +| `CustomEventMessage` | [`custom_event_message.ts`](../packages/message/custom_event_message.ts) | Content ↔ `scripting` page helper | DOM `CustomEvent`; bootstrap handoff and synchronous DOM references, not privileged GM RPC | | `WindowMessage` | [`window_message.ts`](../packages/message/window_message.ts) | Offscreen ↔ Sandbox | `window.postMessage` | | `ServiceWorkerMessageSend` | [`window_message.ts`](../packages/message/window_message.ts) | SW → Offscreen (Chrome) | `clients.matchAll()` + `postMessage` | | `MessageQueue` | [`message_queue.ts`](../packages/message/message_queue.ts) | Broadcast among the contexts that instantiate it — SW, Offscreen, UI pages | `chrome.runtime.sendMessage` + local `EventEmitter3` | diff --git a/docs/references/architecture-agent.md b/docs/references/architecture-agent.md index 8defc00b4..f46812a0b 100644 --- a/docs/references/architecture-agent.md +++ b/docs/references/architecture-agent.md @@ -98,6 +98,34 @@ The Agent subsystem does not use one persistence pattern; pick by data shape, ma attachments), `AgentTaskRunRepo` (task run history), `SkillRepo` (skill `.md`/script bundles). - `MCPServerRepo` (`Repo`) — MCP server configs. +## Userscript resource ownership + +The `CAT.agent.*` APIs are granted per script, but a grant alone does not decide which persisted resources that +script can access. The service-worker GM handlers take the caller identity from `request.script.uuid` and pass it +to the Agent services; they do not use a caller-supplied `scriptUuid` as the authority. + +- **Conversations** created by a script persist `ownerScriptUuid`. Script reads, chats, attaches, and mutations + check that owner. UI and legacy conversations without an owner remain available to the extension UI but are not + visible to script callers. Ephemeral chats are not persisted conversations. +- **Tasks** created by a script persist `ownerScriptUuid`; script list/get/update/delete/enable/run/history + operations are scoped to that owner. For compatibility, a legacy event task without an owner remains visible + only to the script named by `sourceScriptUuid`. +- **DOM monitors** are scoped to the script UUID supplied by the service-worker GM handler and to the tab. A + script caller cannot peek, stop, or replace a monitor owned by another script. +- **Attachments** live in the shared OPFS workspace and do not carry owner metadata themselves. Before + `CAT.agent.opfs.readAttachment` returns a file, `AgentChatRepo` verifies that a persisted message references + it from a conversation owned by the calling script. A guessed ID or a reference borrowed from another script's + conversation is insufficient. + +The checks are implemented in [`gm_agent.ts`](../../src/app/service/service_worker/gm_api/gm_agent.ts), +[`gm_agent_dom.ts`](../../src/app/service/service_worker/gm_api/gm_agent_dom.ts), +[`gm_agent_task.ts`](../../src/app/service/service_worker/gm_api/gm_agent_task.ts), +[`chat_service.ts`](../../src/app/service/agent/service_worker/chat_service.ts), +[`task_service.ts`](../../src/app/service/agent/service_worker/task_service.ts), +[`background_session_manager.ts`](../../src/app/service/agent/service_worker/background_session_manager.ts), +[`opfs_service.ts`](../../src/app/service/agent/service_worker/opfs_service.ts), and +[`dom_cdp.ts`](../../src/app/service/agent/service_worker/dom_cdp.ts). + ## Page / offscreen / sandbox delegation and permission boundaries - **Content (`src/app/service/content/gm_api/cat_agent.ts`)** exposes the `CAT.agent.*` API to user scripts — @@ -120,7 +148,8 @@ The Agent subsystem does not use one persistence pattern; pick by data shape, ma uses CDP; a background (non-active) tab tries CDP first and falls back to `chrome.tabs.captureVisibleTab` on failure; an active tab with no selector uses `chrome.tabs.captureVisibleTab` directly. - **Tab monitoring** (`startMonitor`/`stopMonitor`/`peekMonitor`) is unconditionally CDP-based — there is no - non-CDP path for it at all. + non-CDP path for it at all. A monitor is scoped to its tab and initiating script; other scripts cannot + inspect, stop, or replace it. CDP attaches the debugger to a tab and carries the extra permission/user-visible-banner implications that come with `chrome.debugger`; how often that applies depends on which action you're looking at, not a single diff --git a/docs/references/architecture-execution.md b/docs/references/architecture-execution.md index 717ea0f12..c5c054a30 100644 --- a/docs/references/architecture-execution.md +++ b/docs/references/architecture-execution.md @@ -24,12 +24,12 @@ go through a controlled context object instead of the page's real globals: Key points: - `with(arguments[0]||this.$)` makes every bare identifier resolve against the GM context first. The context is - a `Proxy` that intercepts reads, so the script sees `unsafeWindow`, the granted `GM_*` functions, and a - controlled view of globals — not the raw page scope. + a descriptor-based pseudo-window that projects `unsafeWindow`, the granted `GM_*` functions, and a controlled + view of globals — not the raw page scope. It is a compatibility projection rather than a security membrane. - Context and script name are passed as **unnamed `arguments`** (`arguments[0]`, `arguments[1]`) so user code can't shadow them by declaring variables of the same name. -- `.call(this)` preserves `this` because `chrome.userScripts` invokes the function free-standing (an arrow - function would capture the wrong `this`). +- The wrapper installs the body as a temporary method and removes it in the same expression. This preserves the + userscript `this` without resolving mutable page `call`, `apply`, or `bind` properties. ### Path A — Page scripts → `chrome.userScripts` @@ -38,7 +38,14 @@ patterns and registers the compiled payload (the `scripting` bundle) with `chrom `MAIN` or `USER_SCRIPT` world as required. At document time the content/inject pair ([`script_runtime.ts`](../../src/app/service/content/script_runtime.ts), [`exec_script.ts`](../../src/app/service/content/exec_script.ts)) evaluates the compiled function with the GM -context. +context. The `USER_SCRIPT` content path obtains its matched scripts directly from the service worker over +`ExtensionMessage` after a bootstrap-token handoff. The MAIN `inject` path uses a native extension port for GM RPC +when available; `PageMessage` carries page-visible bootstrap/fallback traffic, MAIN event/value updates, the +whitelisted `external.Scriptcat` API, and the MAIN GM RPC fallback through the `scripting` bundle. That fallback +is checked against the current `PageRpcRegistry` execution handle and grant before it is forwarded to the service +worker. `CustomEventMessage` carries the content bootstrap handoff and synchronous DOM references. Neither +page-visible bridge establishes an authenticated extension origin, so consumers must validate its payloads before +acting on them. ### Path B — Background scripts → Offscreen → Sandbox diff --git a/docs/references/architecture-gm-api.md b/docs/references/architecture-gm-api.md index 8282aae48..9850cdc57 100644 --- a/docs/references/architecture-gm-api.md +++ b/docs/references/architecture-gm-api.md @@ -7,12 +7,15 @@ across contexts to a privileged handler, then streams the result back. The imple - **Content side** ([`src/app/service/content/gm_api/`](../../src/app/service/content/gm_api)) — what runs *near* the userscript. Synchronous-feeling APIs (`GM_getValue`, `GM_log`) and the client half of async ones - (`GM_xmlhttpRequest`, `GM_setValue`). Built on `GM_Base`, which owns the messaging plumbing. + (`GM_xmlhttpRequest`, `GM_setValue`). Built on `GM_Base`, which owns the request facade. `USER_SCRIPT` calls use + the native extension channel; the DOM helper remains a narrow synchronous `CustomEventMessage` path. - **Service-worker side** ([`src/app/service/service_worker/gm_api/`](../../src/app/service/service_worker/gm_api)) — the privileged half: permission verification, cross-origin requests, DNR rule building. - **Offscreen side** ([`src/app/service/offscreen/gm_api.ts`](../../src/app/service/offscreen/gm_api.ts)) — DOM-dependent operations for background scripts (page-context XHR, `window.open`, clipboard). -- **Values** flow through `ValueService` and are broadcast so every tab running the same script sees updates. +- **Values** flow through `ValueService`. MAIN updates use the scripting broadcast, while USER_SCRIPT updates are + delivered over the native per-document callback port so privileged packets do not cross the page-observable DOM + channel. ### Registration: the `@GMContext.API` decorator @@ -82,4 +85,6 @@ traditional GM API: `@GMContext.API` on the content side [`compat-grant.js`](../../packages/eslint/compat-grant.js). What differs is the naming and transport shape — the grant is dotted (`CAT.agent.conversation`) and bound with `follow:` rather than `alias:`, the SW handlers set `dotAlias: false`, and conversation chat streams over `connect()` instead of `sendMessage`. Copy -the nearest existing `CAT.agent.*` method rather than a `GM_*` one. +the nearest existing `CAT.agent.*` method rather than a `GM_*` one. The service-worker handlers derive the script +identity from `request.script.uuid`, then the Agent services enforce persisted resource ownership; see +[`architecture-agent.md`](./architecture-agent.md#userscript-resource-ownership) for the scope and legacy rules. diff --git a/packages/message/README.md b/packages/message/README.md index 7e15272ea..a4206d28e 100644 --- a/packages/message/README.md +++ b/packages/message/README.md @@ -19,3 +19,6 @@ document),细节见 - service_worker 和 offscreen 之间可以使用 postMessage 的方式进行通信,避免同时监听 message 与 connect 导致冲突的问题。 - service_worker 会在空闲后进入不活动状态;与它建立的 `connect()` 长连接会在此时中断,因此需要长连接的场景要考虑 重连/状态恢复,而不是假定连接一直存活——这不是禁止在 service_worker 上使用 `connect`,只是需要为其生命周期设计容错。 +- USER_SCRIPT content 和 MAIN inject 优先使用 `ExtensionMessage` 原生扩展通道;服务端区分浏览器提供的 USER_SCRIPT 来源,并把 MAIN 或专用监听器不可用时的普通端口绑定到文档 bootstrap token。 +- `Server("serviceWorker")` 对浏览器标记的 `userScript` 来源仅允许 `connect()` 使用 `runtime/registerUserScript` 或 `runtime/gmApi`,仅允许 `sendMessage()` 使用 `runtime/gmApi` 或 `runtime/reconnectUserScript`;普通 extension 端口不带该来源标记,USER_SCRIPT / MAIN 的注册回退路径会在 `runtime/registerUserScript` 握手中校验文档 bootstrap token。 +- `CustomEventMessage` 和 `PageMessage` 是页面可见的桥:前者承载 content bootstrap 交接与同步 DOM 节点引用,后者承载 MAIN bootstrap/fallback、事件/值更新、白名单 `external.Scriptcat` API,以及经 `scripting` 中转的 GM RPC fallback。它们不提供已认证的扩展来源;`PageMessage` 的 GM RPC 在转发前必须通过 `PageRpcRegistry` 的执行句柄与 grant 校验。 diff --git a/packages/message/common.ts b/packages/message/common.ts index 009fd8a51..33b3e6757 100644 --- a/packages/message/common.ts +++ b/packages/message/common.ts @@ -8,9 +8,14 @@ export const CustomEventClone = CustomEvent; const performanceClone = (process.env.VI_TESTING === "true" ? new EventTarget() : performance) as Performance; // 避免页面载入后改动 EventTarget.prototype 的方法导致消息传递失败 -export const pageDispatchEvent = performanceClone.dispatchEvent.bind(performanceClone); -export const pageAddEventListener = performanceClone.addEventListener.bind(performanceClone); -export const pageRemoveEventListener = performanceClone.removeEventListener.bind(performanceClone); +const nativeReflectApply = Reflect.apply; +const nativeFunctionBind = Function.prototype.bind; +const bindNative = any>(fn: T, receiver: any): T => + nativeReflectApply(nativeFunctionBind, fn, [receiver]) as T; + +export const pageDispatchEvent = bindNative(performanceClone.dispatchEvent, performanceClone); +export const pageAddEventListener = bindNative(performanceClone.addEventListener, performanceClone); +export const pageRemoveEventListener = bindNative(performanceClone.removeEventListener, performanceClone); const detailClone = typeof cloneInto === "function" ? cloneInto : null; export const pageDispatchCustomEvent = (eventType: string, detail: T) => { if (detailClone && detail) detail = detailClone(detail, performanceClone); diff --git a/packages/message/custom_event_message.test.ts b/packages/message/custom_event_message.test.ts index 190d7f3cb..290e79a5c 100644 --- a/packages/message/custom_event_message.test.ts +++ b/packages/message/custom_event_message.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { CustomEventMessage } from "./custom_event_message"; import { createMouseEvent, pageDispatchEvent } from "@Packages/message/common"; @@ -17,6 +17,30 @@ function createMessagePair() { } describe("CustomEventMessage relatedTarget lifecycle", () => { + it("ignores accessor envelopes without executing their getters", () => { + const receiver = new CustomEventMessage(`custom-event-message-test-${++flagCounter}`, true, ""); + const received = vi.fn(); + receiver.onMessage(received); + const envelope: Record = { + messageId: "hostile", + type: "sendMessage", + data: { action: "custom-event-message-test/hostile" }, + }; + let accessed = false; + Object.defineProperty(envelope, "data", { + configurable: true, + enumerable: true, + get() { + accessed = true; + throw new Error("page getter executed"); + }, + }); + + expect(() => receiver.messageHandle(envelope as any, { postMessage: vi.fn() })).not.toThrow(); + expect(accessed).toBe(false); + expect(received).not.toHaveBeenCalled(); + }); + it("stores a received target on the receiving message until it is consumed", () => { const { sender, receiver } = createMessagePair(); const target = document.createElement("div"); diff --git a/packages/message/custom_event_message.ts b/packages/message/custom_event_message.ts index 63b33038a..dda181001 100644 --- a/packages/message/custom_event_message.ts +++ b/packages/message/custom_event_message.ts @@ -1,6 +1,11 @@ import type { Message, MessageConnect, RuntimeMessageSender, TMessage } from "./types"; import { uuidv4 } from "@App/pkg/utils/uuid"; -import { type PostMessage, type WindowMessageBody, WindowMessageConnect } from "./window_message"; +import { + parseWindowMessageBody, + type PostMessage, + type WindowMessageBody, + WindowMessageConnect, +} from "./window_message"; import EventEmitter from "eventemitter3"; import { DefinedFlags } from "@App/app/service/service_worker/runtime.consts"; import { @@ -78,6 +83,9 @@ export class CustomEventMessage implements Message { } messageHandle(data: WindowMessageBody, target: PostMessage) { + const safeData = parseWindowMessageBody(data); + if (!safeData) return; + data = safeData; // 处理消息 if (data.type === "sendMessage") { // 接收到消息 diff --git a/packages/message/extension_message.test.ts b/packages/message/extension_message.test.ts new file mode 100644 index 000000000..a547f7eb3 --- /dev/null +++ b/packages/message/extension_message.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it, vi } from "vitest"; +import { ExtensionContentMessageSend, ExtensionMessage, ExtensionMessageConnect } from "./extension_message"; + +describe("ExtensionMessage USER_SCRIPT compatibility", () => { + it("reports a failed dedicated listener registration so USER_SCRIPT can use the regular-port fallback", () => { + const runtime = chrome.runtime as unknown as { + onUserScriptConnect?: { addListener: (callback: (...args: any[]) => void) => void }; + onUserScriptMessage?: { addListener: (callback: (...args: any[]) => void) => void }; + messageListener?: Array<(message: any, sender: any, sendResponse: (response: any) => void) => void>; + connectListener?: Array<(port: chrome.runtime.Port) => void>; + }; + const originalConnect = runtime.onUserScriptConnect; + const originalMessage = runtime.onUserScriptMessage; + const initialMessageListenerCount = runtime.messageListener?.length ?? 0; + const initialConnectListenerCount = runtime.connectListener?.length ?? 0; + try { + runtime.onUserScriptConnect = { + addListener: () => { + throw new Error("userScripts permission unavailable"); + }, + }; + runtime.onUserScriptMessage = { + addListener: () => { + throw new Error("userScripts permission unavailable"); + }, + }; + const message = new ExtensionMessage(true); + message.onConnect(() => undefined); + message.onMessage(() => undefined); + + const response = vi.fn(); + const listeners = runtime.messageListener ?? []; + listeners.at(-1)?.({ type: "userScripts.LISTEN_CONNECTIONS" }, {}, response); + + expect(response).toHaveBeenCalledWith(false); + } finally { + if (runtime.messageListener) runtime.messageListener.length = initialMessageListenerCount; + if (runtime.connectListener) runtime.connectListener.length = initialConnectListenerCount; + runtime.onUserScriptConnect = originalConnect; + runtime.onUserScriptMessage = originalMessage; + } + }); + + it("does not require unavailable runtime event listeners", () => { + const runtime = chrome.runtime as unknown as { + onConnect?: typeof chrome.runtime.onConnect; + onMessage?: typeof chrome.runtime.onMessage; + }; + const onConnect = runtime.onConnect; + const onMessage = runtime.onMessage; + + try { + runtime.onConnect = undefined; + runtime.onMessage = undefined; + const message = new ExtensionMessage(); + + expect(() => message.onConnect(() => undefined)).not.toThrow(); + expect(() => message.onMessage(() => undefined)).not.toThrow(); + } finally { + runtime.onConnect = onConnect; + runtime.onMessage = onMessage; + } + }); + + it("keeps native sendMessage and connect bindings after runtime mutation", async () => { + const runtime = chrome.runtime as unknown as { + sendMessage: unknown; + connect: unknown; + }; + const sendMessage = runtime.sendMessage; + const connect = runtime.connect; + const message = new ExtensionMessage(); + + try { + runtime.sendMessage = () => { + throw new Error("patched sendMessage"); + }; + runtime.connect = () => { + throw new Error("patched connect"); + }; + + await expect(message.sendMessage({ action: "test" })).resolves.toMatchObject({ success: true }); + await expect(message.connect({ action: "test" })).resolves.toBeDefined(); + } finally { + runtime.sendMessage = sendMessage; + runtime.connect = connect; + } + }); + + it("keeps a native port postMessage binding after port mutation", () => { + const nativePostMessage = vi.fn(); + const port = { + postMessage: nativePostMessage, + onMessage: { addListener: vi.fn(), removeListener: vi.fn() }, + onDisconnect: { addListener: vi.fn(), removeListener: vi.fn() }, + disconnect: vi.fn(), + } as unknown as chrome.runtime.Port; + const connection = new ExtensionMessageConnect(port); + + port.postMessage = vi.fn(); + connection.sendMessage({ action: "native" }); + + expect(nativePostMessage).toHaveBeenCalledWith({ action: "native" }); + connection.disconnect(true); + }); + + it("preserves an explicit main-frame target when frameId is zero", async () => { + const sendMessage = vi + .spyOn(chrome.tabs, "sendMessage") + .mockImplementation((_tabId, _message, optionsOrCallback, callback) => { + const responseCallback = typeof optionsOrCallback === "function" ? optionsOrCallback : callback; + responseCallback?.({ success: true }); + return Promise.resolve({ success: true }); + }); + try { + await new ExtensionContentMessageSend(7, { frameId: 0 }).sendMessage({ action: "private" }); + + expect(sendMessage).toHaveBeenCalledWith(7, { action: "private" }, { frameId: 0 }, expect.any(Function)); + } finally { + sendMessage.mockRestore(); + } + }); +}); diff --git a/packages/message/extension_message.ts b/packages/message/extension_message.ts index e0702c98f..4d4646705 100644 --- a/packages/message/extension_message.ts +++ b/packages/message/extension_message.ts @@ -1,24 +1,48 @@ import EventEmitter from "eventemitter3"; -import type { Message, MessageConnect, MessageSend, RuntimeMessageSender, TMessage, TMessageCommAction } from "./types"; +import type { + Message, + MessageConnect, + MessageSend, + RuntimeMessageSender, + MessageOrigin, + TMessage, + TMessageCommAction, +} from "./types"; import { uuidv4 } from "@App/pkg/utils/uuid"; const listenerMgr = new EventEmitter(); // 单一管理器 +// 这些引用必须在页面或 USER_SCRIPT 世界有机会改写 chrome.runtime 方法前捕获, +// 否则消息边界会再次查找页面可变的属性。 +const runtimeApi = typeof chrome === "undefined" ? undefined : chrome.runtime; +const nativeRuntimeConnect = + typeof runtimeApi?.connect === "function" ? runtimeApi.connect.bind(runtimeApi) : undefined; +const nativeRuntimeSendMessage = + typeof runtimeApi?.sendMessage === "function" ? runtimeApi.sendMessage.bind(runtimeApi) : undefined; +export const hasNativeRuntimeChannel = nativeRuntimeConnect !== undefined && nativeRuntimeSendMessage !== undefined; export class ExtensionMessage implements Message { + private userScriptConnectionListenerReady = false; + private userScriptMessageListenerReady = false; + constructor(private backgroundPrimary = false) {} connect(data: TMessage): Promise { return new Promise((resolve) => { - const con = chrome.runtime.connect(); - con.postMessage(data); - resolve(new ExtensionMessageConnect(con)); + if (!nativeRuntimeConnect) throw new Error("chrome.runtime.connect is unavailable"); + const con = nativeRuntimeConnect(); + const connection = new ExtensionMessageConnect(con); + connection.sendMessage(data); + resolve(connection); }); } // 发送消息 注意不进行回调的内存泄漏 sendMessage(data: TMessage): Promise { return new Promise((resolve: ((value: T) => void) | null) => { - chrome.runtime.sendMessage(data, (resp: T) => { + if (!nativeRuntimeSendMessage) { + throw new Error("chrome.runtime.sendMessage is unavailable"); + } + nativeRuntimeSendMessage(data, (resp: T) => { const lastError = chrome.runtime.lastError; if (lastError) { console.error("chrome.runtime.lastError in chrome.runtime.sendMessage:", lastError); @@ -38,23 +62,25 @@ export class ExtensionMessage implements Message { }; onConnect(callback: (data: TMessage, con: MessageConnect) => void) { - chrome.runtime.onConnect.addListener((port: chrome.runtime.Port) => { - let myPort: chrome.runtime.Port | null = port; - const lastError = chrome.runtime.lastError; - if (lastError) { - console.error("chrome.runtime.lastError in chrome.runtime.onConnect", lastError); - // 消息API发生错误因此不继续执行 - } - const handler = (msg: TMessage) => { - const port = myPort; - if (port !== null) { - myPort = null; - port.onMessage.removeListener(handler); - callback(msg, new ExtensionMessageConnect(port)); + if (typeof chrome.runtime?.onConnect?.addListener === "function") { + chrome.runtime.onConnect.addListener((port: chrome.runtime.Port) => { + let myPort: chrome.runtime.Port | null = port; + const lastError = chrome.runtime.lastError; + if (lastError) { + console.error("chrome.runtime.lastError in chrome.runtime.onConnect", lastError); + // 消息API发生错误因此不继续执行 } - }; - myPort.onMessage.addListener(handler); - }); + const handler = (msg: TMessage) => { + const port = myPort; + if (port !== null) { + myPort = null; + port.onMessage.removeListener(handler); + callback(msg, new ExtensionMessageConnect(port, "extension")); + } + }; + myPort.onMessage.addListener(handler); + }); + } if (this.backgroundPrimary) { let addUserScriptConnectionListener: (() => void) | null = () => { @@ -71,20 +97,23 @@ export class ExtensionMessage implements Message { if (port !== null) { myPort = null; port.onMessage.removeListener(handler); - callback(msg, new ExtensionMessageConnect(port)); + callback(msg, new ExtensionMessageConnect(port, "userScript")); } }; myPort.onMessage.addListener(handler); }); addUserScriptConnectionListener = null; + this.userScriptConnectionListenerReady = true; } catch { - // do nothing + this.userScriptConnectionListenerReady = false; } }; // Firefox 需要先得到 userScripts 权限才能进行 onUserScriptConnect 的监听 this.tryEnableUserScriptConnectionListener = () => { if (typeof chrome.runtime.onUserScriptConnect?.addListener === "function") { addUserScriptConnectionListener && addUserScriptConnectionListener(); + } else { + this.userScriptConnectionListenerReady = false; } }; // Chrome 在初始化时就能监听 @@ -97,32 +126,35 @@ export class ExtensionMessage implements Message { callback: ( data: TMessageCommAction, sendResponse: (data: any) => void, - sender: RuntimeMessageSender + sender: RuntimeMessageSender, + origin?: MessageOrigin ) => boolean | void ): void { - chrome.runtime.onMessage.addListener((msg: TMessage, sender, sendResponse) => { - const lastError = chrome.runtime.lastError; - if (lastError) { - console.error("chrome.runtime.lastError in chrome.runtime.onMessage:", lastError); - // 消息API发生错误因此不继续执行 - return false; - } - if ((msg as any)?.type === "userScripts.LISTEN_CONNECTIONS" && this.backgroundPrimary) { - if ( - typeof chrome.runtime.onUserScriptConnect?.addListener === "function" && - typeof chrome.runtime.onUserScriptMessage?.addListener === "function" - ) { - this.tryEnableUserScriptConnectionListener(); - this.tryEnableUserScriptMessageListener(); - sendResponse(true); - } else { - sendResponse(false); + if (typeof chrome.runtime?.onMessage?.addListener === "function") { + chrome.runtime.onMessage.addListener((msg: TMessage, sender, sendResponse) => { + const lastError = chrome.runtime.lastError; + if (lastError) { + console.error("chrome.runtime.lastError in chrome.runtime.onMessage:", lastError); + // 消息API发生错误因此不继续执行 + return false; } - return false; - } - if (typeof msg.action !== "string") return; - return callback(msg, sendResponse, sender); - }); + if ((msg as any)?.type === "userScripts.LISTEN_CONNECTIONS" && this.backgroundPrimary) { + if ( + typeof chrome.runtime.onUserScriptConnect?.addListener === "function" && + typeof chrome.runtime.onUserScriptMessage?.addListener === "function" + ) { + this.tryEnableUserScriptConnectionListener(); + this.tryEnableUserScriptMessageListener(); + sendResponse(this.userScriptConnectionListenerReady && this.userScriptMessageListenerReady); + } else { + sendResponse(false); + } + return false; + } + if (typeof msg.action !== "string") return; + return callback(msg, sendResponse, sender, "extension"); + }); + } if (this.backgroundPrimary) { let addUserScriptMessageListener: (() => void) | null = () => { @@ -130,23 +162,32 @@ export class ExtensionMessage implements Message { // 监听用户脚本的消息 chrome.runtime.onUserScriptMessage.addListener((msg: TMessage, sender, sendResponse) => { const lastError = chrome.runtime.lastError; - if (typeof msg.action !== "string") return; if (lastError) { console.error("chrome.runtime.lastError in chrome.runtime.onUserScriptMessage:", lastError); // 消息API发生错误因此不继续执行 return false; } - return callback(msg, sendResponse, sender); + if ((msg as any)?.type === "userScripts.LISTEN_CONNECTIONS" && this.backgroundPrimary) { + this.tryEnableUserScriptConnectionListener(); + this.tryEnableUserScriptMessageListener(); + sendResponse(this.userScriptConnectionListenerReady && this.userScriptMessageListenerReady); + return false; + } + if (typeof msg.action !== "string") return; + return callback(msg, sendResponse, sender, "userScript"); }); addUserScriptMessageListener = null; + this.userScriptMessageListenerReady = true; } catch { - // do nothing + this.userScriptMessageListenerReady = false; } }; // Firefox 需要先得到 userScripts 权限才能进行 onUserScriptMessage 的监听 this.tryEnableUserScriptMessageListener = () => { if (typeof chrome.runtime.onUserScriptMessage?.addListener === "function") { addUserScriptMessageListener && addUserScriptMessageListener(); + } else { + this.userScriptMessageListenerReady = false; } }; // Chrome 在初始化时就能监听 @@ -158,10 +199,18 @@ export class ExtensionMessage implements Message { export class ExtensionMessageConnect implements MessageConnect { private readonly listenerId = `${uuidv4()}`; // 使用 uuidv4 确保唯一 private con: chrome.runtime.Port | null; + private readonly postMessage: (data: TMessage) => void; private isSelfDisconnected = false; - constructor(con: chrome.runtime.Port) { + constructor( + con: chrome.runtime.Port, + // 来源只记录浏览器原生通道的来源,供服务端区分 USER_SCRIPT 与扩展内部消息。 + private readonly origin: "extension" | "userScript" = "extension" + ) { this.con = con; // 强引用 + if (typeof con.postMessage !== "function") throw new TypeError("Invalid runtime port"); + // Port 的原型可能被页面改写;后续发送固定使用构造时取得的绑定方法。 + this.postMessage = con.postMessage.bind(con); const handler = (msg: TMessage, _con: chrome.runtime.Port) => { listenerMgr.emit(`onMessage:${this.listenerId}`, msg); }; @@ -188,7 +237,7 @@ export class ExtensionMessageConnect implements MessageConnect { // 無法 sendMessage 不应该屏蔽错误 throw new Error("Attempted to sendMessage on a disconnected port."); } - this.con.postMessage(data); + this.postMessage(data); } onMessage(callback: (data: TMessage) => void) { @@ -229,6 +278,10 @@ export class ExtensionMessageConnect implements MessageConnect { } return this.con; } + + getOrigin(): "extension" | "userScript" { + return this.origin; + } } export class ExtensionContentMessageSend implements MessageSend { @@ -242,7 +295,7 @@ export class ExtensionContentMessageSend implements MessageSend { sendMessage(data: TMessage): Promise { return new Promise((resolve) => { - if (!this.options?.documentId && !this.options?.frameId) { + if (this.options?.documentId === undefined && this.options?.frameId === undefined) { // 发送给指定的tab chrome.tabs.sendMessage(this.tabId, data, (resp: T) => { const lastError = chrome.runtime.lastError; @@ -269,7 +322,7 @@ export class ExtensionContentMessageSend implements MessageSend { return new Promise((resolve) => { const con = chrome.tabs.connect(this.tabId, this.options); con.postMessage(data); - resolve(new ExtensionMessageConnect(con)); + resolve(new ExtensionMessageConnect(con, "extension")); }); } } diff --git a/packages/message/page_message.test.ts b/packages/message/page_message.test.ts new file mode 100644 index 000000000..0479243e8 --- /dev/null +++ b/packages/message/page_message.test.ts @@ -0,0 +1,158 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { PageMessage } from "./page_message"; + +type FakeWindow = Window & { + handlers: Set<(event: MessageEvent) => void>; +}; + +const createWindow = (): FakeWindow => { + const handlers = new Set<(event: MessageEvent) => void>(); + const target = { + handlers, + addEventListener: vi.fn((_type: string, handler: (event: MessageEvent) => void) => { + handlers.add(handler); + }), + removeEventListener: vi.fn((_type: string, handler: (event: MessageEvent) => void) => { + handlers.delete(handler); + }), + postMessage: vi.fn((data: unknown) => { + queueMicrotask(() => { + for (const handler of handlers) handler({ source: target, data } as unknown as MessageEvent); + }); + }), + } as unknown as FakeWindow; + return target; +}; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("PageMessage", () => { + it("routes structured-clone messages only to the opposite role", async () => { + const target = createWindow(); + const scripting = new PageMessage("page-message-test", "scripting", target); + const inject = new PageMessage("page-message-test", "inject", target); + const received = vi.fn((_data, sendResponse) => sendResponse({ code: 0, data: "pong" })); + inject.onMessage(received); + + const response = await scripting.sendMessage({ action: "inject/ping", data: "ping" }); + + expect(response).toEqual({ code: 0, data: "pong" }); + expect(received).toHaveBeenCalledWith( + { action: "inject/ping", data: "ping" }, + expect.any(Function), + expect.any(Object) + ); + expect(target.postMessage).toHaveBeenCalledTimes(2); + + scripting.dispose(); + inject.dispose(); + }); + + it("supports scoped connections and removes its listener on dispose", async () => { + const target = createWindow(); + const scripting = new PageMessage("page-message-test", "scripting", target); + const inject = new PageMessage("page-message-test", "inject", target); + const received = vi.fn(); + inject.onConnect((_data, connection) => connection.onMessage(received)); + + const connection = await scripting.connect({ action: "inject/connect" }); + connection.sendMessage({ action: "inject/message", data: 1 }); + await new Promise((resolve) => queueMicrotask(resolve)); + + expect(received).toHaveBeenCalledWith({ action: "inject/message", data: 1 }); + const handlerCount = target.handlers.size; + scripting.dispose(); + expect(target.handlers.size).toBe(handlerCount - 1); + inject.dispose(); + }); + + it("ignores envelopes with accessor fields without executing the accessor", () => { + const target = createWindow(); + const inject = new PageMessage("page-message-test", "inject", target); + const received = vi.fn(); + inject.onMessage(received); + const envelope: Record = { + channel: "page-message-test", + source: "scripting", + target: "inject", + messageId: "hostile", + type: "sendMessage", + data: { action: "inject/ping" }, + }; + let accessed = false; + Object.defineProperty(envelope, "data", { + configurable: true, + enumerable: true, + get() { + accessed = true; + throw new Error("page getter executed"); + }, + }); + + const handler = [...target.handlers][0]; + expect(() => handler({ source: target, data: envelope } as unknown as MessageEvent)).not.toThrow(); + expect(accessed).toBe(false); + expect(received).not.toHaveBeenCalled(); + inject.dispose(); + }); + + it("ignores proxy envelopes whose own-key inspection is hostile", () => { + const target = createWindow(); + const inject = new PageMessage("page-message-test", "inject", target); + const received = vi.fn(); + inject.onMessage(received); + const envelope = new Proxy( + { + channel: "page-message-test", + source: "scripting", + target: "inject", + messageId: "hostile", + type: "sendMessage", + data: { action: "inject/ping" }, + }, + { + ownKeys() { + throw new Error("page proxy executed"); + }, + } + ); + + const handler = [...target.handlers][0]; + expect(() => handler({ source: target, data: envelope } as unknown as MessageEvent)).not.toThrow(); + expect(received).not.toHaveBeenCalled(); + inject.dispose(); + }); + + it("keeps page connections working when the page patches Function.prototype.bind", async () => { + const target = createWindow(); + const scripting = new PageMessage("page-message-test", "scripting", target); + const originalBind = Function.prototype.bind; + let connection: Awaited> | undefined; + + try { + let connectionPromise: ReturnType | undefined; + try { + Function.prototype.bind = (() => { + throw new Error("patched bind"); + }) as typeof Function.prototype.bind; + connectionPromise = scripting.connect({ action: "inject/connect" }); + } finally { + Function.prototype.bind = originalBind; + } + + connection = await connectionPromise!; + connection.sendMessage({ action: "inject/message" }); + + expect(target.postMessage).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ type: "connectMessage", data: { action: "inject/message" } }), + "*" + ); + } finally { + connection?.disconnect(true); + scripting.dispose(); + } + }); +}); diff --git a/packages/message/page_message.ts b/packages/message/page_message.ts new file mode 100644 index 000000000..011f9cbcd --- /dev/null +++ b/packages/message/page_message.ts @@ -0,0 +1,270 @@ +import EventEmitter from "eventemitter3"; +import { uuidv4 } from "@App/pkg/utils/uuid"; +import type { + Message, + MessageConnect, + OnConnectCallback, + OnMessageCallback, + RuntimeMessageSender, + TMessage, +} from "./types"; + +export type PageMessageRole = "scripting" | "inject"; + +type PageMessageType = "sendMessage" | "respMessage" | "connect" | "disconnect" | "connectMessage"; + +type PageMessageBody = { + readonly channel: string; + readonly source: PageMessageRole; + readonly target: PageMessageRole; + readonly messageId: string; + readonly type: PageMessageType; + readonly data: TMessage | null; +}; + +const nativeReflectApply = Reflect.apply; +const nativeFunctionBind = Function.prototype.bind; + +const bindNative = any>(fn: T, receiver: any): T => + nativeReflectApply(nativeFunctionBind, fn, [receiver]) as T; + +const listenerMgr = new EventEmitter(); + +const nativeReflectOwnKeys = Reflect.ownKeys; +const nativeObjectGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; + +const parsePageMessageBody = (value: unknown): PageMessageBody | undefined => { + if (value === null || typeof value !== "object") return undefined; + + try { + if (nativeReflectOwnKeys(value).length !== 6) return undefined; + + const channel = nativeObjectGetOwnPropertyDescriptor(value, "channel"); + const source = nativeObjectGetOwnPropertyDescriptor(value, "source"); + const target = nativeObjectGetOwnPropertyDescriptor(value, "target"); + const messageId = nativeObjectGetOwnPropertyDescriptor(value, "messageId"); + const type = nativeObjectGetOwnPropertyDescriptor(value, "type"); + const data = nativeObjectGetOwnPropertyDescriptor(value, "data"); + if ( + !channel || + !("value" in channel) || + !source || + !("value" in source) || + !target || + !("value" in target) || + !messageId || + !("value" in messageId) || + !type || + !("value" in type) || + !data || + !("value" in data) + ) { + return undefined; + } + + const messageType = type.value; + if ( + typeof channel.value !== "string" || + (source.value !== "scripting" && source.value !== "inject") || + (target.value !== "scripting" && target.value !== "inject") || + typeof messageId.value !== "string" || + (messageType !== "sendMessage" && + messageType !== "respMessage" && + messageType !== "connect" && + messageType !== "disconnect" && + messageType !== "connectMessage") + ) { + return undefined; + } + + return { + channel: channel.value, + source: source.value, + target: target.value, + messageId: messageId.value, + type: messageType, + data: data.value, + } as PageMessageBody; + } catch { + return undefined; + } +}; + +const otherRole = (role: PageMessageRole): PageMessageRole => (role === "scripting" ? "inject" : "scripting"); + +class PageMessageConnect implements MessageConnect { + private readonly listenerId = uuidv4(); + private target: (() => void) | null; + private isSelfDisconnected = false; + + constructor( + private readonly messageId: string, + private readonly targetRole: PageMessageRole, + private readonly send: ( + target: PageMessageRole, + body: Omit + ) => void, + private readonly EE: EventEmitter + ) { + const handler = (message: TMessage) => { + listenerMgr.emit(`onMessage:${this.listenerId}`, message); + }; + const cleanup = () => { + if (!this.target) return; + this.target = null; + listenerMgr.removeAllListeners(`cleanup:${this.listenerId}`); + this.EE.removeAllListeners(`connectMessage:${this.messageId}`); + this.EE.removeAllListeners(`disconnect:${this.messageId}`); + listenerMgr.emit(`onDisconnect:${this.listenerId}`, this.isSelfDisconnected); + listenerMgr.removeAllListeners(`onDisconnect:${this.listenerId}`); + listenerMgr.removeAllListeners(`onMessage:${this.listenerId}`); + }; + this.target = cleanup; + this.EE.addListener(`connectMessage:${this.messageId}`, handler); + this.EE.addListener(`disconnect:${this.messageId}`, cleanup); + listenerMgr.once(`cleanup:${this.listenerId}`, cleanup); + } + + sendMessage(data: TMessage): void { + if (!this.target) throw new Error("Attempted to sendMessage on a disconnected page channel."); + this.send(this.targetRole, { + messageId: this.messageId, + type: "connectMessage", + data, + }); + } + + onMessage(callback: (data: TMessage) => void): void { + if (!this.target) throw new Error("onMessage on a disconnected page channel."); + listenerMgr.addListener(`onMessage:${this.listenerId}`, callback); + } + + disconnect(ignoreAlreadyDisconnected = false): void { + if (!this.target) { + if (ignoreAlreadyDisconnected) return; + throw new Error("Attempted to disconnect a disconnected page channel."); + } + this.isSelfDisconnected = true; + this.send(this.targetRole, { + messageId: this.messageId, + type: "disconnect", + data: null, + }); + listenerMgr.emit(`cleanup:${this.listenerId}`); + } + + onDisconnect(callback: (isSelfDisconnected: boolean) => void): void { + if (!this.target) throw new Error("onDisconnect on a disconnected page channel."); + listenerMgr.once(`onDisconnect:${this.listenerId}`, callback); + } +} + +/** + * 页面异步 RPC 专用通道。 + * + * role 与 channel 只负责传输路由;调用方仍必须验证每个请求,并把权限绑定到隔离执行记录。 + */ +export class PageMessage implements Message { + readonly EE = new EventEmitter(); + private readonly postMessage: (message: unknown, targetOrigin: string) => void; + private readonly messageHandler: (event: MessageEvent) => void; + private readonly targetRole: PageMessageRole; + private sendEnvelopeBound: + | ((target: PageMessageRole, body: Omit) => void) + | undefined; + + constructor( + private readonly channel: string, + private readonly role: PageMessageRole, + private readonly sourceWindow: Window = window + ) { + if (typeof sourceWindow.postMessage !== "function") throw new TypeError("window.postMessage is unavailable"); + this.postMessage = bindNative(sourceWindow.postMessage, sourceWindow); + this.targetRole = otherRole(role); + this.messageHandler = (event: MessageEvent) => { + if (event.source !== null && event.source !== sourceWindow) return; + const body = parsePageMessageBody(event.data); + if (!body || body.channel !== this.channel || body.target !== this.role || body.source !== this.targetRole) { + return; + } + this.messageHandle(body); + }; + sourceWindow.addEventListener("message", this.messageHandler); + } + + private sendEnvelope(target: PageMessageRole, body: Omit): void { + this.postMessage( + { + channel: this.channel, + source: this.role, + target, + ...body, + } satisfies PageMessageBody, + "*" + ); + } + + private getSendEnvelopeBound() { + return (this.sendEnvelopeBound ??= bindNative(this.sendEnvelope, this)); + } + + private messageHandle(body: PageMessageBody): void { + if (body.type === "sendMessage") { + this.EE.emit( + "message", + body.data, + (response: TMessage) => { + this.sendEnvelope(body.source, { + messageId: body.messageId, + type: "respMessage", + data: response, + }); + }, + {} as RuntimeMessageSender + ); + } else if (body.type === "respMessage") { + this.EE.emit(`response:${body.messageId}`, body); + } else if (body.type === "connect") { + this.EE.emit( + "connect", + body.data, + new PageMessageConnect(body.messageId, body.source, this.getSendEnvelopeBound(), this.EE) + ); + } else if (body.type === "disconnect") { + this.EE.emit(`disconnect:${body.messageId}`); + } else if (body.type === "connectMessage") { + this.EE.emit(`connectMessage:${body.messageId}`, body.data); + } + } + + onConnect(callback: OnConnectCallback): void { + this.EE.addListener("connect", callback); + } + + onMessage(callback: OnMessageCallback): void { + this.EE.addListener("message", callback); + } + + connect(data: TMessage): Promise { + const messageId = uuidv4(); + this.sendEnvelope(this.targetRole, { messageId, type: "connect", data }); + return Promise.resolve(new PageMessageConnect(messageId, this.targetRole, this.getSendEnvelopeBound(), this.EE)); + } + + sendMessage(data: TMessage): Promise { + return new Promise((resolve) => { + const messageId = uuidv4(); + const eventId = `response:${messageId}`; + this.EE.addListener(eventId, (body: PageMessageBody) => { + this.EE.removeAllListeners(eventId); + resolve(body.data as T); + }); + this.sendEnvelope(this.targetRole, { messageId, type: "sendMessage", data }); + }); + } + + dispose(): void { + this.sourceWindow.removeEventListener("message", this.messageHandler); + this.EE.removeAllListeners(); + } +} diff --git a/packages/message/server.test.ts b/packages/message/server.test.ts index b7b1ff007..ac6959811 100644 --- a/packages/message/server.test.ts +++ b/packages/message/server.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, beforeEach, vi, afterEach } from "vitest"; -import { GetSenderType, SenderConnect, SenderRuntime, Server, type IGetSender } from "./server"; +import { forwardMessage, GetSenderType, SenderConnect, SenderRuntime, Server, type IGetSender } from "./server"; import { CustomEventMessage } from "./custom_event_message"; import type { MessageConnect, RuntimeMessageSender } from "./types"; import { uuidv4 } from "@App/pkg/utils/uuid"; @@ -35,6 +35,87 @@ afterEach(() => { }); describe("Server", () => { + it("ignores message envelopes with accessor actions without executing the accessor", () => { + const handler = vi.fn(); + server.on("on-hostile", handler); + const message: Record = { data: {} }; + let accessed = false; + Object.defineProperty(message, "action", { + configurable: true, + enumerable: true, + get() { + accessed = true; + throw new Error("page getter executed"); + }, + }); + + expect(() => inboundMessage.EE.emit("message", message, vi.fn(), {})).not.toThrow(); + expect(accessed).toBe(false); + expect(handler).not.toHaveBeenCalled(); + }); + + it("ignores message envelopes with accessor data without executing the accessor", () => { + const handler = vi.fn(); + server.on("on-hostile-data", handler); + const message: Record = { action: "api/on-hostile-data" }; + let accessed = false; + Object.defineProperty(message, "data", { + configurable: true, + enumerable: true, + get() { + accessed = true; + throw new Error("page getter executed"); + }, + }); + + expect(() => inboundMessage.EE.emit("message", message, vi.fn(), {})).not.toThrow(); + expect(accessed).toBe(false); + expect(handler).not.toHaveBeenCalled(); + }); + + it("应该在消息和长连接转发中都应用参数转换", async () => { + const transformed: unknown[] = []; + const targetFlag = `${uuidv4()}::target`; + const targetInbound = new CustomEventMessage(targetFlag, true); + const targetOutbound = new CustomEventMessage(targetFlag, false); + const targetServer = new Server("service", targetInbound); + targetServer.on("stream", (params) => { + transformed.push(params); + return "connected"; + }); + targetServer.on("call", (params) => { + transformed.push(params); + return "called"; + }); + + const sourceFlag = `${uuidv4()}::source`; + const sourceInbound = new CustomEventMessage(sourceFlag, true); + const sourceOutbound = new CustomEventMessage(sourceFlag, false); + const sourceServer = new Server("source", sourceInbound); + const targetSender = { + sendMessage: (data: any) => targetOutbound.sendMessage(data), + connect: (data: any) => targetOutbound.connect(data), + }; + forwardMessage("service", "stream", sourceServer, targetSender, undefined, (params) => ({ + ...params, + transformed: true, + })); + forwardMessage("service", "call", sourceServer, targetSender, undefined, (params) => ({ + ...params, + transformed: true, + })); + + const stream = await sourceOutbound.connect({ action: "source/stream", data: { value: 1 } }); + const response = await sourceOutbound.sendMessage({ action: "source/call", data: { value: 2 } }); + + expect(response.data).toBe("called"); + expect(transformed).toEqual([ + { value: 1, transformed: true }, + { value: 2, transformed: true }, + ]); + stream.disconnect(true); + }); + describe("基本功能测试 1", () => { it.concurrent("应该能够注册和调用 API", async () => { const mockHandler = vi.fn().mockResolvedValue("test response"); @@ -489,6 +570,36 @@ describe("Server", () => { expect(extSender.documentId).toBe("doc-123"); }); + it("应该保留有效的零标签页和窗口编号", () => { + let capturedSender: IGetSender; + + server.on("on-zero-ids", (_params, sender) => { + capturedSender = sender; + }); + + const mockSender: RuntimeMessageSender = { + tab: { id: 0, windowId: 0 }, + frameId: 0, + } as RuntimeMessageSender; + + (server as any).messageHandle("on-zero-ids", {}, vi.fn(), mockSender); + + expect(capturedSender!.getExtMessageSender()).toMatchObject({ tabId: 0, windowId: 0, frameId: 0 }); + }); + + it("应该把扩展消息来源传给 SenderRuntime", () => { + let capturedOrigin: string | undefined; + server.on("on-origin", (_params, sender) => { + capturedOrigin = sender.getConnectOrigin?.(); + }); + + const sendResponse = vi.fn(); + const mockSender = { tab: { id: 123 } } as RuntimeMessageSender; + (server as any).messageHandle("on-origin", {}, sendResponse, mockSender, "userScript"); + + expect(capturedOrigin).toBe("userScript"); + }); + it.concurrent("应该为没有 tab 的 sender 返回 -1 tabId", async () => { let capturedSender: IGetSender; @@ -534,6 +645,59 @@ describe("Server", () => { }); }); + describe("USER_SCRIPT action boundary", () => { + it("rejects privileged service worker actions before dispatch", () => { + const serviceWorkerServer = new Server("serviceWorker", inboundMessage); + const handler = vi.fn(); + serviceWorkerServer.on("script/getAllScripts", handler); + const sendResponse = vi.fn(); + const sender = {} as RuntimeMessageSender; + + (serviceWorkerServer as any).messageHandle("script/getAllScripts", {}, sendResponse, sender, "userScript"); + + expect(handler).not.toHaveBeenCalled(); + expect(sendResponse).toHaveBeenCalledWith({ code: -1, message: "userScript action is not allowed" }); + }); + + it("allows only the USER_SCRIPT GM API message", () => { + const serviceWorkerServer = new Server("serviceWorker", inboundMessage); + const handler = vi.fn().mockReturnValue("ok"); + serviceWorkerServer.on("runtime/gmApi", handler); + const sendResponse = vi.fn(); + const sender = {} as RuntimeMessageSender; + + (serviceWorkerServer as any).messageHandle( + "runtime/gmApi", + { api: "GM_log" }, + sendResponse, + sender, + "userScript" + ); + + expect(handler).toHaveBeenCalledWith({ api: "GM_log" }, expect.any(SenderRuntime)); + expect(sendResponse).toHaveBeenCalledWith({ code: 0, data: "ok" }); + }); + + it("allows the native USER_SCRIPT reconnect request", () => { + const serviceWorkerServer = new Server("serviceWorker", inboundMessage); + const handler = vi.fn().mockReturnValue({ bootstrapToken: "next-token" }); + serviceWorkerServer.on("runtime/reconnectUserScript", handler); + const sendResponse = vi.fn(); + const sender = {} as RuntimeMessageSender; + + (serviceWorkerServer as any).messageHandle( + "runtime/reconnectUserScript", + undefined, + sendResponse, + sender, + "userScript" + ); + + expect(handler).toHaveBeenCalledWith(undefined, expect.any(SenderRuntime)); + expect(sendResponse).toHaveBeenCalledWith({ code: 0, data: { bootstrapToken: "next-token" } }); + }); + }); + describe("Connect 功能测试", () => { it("应该能够处理连接消息", async () => { const mockHandler = vi.fn(); diff --git a/packages/message/server.ts b/packages/message/server.ts index 3df109f80..485fb9014 100644 --- a/packages/message/server.ts +++ b/packages/message/server.ts @@ -1,9 +1,41 @@ -import type { RuntimeMessageSender, MessageConnect, ExtMessageSender, Message, TMessage, MessageSend } from "./types"; +import type { + RuntimeMessageSender, + MessageConnect, + ExtMessageSender, + Message, + MessageOrigin, + TMessage, + MessageSend, +} from "./types"; import LoggerCore from "@App/app/logger/core"; import { connect, sendMessage } from "./client"; import { ExtensionMessageConnect } from "./extension_message"; import Logger from "@App/app/logger/logger"; +const nativeReflectApply = Reflect.apply; +const nativeFunctionBind = Function.prototype.bind; +const nativeObjectGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; +// 转发监听器会跨 context 保存一段时间,绑定时固定原生 bind,避免页面改写原型。 +const bindNative = any>(fn: T, receiver: any): T => + nativeReflectApply(nativeFunctionBind, fn, [receiver]) as T; + +type ParsedServerMessage = { action: string; data?: unknown }; + +const parseServerMessage = (value: unknown): ParsedServerMessage | undefined => { + if (value === null || typeof value !== "object") return undefined; + try { + const actionDescriptor = nativeObjectGetOwnPropertyDescriptor(value, "action"); + if (!actionDescriptor || !("value" in actionDescriptor) || typeof actionDescriptor.value !== "string") { + return undefined; + } + const dataDescriptor = nativeObjectGetOwnPropertyDescriptor(value, "data"); + if (dataDescriptor && !("value" in dataDescriptor)) return undefined; + return { action: actionDescriptor.value, data: dataDescriptor?.value }; + } catch { + return undefined; + } +}; + export const enum GetSenderType { CONNECT = 1, EXTCONNECT = 1 | 2, @@ -15,6 +47,7 @@ export interface IGetSender { getSender(): RuntimeMessageSender | undefined; getExtMessageSender(): ExtMessageSender; getConnect(): MessageConnect | undefined; + getConnectOrigin?(): MessageOrigin | undefined; } export class SenderConnect { @@ -47,8 +80,8 @@ export class SenderConnect { if (this.sender instanceof ExtensionMessageConnect) { const con = this.sender.getPort(); return { - windowId: con.sender?.tab?.windowId || -1, // -1表示后台脚本 - tabId: con.sender?.tab?.id || -1, // -1表示后台脚本 + windowId: con.sender?.tab?.windowId ?? -1, // -1表示后台脚本 + tabId: con.sender?.tab?.id ?? -1, // -1表示后台脚本 frameId: con.sender?.frameId, documentId: con.sender?.documentId, }; @@ -65,11 +98,18 @@ export class SenderConnect { getConnect(): MessageConnect { return this.sender; } + + getConnectOrigin(): "extension" | "userScript" | undefined { + return this.sender instanceof ExtensionMessageConnect ? this.sender.getOrigin() : undefined; + } } export class SenderRuntime { private readonly mType; - constructor(private sender: RuntimeMessageSender) { + constructor( + private sender: RuntimeMessageSender, + private readonly origin?: MessageOrigin + ) { this.mType = GetSenderType.RUNTIME; } @@ -97,8 +137,8 @@ export class SenderRuntime { }; } return { - windowId: sender.tab?.windowId || -1, // -1表示后台脚本 - tabId: sender.tab?.id || -1, // -1表示后台脚本 + windowId: sender.tab?.windowId ?? -1, // -1表示后台脚本 + tabId: sender.tab?.id ?? -1, // -1表示后台脚本 frameId: sender.frameId, documentId: sender.documentId, }; @@ -107,6 +147,10 @@ export class SenderRuntime { getConnect(): undefined { return undefined; } + + getConnectOrigin(): MessageOrigin | undefined { + return this.origin; + } } type ApiFunction = (params: any, con: IGetSender) => Promise | any | void; @@ -132,7 +176,7 @@ export class Server { private logger = LoggerCore.getInstance().logger({ service: "messageServer" }); constructor( - prefix: string, + private readonly prefix: string, msgReceiver: Message | Message[], private enableConnect: boolean = true ) { @@ -140,10 +184,11 @@ export class Server { if (this.enableConnect) { msgReceiverList.forEach((msg) => { msg.onConnect((msg: TMessage, con: MessageConnect) => { - if (typeof msg.action !== "string") return; - this.logger.trace("server onConnect", { msg }); - if (msg.action?.startsWith(prefix)) { - return this.connectHandle(msg.action.slice(prefix.length + 1), msg.data, con); + const parsed = parseServerMessage(msg); + if (!parsed) return; + this.logger.trace("server onConnect", { action: parsed.action }); + if (parsed.action.startsWith(this.prefix)) { + return this.connectHandle(parsed.action.slice(this.prefix.length + 1), parsed.data, con); } return false; }); @@ -151,11 +196,18 @@ export class Server { } msgReceiverList.forEach((msg) => { - msg.onMessage((msg: TMessage, sendResponse, sender) => { - if (typeof msg.action !== "string") return; - this.logger.trace("server onMessage", { msg: msg as any }); - if (msg.action?.startsWith(prefix)) { - return this.messageHandle(msg.action.slice(prefix.length + 1), msg.data, sendResponse, sender); + msg.onMessage((msg: TMessage, sendResponse, sender, origin) => { + const parsed = parseServerMessage(msg); + if (!parsed) return; + this.logger.trace("server onMessage", { action: parsed.action }); + if (parsed.action.startsWith(this.prefix)) { + return this.messageHandle( + parsed.action.slice(this.prefix.length + 1), + parsed.data, + sendResponse, + sender, + origin + ); } }); return false; @@ -171,9 +223,15 @@ export class Server { } private connectHandle(msg: string, params: any, con: MessageConnect) { + const sender = new SenderConnect(con); + if (!this.isUserScriptActionAllowed(msg, sender.getConnectOrigin(), true)) { + con.sendMessage({ code: -1, message: "userScript action is not allowed" }); + con.disconnect(true); + return true; + } const func = this.apiFunctionMap.get(msg); if (func) { - const ret = func(params, new SenderConnect(con)); + const ret = func(params, sender); if (ret) { if (ret instanceof Promise) { ret @@ -197,12 +255,18 @@ export class Server { action: string, params: any, sendResponse: (response: any) => void, - sender: RuntimeMessageSender + sender: RuntimeMessageSender, + origin?: MessageOrigin ) { + if (!this.isUserScriptActionAllowed(action, origin, false)) { + sendResponse({ code: -1, message: "userScript action is not allowed" }); + this.logger.warn("userScript action rejected", { action }); + return; + } const func = this.apiFunctionMap.get(action); if (func) { try { - const ret = func(params, new SenderRuntime(sender)); + const ret = func(params, new SenderRuntime(sender, origin)); if (ret instanceof Promise) { ret .then((data) => { @@ -229,6 +293,14 @@ export class Server { this.logger.error("no such api", { action: action }); } } + + private isUserScriptActionAllowed(action: string, origin: MessageOrigin | undefined, isConnect: boolean): boolean { + // USER_SCRIPT 只应取得注册握手、断线重连和 GM RPC;其他 serviceWorker API 仍只接受扩展通道。 + if (this.prefix !== "serviceWorker" || origin !== "userScript") return true; + return isConnect + ? action === "runtime/registerUserScript" || action === "runtime/gmApi" + : action === "runtime/gmApi" || action === "runtime/reconnectUserScript"; + } } export class Group { @@ -293,22 +365,23 @@ export function forwardMessage( path: string, receiverFrom: Server, senderTo: MessageSend, - middleware?: ApiFunctionSync + middleware?: ApiFunctionSync, + transform?: (params: any, con: IGetSender) => any ) { const handler = async (params: any, fromCon: IGetSender): Promise => { const fromConnect: MessageConnect | undefined = fromCon.getConnect(); if (fromConnect) { const toCon: MessageConnect = await connect(senderTo, `${prefix}/${path}`, params); - fromConnect.onMessage(toCon.sendMessage.bind(toCon)); - toCon.onMessage(fromConnect.sendMessage.bind(fromConnect)); - fromConnect.onDisconnect(toCon.disconnect.bind(toCon)); - toCon.onDisconnect(fromConnect.disconnect.bind(fromConnect)); + fromConnect.onMessage(bindNative(toCon.sendMessage, toCon)); + toCon.onMessage(bindNative(fromConnect.sendMessage, fromConnect)); + fromConnect.onDisconnect(bindNative(toCon.disconnect, toCon)); + toCon.onDisconnect(bindNative(fromConnect.disconnect, fromConnect)); return undefined; } else { return sendMessage(senderTo, prefix + "/" + path, params); } }; - receiverFrom.on(path, (params, sender) => { + const processTransformed = (params: any, sender: IGetSender) => { if (middleware) { // 此处是为了处理CustomEventMessage的同步消息情况 const resp = middleware(params, sender) as any; @@ -324,5 +397,15 @@ export function forwardMessage( } } return handler(params, sender); - }); + }; + const process = transform + ? (params: any, sender: IGetSender) => { + // 转换先于中间件和转发执行,使跨世界输入只在一个受控位置完成校验/复制。 + const transformed = transform(params, sender); + return transformed instanceof Promise + ? transformed.then((data) => processTransformed(data, sender)) + : processTransformed(transformed, sender); + } + : processTransformed; + receiverFrom.on(path, process); } diff --git a/packages/message/types.ts b/packages/message/types.ts index 1b323b8de..7d710f50b 100644 --- a/packages/message/types.ts +++ b/packages/message/types.ts @@ -28,12 +28,14 @@ export type TMessageCommCode = { export type TMessage = TMessagQueueUnit | TMessageCommAction | TMessageCommCode; export type RuntimeMessageSender = chrome.runtime.MessageSender; +export type MessageOrigin = "extension" | "userScript"; export type OnConnectCallback = (data: TMessage, con: MessageConnect) => void; export type OnMessageCallback = ( data: TMessage, sendResponse: (data: any) => void, - sender: RuntimeMessageSender + sender: RuntimeMessageSender, + origin?: MessageOrigin ) => boolean | void; export interface Message { diff --git a/packages/message/window_message.test.ts b/packages/message/window_message.test.ts index 00be4f8d7..0262c8800 100644 --- a/packages/message/window_message.test.ts +++ b/packages/message/window_message.test.ts @@ -210,6 +210,63 @@ describe("WindowMessage.connect", () => { }); }); +describe("WindowMessage envelope validation", () => { + it("ignores accessor envelopes without executing their getters", () => { + let messageHandler: ((event: MessageEvent) => void) | undefined; + const sourceWindow = { + addEventListener: vi.fn((_event: string, handler: (event: MessageEvent) => void) => { + messageHandler = handler; + }), + } as unknown as Window; + const targetWindow = {} as unknown as Window; + const windowMessage = new WindowMessage(sourceWindow, targetWindow); + const received = vi.fn(); + windowMessage.onMessage(received); + const envelope: Record = { + messageId: "hostile", + type: "sendMessage", + data: { action: "offscreen/ping" }, + }; + let accessed = false; + Object.defineProperty(envelope, "data", { + configurable: true, + enumerable: true, + get() { + accessed = true; + throw new Error("page getter executed"); + }, + }); + + expect(() => messageHandler!({ source: targetWindow, data: envelope } as unknown as MessageEvent)).not.toThrow(); + expect(accessed).toBe(false); + expect(received).not.toHaveBeenCalled(); + }); + + it("ignores proxy envelopes whose own-key inspection throws", () => { + let messageHandler: ((event: MessageEvent) => void) | undefined; + const sourceWindow = { + addEventListener: vi.fn((_event: string, handler: (event: MessageEvent) => void) => { + messageHandler = handler; + }), + } as unknown as Window; + const targetWindow = {} as unknown as Window; + const windowMessage = new WindowMessage(sourceWindow, targetWindow); + const received = vi.fn(); + windowMessage.onMessage(received); + const envelope = new Proxy( + { messageId: "hostile", type: "sendMessage", data: { action: "offscreen/ping" } }, + { + ownKeys() { + throw new Error("page proxy executed"); + }, + } + ); + + expect(() => messageHandler!({ source: targetWindow, data: envelope } as unknown as MessageEvent)).not.toThrow(); + expect(received).not.toHaveBeenCalled(); + }); +}); + // 单测重点:target 支持传入惰性求值函数,避免在 Firefox sandbox iframe 尚处于初始 about:blank // 阶段就缓存 contentWindow 快照——导航到真正的 sandbox 页面后,浏览器是否仍保证该快照与 // 事件的 e.source 全等属于实现细节,不可依赖;每次发送/比对都应重新读取当前值。 diff --git a/packages/message/window_message.ts b/packages/message/window_message.ts index 9946bd089..f844100bc 100644 --- a/packages/message/window_message.ts +++ b/packages/message/window_message.ts @@ -32,6 +32,40 @@ export type WindowMessageBody = { data: T | null; // 消息数据 }; +const nativeReflectOwnKeys = Reflect.ownKeys; +const nativeObjectGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; + +export const parseWindowMessageBody = (value: unknown): WindowMessageBody | undefined => { + if (value === null || typeof value !== "object") return undefined; + + try { + if (nativeReflectOwnKeys(value).length !== 3) return undefined; + + const messageId = nativeObjectGetOwnPropertyDescriptor(value, "messageId"); + const type = nativeObjectGetOwnPropertyDescriptor(value, "type"); + const data = nativeObjectGetOwnPropertyDescriptor(value, "data"); + if (!messageId || !("value" in messageId) || !type || !("value" in type) || !data || !("value" in data)) { + return undefined; + } + + const messageType = type.value; + if ( + typeof messageId.value !== "string" || + (messageType !== "sendMessage" && + messageType !== "respMessage" && + messageType !== "connect" && + messageType !== "disconnect" && + messageType !== "connectMessage") + ) { + return undefined; + } + + return { messageId: messageId.value, type: messageType, data: data.value } as WindowMessageBody; + } catch { + return undefined; + } +}; + export class WindowMessage implements Message { EE = new EventEmitter(); @@ -78,6 +112,9 @@ export class WindowMessage implements Message { } messageHandle(data: WindowMessageBody, target: PostMessage) { + const safeData = parseWindowMessageBody(data); + if (!safeData) return; + data = safeData; // 处理消息 if (data.type === "sendMessage") { // 接收到消息 @@ -257,6 +294,9 @@ export class ServiceWorkerMessageSend implements Message { } messageHandle(data: WindowMessageBody, source?: PostMessage) { + const safeData = parseWindowMessageBody(data); + if (!safeData) return; + data = safeData; // 处理消息 if (data.type === "sendMessage" && source) { // 接收到来自offscreen的请求消息 @@ -358,6 +398,9 @@ export class ServiceWorkerClientMessage implements Message { } messageHandle(data: WindowMessageBody, source?: PostMessage) { + const safeData = parseWindowMessageBody(data); + if (!safeData) return; + data = safeData; // 只处理响应类消息,请求类消息由WindowMessage处理 if (data.type === "sendMessage" && source) { this.EE.emit( diff --git a/rspack.config.ts b/rspack.config.ts index f9e8ae578..3848a05aa 100644 --- a/rspack.config.ts +++ b/rspack.config.ts @@ -138,6 +138,9 @@ export default { new rspack.DefinePlugin({ "process.env.VI_TESTING": "'false'", "process.env.SC_RANDOM_KEY": `'${uuidv4()}'`, + // 每次构建都生成独立标记,脚本包装器只接受扩展内部传入的完整性密钥。 + "process.env.SC_RANDOM_FNKEY": `'${uuidv4()}'`, + "process.env.SC_ZN_RAND": `'$${uuidv4()}'`, "process.env.SC_DISABLE_AGENT": `'${enableAgent ? "false" : "true"}'`, }), new rspack.CopyRspackPlugin({ diff --git a/src/app/repo/agent_chat.test.ts b/src/app/repo/agent_chat.test.ts index 95b0dc1ce..a3fc1da32 100644 --- a/src/app/repo/agent_chat.test.ts +++ b/src/app/repo/agent_chat.test.ts @@ -157,6 +157,44 @@ describe("AgentChatRepo 附件存储", () => { expect(result).toBeInstanceOf(Blob); }); + it("附件读取权限只授予拥有引用该附件的脚本会话", async () => { + const conversation = await repo.createConversation({ + id: "conv-script-attachment", + ownerScriptUuid: "script-a", + title: "Script", + modelId: "m1", + createtime: 1, + updatetime: 1, + }); + await repo.saveMessages( + conversation.id, + [ + { + id: "message-script-attachment", + conversationId: conversation.id, + role: "user", + content: [{ type: "image", attachmentId: "script-image", mimeType: "image/png" }], + ownedAttachmentIds: ["script-image"], + createtime: 1, + }, + { + id: "message-borrowed-attachment", + conversationId: conversation.id, + role: "user", + content: [{ type: "image", attachmentId: "borrowed-image", mimeType: "image/png" }], + createtime: 2, + }, + ], + undefined, + { generation: conversation.generation! } + ); + + await expect(repo.isAttachmentAccessibleToScript("script-image", "script-a")).resolves.toBe(true); + await expect(repo.isAttachmentAccessibleToScript("borrowed-image", "script-a")).resolves.toBe(false); + await expect(repo.isAttachmentAccessibleToScript("script-image", "script-b")).resolves.toBe(false); + await expect(repo.isAttachmentAccessibleToScript("unreferenced", "script-a")).resolves.toBe(false); + }); + it("getAttachment 不存在的附件应返回 null", async () => { const result = await repo.getAttachment("nonexistent"); diff --git a/src/app/repo/agent_chat.ts b/src/app/repo/agent_chat.ts index 88fbaf1ff..5643f89da 100644 --- a/src/app/repo/agent_chat.ts +++ b/src/app/repo/agent_chat.ts @@ -500,6 +500,20 @@ export class AgentChatRepo extends OPFSRepo { } } + // 用户脚本只能读取自己拥有的会话消息声明过的附件;附件文件本身不携带 owner 元数据, + // 因此必须以持久化消息中的所有权字段作为授权依据,不能仅凭可猜测的附件 ID 或借用引用放行。 + async isAttachmentAccessibleToScript(id: string, scriptUuid: string): Promise { + if (!id || !scriptUuid) return false; + for (const conversation of await this.listConversations()) { + if (conversation.ownerScriptUuid !== scriptUuid) continue; + const snapshot = await this.getMessageSnapshot(conversation.id, conversation.generation); + if (collectMessageAttachmentIds(snapshot.messages, isLegacyGeneration(conversation.generation)).has(id)) { + return true; + } + } + return false; + } + // 删除单个附件(同时清理新旧路径) async deleteAttachment(id: string): Promise { // 新路径: agents/workspace/uploads/{id} diff --git a/src/app/repo/scripts.ts b/src/app/repo/scripts.ts index cf99f6de4..d2065f773 100644 --- a/src/app/repo/scripts.ts +++ b/src/app/repo/scripts.ts @@ -3,6 +3,7 @@ import type { Resource, ResourceType } from "./resource"; import type { SCMetadata } from "./metadata"; import type { GMInfoEnv } from "../service/content/types"; import type { URLRuleEntry } from "@App/pkg/utils/url_matcher"; +import type { ScriptEnvTag } from "@Packages/message/consts"; // 脚本模型 export type SCRIPT_TYPE = 1 | 2 | 3; @@ -110,6 +111,12 @@ export interface ScriptRunResource extends Script { resourceByType?: ScriptResourceByType; metadata: SCMetadata; // 经自定义覆盖的 Metadata originalMetadata: SCMetadata; // 原本的 Metadata (目前只需要 match, include, exclude) + /** 页面执行环境绑定的能力句柄。 */ + executionHandle?: string; + /** 执行脚本所在的页面环境。 */ + executionEnvTag?: ScriptEnvTag; + /** 页面执行绑定使用的值更新关联标识。 */ + executionRunFlag?: string; } /** @@ -151,6 +158,10 @@ export type TClientPageLoadInfo = injectScriptList: TScriptInfo[]; contentScriptList: TScriptInfo[]; envInfo: GMInfoEnv; + /** 一次性令牌,供 USER_SCRIPT world 请求私有 bootstrap。 */ + userScriptBootstrapToken?: string; + /** 一次性令牌,供 MAIN world 的 inject 环境请求私有 bootstrap。 */ + userScriptInjectBootstrapToken?: string; } | { ok: false }; diff --git a/src/app/service/agent/core/types.ts b/src/app/service/agent/core/types.ts index c2f197d5e..4f2ee5cc0 100644 --- a/src/app/service/agent/core/types.ts +++ b/src/app/service/agent/core/types.ts @@ -22,6 +22,8 @@ export type MessageContent = string | ContentBlock[]; export type Conversation = { id: string; + /** ScriptCat API owner; absent on conversations created by the extension UI or older records. */ + ownerScriptUuid?: string; /** Immutable identity for this incarnation of an ID. Filled when legacy records are loaded. */ generation?: string; /** Optimistic-concurrency version. Filled when legacy records are loaded. */ @@ -640,6 +642,8 @@ export type MCPApiRequest = /** 定时任务基础字段(两种模式共用) */ type AgentTaskBase = { id: string; + /** ScriptCat API owner; absent on tasks created by the extension UI or older records. */ + ownerScriptUuid?: string; /** Immutable identity for this incarnation of the task ID. */ generation?: string; /** Optimistic-concurrency version. */ @@ -733,5 +737,6 @@ export type ConversationApiRequest = generation?: string; messageIds: string[]; preserveAttachmentIds?: string[]; + scriptUuid?: string; } - | { action: "delete"; conversationId: string; generation: string; revision?: number }; + | { action: "delete"; conversationId: string; generation: string; revision?: number; scriptUuid?: string }; diff --git a/src/app/service/agent/service_worker/agent.ts b/src/app/service/agent/service_worker/agent.ts index 4ef1420f7..507e694f9 100644 --- a/src/app/service/agent/service_worker/agent.ts +++ b/src/app/service/agent/service_worker/agent.ts @@ -352,8 +352,8 @@ export class AgentService { } // 处理定时任务 API 请求,供 GMApi 调用 - async handleAgentTaskApi(params: AgentTaskApiRequest) { - return this.agentTaskService.handleAgentTask(params); + async handleAgentTaskApi(params: AgentTaskApiRequest, ownerScriptUuid?: string) { + return this.agentTaskService.handleAgentTask(params, ownerScriptUuid); } // 处理 CAT.agent.model API 请求,委托给 AgentModelService @@ -386,7 +386,7 @@ export class AgentService { // 附加到后台运行会话,供 GMApi 调用 async handleAttachToConversationFromGmApi( - params: { conversationId: string; generation?: string }, + params: { conversationId: string; generation?: string; scriptUuid: string }, sender: IGetSender ) { return this.handleAttachToConversation(params, sender); @@ -399,7 +399,7 @@ export class AgentService { // 附加到后台运行中的会话(委托给 BackgroundSessionManager) private async handleAttachToConversation( - params: { conversationId: string; generation?: string }, + params: { conversationId: string; generation?: string; scriptUuid?: string }, sender: IGetSender ) { return this.bgSessionManager.handleAttach(params, sender); diff --git a/src/app/service/agent/service_worker/background_session_manager.test.ts b/src/app/service/agent/service_worker/background_session_manager.test.ts new file mode 100644 index 000000000..bab0570ca --- /dev/null +++ b/src/app/service/agent/service_worker/background_session_manager.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it, vi } from "vitest"; +import { BackgroundSessionManager, type RunningConversation } from "./background_session_manager"; + +function createSender() { + const sentMessages: any[] = []; + const connection = { + sendMessage: (message: any) => sentMessages.push(message), + onMessage: vi.fn(), + onDisconnect: vi.fn(), + }; + return { + sender: { + isType: (type: any) => type === 1, + getConnect: () => connection, + } as any, + sentMessages, + }; +} + +describe("BackgroundSessionManager script ownership", () => { + it("does not attach a script to another script's running conversation", async () => { + const manager = new BackgroundSessionManager(); + const rc: RunningConversation = { + conversationId: "conv-owned", + generation: "gen-a", + ownerScriptUuid: "script-a", + abortController: new AbortController(), + listeners: new Set(), + streamingState: { content: "secret", thinking: "", toolCalls: [] }, + askResolvers: new Map(), + tasks: [], + status: "running" as const, + }; + manager.set(rc.conversationId, rc); + const { sender, sentMessages } = createSender(); + + await manager.handleAttach( + { conversationId: rc.conversationId, generation: rc.generation, scriptUuid: "script-b" }, + sender + ); + + expect(sentMessages).toContainEqual({ action: "event", data: { type: "sync", tasks: [], status: "done" } }); + expect(rc.listeners.size).toBe(0); + }); +}); diff --git a/src/app/service/agent/service_worker/background_session_manager.ts b/src/app/service/agent/service_worker/background_session_manager.ts index 1378502fa..a2c385e23 100644 --- a/src/app/service/agent/service_worker/background_session_manager.ts +++ b/src/app/service/agent/service_worker/background_session_manager.ts @@ -10,6 +10,8 @@ export type ListenerEntry = { // 后台运行会话状态 export type RunningConversation = { conversationId: string; + /** ScriptCat API owner; absent for conversations started by the extension UI. */ + ownerScriptUuid?: string; // 该次运行绑定的会话 generation;attach() 的调用方必须持有同一 generation 才允许附加, // 否则会静默观察到删除重建后无关的新一代会话 generation: string; @@ -191,7 +193,10 @@ export class BackgroundSessionManager { } // 附加 UI 连接到后台运行中的会话(同步快照 + listener + askUser resolver + stop) - async handleAttach(params: { conversationId: string; generation?: string }, sender: IGetSender): Promise { + async handleAttach( + params: { conversationId: string; generation?: string; scriptUuid?: string }, + sender: IGetSender + ): Promise { if (!sender.isType(GetSenderType.CONNECT)) { throw new Error("attachToConversation requires connect mode"); } @@ -209,6 +214,13 @@ export class BackgroundSessionManager { return; } + // Script callers may observe only the running conversation owned by the same script. + // Missing owners are legacy/UI records and therefore fail closed for scripts. + if (params.scriptUuid !== undefined && rc.ownerScriptUuid !== params.scriptUuid) { + sendEvent({ type: "sync", tasks: [], status: "done" }); + return; + } + // 调用方持有的 generation 与实际运行中的会话不一致:会话已被删除重建, // 不能让旧一代的调用方附加到无关的新一代会话上 if (params.generation !== undefined && rc.generation !== params.generation) { diff --git a/src/app/service/agent/service_worker/chat.test.ts b/src/app/service/agent/service_worker/chat.test.ts index 97ff823b6..7a2f80468 100644 --- a/src/app/service/agent/service_worker/chat.test.ts +++ b/src/app/service/agent/service_worker/chat.test.ts @@ -1,5 +1,11 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { createTestService, makeSkillRecord, makeSkillScriptRecord, makeTextResponse } from "./test-helpers"; +import { + createMockSender, + createTestService, + makeSkillRecord, + makeSkillScriptRecord, + makeTextResponse, +} from "./test-helpers"; // ---- handleConversationChat skipSaveUserMessage(重新生成 bug 修复验证)---- @@ -206,6 +212,83 @@ describe("handleConversationChat skipSaveUserMessage", () => { }); }); +describe("CAT.agent.conversation owner isolation", () => { + it("creates a persisted conversation bound to the requesting script", async () => { + const { service, mockRepo } = createTestService(); + + await (service as any).handleConversationApi({ + action: "create", + options: { model: "test-openai" }, + scriptUuid: "script-a", + }); + + expect(mockRepo.createConversation).toHaveBeenCalledWith(expect.objectContaining({ ownerScriptUuid: "script-a" })); + }); + + it("does not expose an owned or legacy conversation to another script", async () => { + const { service, mockRepo } = createTestService(); + mockRepo.listConversations.mockResolvedValue([ + { id: "owned", title: "Owned", modelId: "test-openai", ownerScriptUuid: "script-a" }, + { id: "legacy", title: "Legacy", modelId: "test-openai" }, + ]); + + await expect( + (service as any).handleConversationApi({ action: "get", id: "owned", scriptUuid: "script-b" }) + ).resolves.toBeNull(); + await expect( + (service as any).handleConversationApi({ action: "get", id: "legacy", scriptUuid: "script-a" }) + ).resolves.toBeNull(); + await expect( + (service as any).handleConversationApi({ action: "get", id: "owned", scriptUuid: "script-a" }) + ).resolves.toMatchObject({ id: "owned" }); + }); + + it("rejects every script mutation before it reaches message or conversation storage", async () => { + const { service, mockRepo } = createTestService(); + mockRepo.listConversations.mockResolvedValue([ + { id: "owned", title: "Owned", modelId: "test-openai", ownerScriptUuid: "script-a" }, + ]); + const requests = [ + { action: "getMessages", conversationId: "owned" }, + { action: "save", conversationId: "owned" }, + { action: "clearMessages", conversationId: "owned" }, + { action: "deleteMessages", conversationId: "owned", messageIds: [] }, + { action: "delete", conversationId: "owned", generation: "gen" }, + ]; + + for (const request of requests) { + await expect((service as any).handleConversationApi({ ...request, scriptUuid: "script-b" })).rejects.toThrow( + "Conversation not found" + ); + } + + expect(mockRepo.getMessageSnapshot).not.toHaveBeenCalled(); + expect(mockRepo.saveMessages).not.toHaveBeenCalled(); + expect(mockRepo.deleteConversation).not.toHaveBeenCalled(); + }); + + it("rejects a foreign script's chat before loading history or calling the model", async () => { + const { service, mockRepo } = createTestService(); + const { sender, sentMessages } = createMockSender(); + mockRepo.listConversations.mockResolvedValue([ + { id: "owned", title: "Owned", modelId: "test-openai", ownerScriptUuid: "script-a" }, + ]); + const fetchSpy = vi.spyOn(globalThis, "fetch"); + + await (service as any).handleConversationChat( + { conversationId: "owned", message: "secret", scriptUuid: "script-b" }, + sender + ); + + expect(sentMessages.map((message) => message.data)).toContainEqual( + expect.objectContaining({ type: "error", message: "Conversation not found" }) + ); + expect(mockRepo.getMessages).not.toHaveBeenCalled(); + expect(fetchSpy).not.toHaveBeenCalled(); + fetchSpy.mockRestore(); + }); +}); + describe("userscript 会话工具隔离", () => { it("携带 scriptUuid 时不注册无法交互的 ask_user 工具", async () => { const { service } = createTestService(); @@ -394,6 +477,7 @@ describe("handleConversationChat 场景补充", () => { id: "conv-1", title: "Test", modelId: "test-openai", + ownerScriptUuid: "script-1", generation: "gen-b", createtime: Date.now(), updatetime: Date.now(), diff --git a/src/app/service/agent/service_worker/chat_service.ts b/src/app/service/agent/service_worker/chat_service.ts index 89f79bd28..738246641 100644 --- a/src/app/service/agent/service_worker/chat_service.ts +++ b/src/app/service/agent/service_worker/chat_service.ts @@ -269,8 +269,10 @@ export class ChatService { case "create": return this.createConversation(params); case "get": - return this.getConversation(params.id); + return this.getConversation(params.id, params.scriptUuid); case "getMessages": + if (params.scriptUuid !== undefined) + await this.requireConversationAccess(params.conversationId, params.scriptUuid); // params.generation 提供时,与当前存储不一致(会话已被删除重建)则拒绝而非返回无关一代的消息; // 未提供 generation 时保留旧行为:会话不存在则返回空数组 try { @@ -281,15 +283,19 @@ export class ChatService { } case "save": { // 对话已经在 chat 过程中持久化,这里确保元数据也保存;仍需校验调用方持有的 generation - if (params.generation !== undefined) { - const conv = await this.getConversation(params.conversationId); - if (!conv || conv.generation !== params.generation) { - throw new Error("Conversation generation mismatch"); - } + const conv = + params.scriptUuid !== undefined || params.generation !== undefined + ? await this.getConversation(params.conversationId, params.scriptUuid) + : undefined; + if (params.scriptUuid !== undefined && !conv) throw new Error("Conversation not found"); + if (params.generation !== undefined && (!conv || conv.generation !== params.generation)) { + throw new Error("Conversation generation mismatch"); } return true; } case "clearMessages": + if (params.scriptUuid !== undefined) + await this.requireConversationAccess(params.conversationId, params.scriptUuid); // 会话正在等待脚本工具结果时,这个 clear 很可能来自该工具 handler 内部的 // await conv.clear():chat 持有会话队列锁等待 toolResults,clear 排队等锁, // 相互等待成死锁。对这个窗口显式拒绝(fail fast);其余时刻仍与 chat/compact @@ -317,6 +323,8 @@ export class ChatService { return true; }); case "deleteMessages": + if (params.scriptUuid !== undefined) + await this.requireConversationAccess(params.conversationId, params.scriptUuid); return stackAsyncTask(conversationChatLockKey(params.conversationId), async () => { const snapshot = await this.chatRepo.getMessageSnapshot(params.conversationId, params.generation); const ids = new Set(params.messageIds); @@ -333,6 +341,8 @@ export class ChatService { return true; }); case "delete": { + if (params.scriptUuid !== undefined) + await this.requireConversationAccess(params.conversationId, params.scriptUuid); this.abortAdmittedChats(params.conversationId); this.bgSessionManager.stop(params.conversationId); return stackAsyncTask(conversationChatLockKey(params.conversationId), async () => { @@ -352,6 +362,7 @@ export class ChatService { const model = await this.modelService.getModel(params.options.model); const conv: Conversation = { id: params.options.id || uuidv4(), + ownerScriptUuid: params.scriptUuid, title: "New Chat", modelId: model.id, system: params.options.system, @@ -362,10 +373,10 @@ export class ChatService { return this.chatRepo.createConversation(conv); } - private async getConversation(id: string): Promise { + private async getConversation(id: string, scriptUuid?: string): Promise { const conversations = await this.chatRepo.listConversations(); const conversation = conversations.find((item) => item.id === id); - if (!conversation) return null; + if (!conversation || (scriptUuid !== undefined && conversation.ownerScriptUuid !== scriptUuid)) return null; return { ...conversation, generation: conversation.generation || `legacy:${conversation.id}`, @@ -373,6 +384,12 @@ export class ChatService { }; } + private async requireConversationAccess(id: string, scriptUuid: string): Promise { + const conversation = await this.getConversation(id, scriptUuid); + if (!conversation) throw new Error("Conversation not found"); + return conversation; + } + // 统一的流式 conversation chat(UI 和脚本 API 共用) // 同一 conversationId 的 chat / compact(compact 复用本方法的 params.compact 分支)都必须与 // clearMessages 串行执行,避免并发读改写互相覆盖对方的持久化写入。 @@ -402,6 +419,21 @@ export class ChatService { // 后台模式:非 ephemeral、非 compact 时可用 const isBackground = params.background === true && !params.ephemeral && !params.compact; + // Script callers must prove ownership before entering the queue or touching a connection. + // Legacy/UI conversations have no owner and therefore fail closed for scripts. + if (!params.ephemeral && params.scriptUuid !== undefined) { + const conversation = await this.getConversation(params.conversationId, params.scriptUuid); + if (!conversation) { + try { + msgConn.sendMessage({ action: "event", data: { type: "error", message: "Conversation not found" } }); + } catch { + // 端口已断开,无需通知 + } + await releaseProvisionalUserAttachments(); + return; + } + } + if (!params.ephemeral && this.conversationsAwaitingScriptTools.has(params.conversationId)) { try { msgConn.sendMessage({ @@ -619,7 +651,7 @@ export class ChatService { if (isBackground) { // 后台会话必须先确认调用方持有的 generation 与当前存储一致,否则一次删除重建后的 // 陈旧调用会静默附加到无关的新一代会话上 - const conv = await this.getConversation(params.conversationId); + const conv = await this.getConversation(params.conversationId, params.scriptUuid); if (!conv) { await releaseProvisionalUserAttachments(); sendEventDirect({ type: "error", message: "Conversation not found" }); @@ -637,6 +669,7 @@ export class ChatService { rc = { conversationId: params.conversationId, generation: conv.generation!, + ownerScriptUuid: conv.ownerScriptUuid, abortController, listeners: new Set(), streamingState: { content: "", thinking: "", toolCalls: [] }, @@ -741,7 +774,7 @@ export class ChatService { } // 获取对话和模型 - const conv = await this.getConversation(params.conversationId); + const conv = await this.getConversation(params.conversationId, params.scriptUuid); if (!conv) { sendEvent({ type: "error", message: "Conversation not found" }); return; @@ -924,7 +957,7 @@ export class ChatService { abortController: AbortController ): Promise { const startTime = Date.now(); - const conv = await this.getConversation(params.conversationId); + const conv = await this.getConversation(params.conversationId, params.scriptUuid); if (!conv) { sendEvent({ type: "error", message: "Conversation not found" }); return; diff --git a/src/app/service/agent/service_worker/dom.test.ts b/src/app/service/agent/service_worker/dom.test.ts index a0cf6829d..9d9938a24 100644 --- a/src/app/service/agent/service_worker/dom.test.ts +++ b/src/app/service/agent/service_worker/dom.test.ts @@ -406,6 +406,14 @@ describe("AgentDomService", () => { }); }); + describe("monitor", () => { + it("应拒绝在浏览器内部页面启动监控", async () => { + mockTabsGet.mockResolvedValue({ id: 1, url: "chrome://settings", status: "complete", discarded: false }); + + await expect(service.startMonitor(1)).rejects.toThrow("Agent DOM operation not allowed for URL:"); + }); + }); + describe("resolveTabId", () => { it("应在 tab 被 discard 时自动 reload", async () => { mockTabsGet.mockResolvedValueOnce({ diff --git a/src/app/service/agent/service_worker/dom.ts b/src/app/service/agent/service_worker/dom.ts index df0f6d951..7a1ac953e 100644 --- a/src/app/service/agent/service_worker/dom.ts +++ b/src/app/service/agent/service_worker/dom.ts @@ -264,18 +264,20 @@ export class AgentDomService { } // 启动页面监控(CDP:dialog 自动处理 + MutationObserver) - async startMonitor(tabId: number): Promise { - return cdpStartMonitor(tabId); + async startMonitor(tabId: number, scriptUuid?: string): Promise { + const tab = await chrome.tabs.get(tabId); + assertDomUrlAllowed(tab.url || ""); + return cdpStartMonitor(tabId, scriptUuid); } // 停止监控并返回收集的结果 - async stopMonitor(tabId: number): Promise { - return cdpStopMonitor(tabId); + async stopMonitor(tabId: number, scriptUuid?: string): Promise { + return cdpStopMonitor(tabId, scriptUuid); } // 查询当前 monitor 状态(不停止监控) - peekMonitor(tabId: number): MonitorStatus { - return cdpPeekMonitor(tabId); + peekMonitor(tabId: number, scriptUuid?: string): MonitorStatus { + return cdpPeekMonitor(tabId, scriptUuid); } // 处理 GM API 请求路由 @@ -300,11 +302,11 @@ export class AgentDomService { case "executeScript": return this.executeScript(request.code, request.options); case "startMonitor": - return this.startMonitor(request.tabId); + return this.startMonitor(request.tabId, request.scriptUuid); case "stopMonitor": - return this.stopMonitor(request.tabId); + return this.stopMonitor(request.tabId, request.scriptUuid); case "peekMonitor": - return this.peekMonitor(request.tabId); + return this.peekMonitor(request.tabId, request.scriptUuid); default: throw new Error(`Unknown DOM action: ${(request as any).action}`); } diff --git a/src/app/service/agent/service_worker/dom_cdp.test.ts b/src/app/service/agent/service_worker/dom_cdp.test.ts index f09cfa183..c7f97ddec 100644 --- a/src/app/service/agent/service_worker/dom_cdp.test.ts +++ b/src/app/service/agent/service_worker/dom_cdp.test.ts @@ -17,7 +17,7 @@ vi.stubGlobal("chrome", { tabs: { get: mockTabsGet }, }); -import { cdpClick } from "./dom_cdp"; +import { cdpClick, cdpPeekMonitor, cdpStartMonitor, cdpStopMonitor } from "./dom_cdp"; afterAll(() => { vi.stubGlobal("chrome", savedChrome); @@ -91,4 +91,40 @@ describe("agent_dom_cdp", () => { }); await expect(cdpClick(999, "#nonexistent")).rejects.toThrow(/Element not found/); }); + + it("页面监控只能由创建它的脚本重新启动", async () => { + mockTabsGet.mockResolvedValue({ url: "https://example.com" }); + mockSendCommand.mockResolvedValue({ root: { nodeId: 1 } }); + + await cdpStartMonitor(999, "script-a"); + + await expect(cdpStartMonitor(999, "script-b")).rejects.toThrow("Monitor belongs to another script"); + + await cdpStopMonitor(999, "script-a"); + }); + + it("并发重启同一标签页的监控不会泄漏旧监听器", async () => { + mockTabsGet.mockResolvedValue({ url: "https://example.com" }); + mockSendCommand.mockResolvedValue({ root: { nodeId: 1 } }); + + await Promise.all([cdpStartMonitor(997, "script-a"), cdpStartMonitor(997, "script-a")]); + await cdpStopMonitor(997, "script-a"); + + expect(mockAttach).toHaveBeenCalledTimes(2); + expect(mockDetach).toHaveBeenCalledTimes(2); + expect(chrome.debugger.onEvent.addListener as ReturnType).toHaveBeenCalledTimes(2); + expect(chrome.debugger.onEvent.removeListener as ReturnType).toHaveBeenCalledTimes(2); + }); + + it("页面监控的结果不能被其他脚本读取或停止", async () => { + mockTabsGet.mockResolvedValue({ url: "https://example.com" }); + mockSendCommand.mockResolvedValue({ root: { nodeId: 1 } }); + + await cdpStartMonitor(998, "script-a"); + + expect(cdpPeekMonitor(998, "script-b")).toEqual({ hasChanges: false, dialogCount: 0, nodeCount: 0 }); + await expect(cdpStopMonitor(998, "script-b")).rejects.toThrow("Monitor belongs to another script"); + + await cdpStopMonitor(998, "script-a"); + }); }); diff --git a/src/app/service/agent/service_worker/dom_cdp.ts b/src/app/service/agent/service_worker/dom_cdp.ts index f05980868..26d08229d 100644 --- a/src/app/service/agent/service_worker/dom_cdp.ts +++ b/src/app/service/agent/service_worker/dom_cdp.ts @@ -16,12 +16,32 @@ type CapturedNode = { }; type MonitorSession = { + ownerScriptUuid?: string; dialogs: Array<{ type: string; message: string }>; capturedNodes: CapturedNode[]; // 从事件中直接提取的节点信息 listener: MonitorEventListener; }; const activeMonitors = new Map(); +const monitorOperationQueues = new Map>(); + +async function withMonitorOperation(tabId: number, operation: () => Promise): Promise { + const previous = monitorOperationQueues.get(tabId) || Promise.resolve(); + let release!: () => void; + const current = new Promise((resolve) => { + release = resolve; + }); + monitorOperationQueues.set(tabId, current); + await previous; + try { + return await operation(); + } finally { + release(); + if (monitorOperationQueues.get(tabId) === current) { + monitorOperationQueues.delete(tabId); + } + } +} // 生命周期管理:attach → 执行 → detach // 如果该 tabId 已有活跃的 monitor(已 attach),则复用连接,不做 attach/detach @@ -239,10 +259,18 @@ export async function cdpScreenshot(tabId: number, options?: ScreenshotOptions): // ---- 页面监控(startMonitor / stopMonitor) ---- // 启动页面监控:attach debugger,纯 CDP 事件监听(dialog + DOM 变化),零注入 -export async function cdpStartMonitor(tabId: number): Promise { +export function cdpStartMonitor(tabId: number, ownerScriptUuid?: string): Promise { + return withMonitorOperation(tabId, () => startMonitor(tabId, ownerScriptUuid)); +} + +async function startMonitor(tabId: number, ownerScriptUuid?: string): Promise { // 如果已有 monitor,先停止 - if (activeMonitors.has(tabId)) { - await cdpStopMonitor(tabId); + const current = activeMonitors.get(tabId); + if (current) { + if (current.ownerScriptUuid !== ownerScriptUuid) { + throw new Error("Monitor belongs to another script"); + } + await stopMonitor(tabId, ownerScriptUuid); } const dialogs: Array<{ type: string; message: string }> = []; @@ -292,13 +320,16 @@ export async function cdpStartMonitor(tabId: number): Promise { }; chrome.debugger.onEvent.addListener(listener); - activeMonitors.set(tabId, { dialogs, capturedNodes, listener }); + activeMonitors.set(tabId, { ownerScriptUuid, dialogs, capturedNodes, listener }); } // 轻量查询当前 monitor 状态(不停止监控) -export function cdpPeekMonitor(tabId: number): { hasChanges: boolean; dialogCount: number; nodeCount: number } { +export function cdpPeekMonitor( + tabId: number, + ownerScriptUuid?: string +): { hasChanges: boolean; dialogCount: number; nodeCount: number } { const monitor = activeMonitors.get(tabId); - if (!monitor) { + if (!monitor || monitor.ownerScriptUuid !== ownerScriptUuid) { return { hasChanges: false, dialogCount: 0, nodeCount: 0 }; } const dialogCount = monitor.dialogs.length; @@ -315,8 +346,15 @@ function stripHtmlTags(html: string): string { } // 停止监控:纯 CDP 解析新增节点 → 收集结果 → detach -export async function cdpStopMonitor(tabId: number): Promise { +export function cdpStopMonitor(tabId: number, ownerScriptUuid?: string): Promise { + return withMonitorOperation(tabId, () => stopMonitor(tabId, ownerScriptUuid)); +} + +async function stopMonitor(tabId: number, ownerScriptUuid?: string): Promise { const monitor = activeMonitors.get(tabId); + if (monitor && monitor.ownerScriptUuid !== ownerScriptUuid) { + throw new Error("Monitor belongs to another script"); + } const result: MonitorResult = { dialogs: monitor?.dialogs || [], addedNodes: [], diff --git a/src/app/service/agent/service_worker/opfs.test.ts b/src/app/service/agent/service_worker/opfs.test.ts index 9f2377f0b..e48a94a29 100644 --- a/src/app/service/agent/service_worker/opfs.test.ts +++ b/src/app/service/agent/service_worker/opfs.test.ts @@ -176,6 +176,24 @@ describe("handleOPFSApi", () => { expect(mockRepo.getAttachment).toHaveBeenCalledWith("att-123"); }); + it("readAttachment 不得读取其他脚本未拥有的附件", async () => { + const { service, mockRepo } = createTestService(); + mockRepo.isAttachmentAccessibleToScript.mockResolvedValue(false); + mockRepo.getAttachment = vi.fn().mockResolvedValue(new Blob(["secret"], { type: "image/png" })); + + await expect( + service.handleOPFSApi( + { + action: "readAttachment", + id: "att-private", + scriptUuid: "script-b", + }, + mockOPFSSender + ) + ).rejects.toThrow("Attachment access denied: att-private"); + expect(mockRepo.getAttachment).not.toHaveBeenCalled(); + }); + it("readAttachment 附件不存在时应抛出错误", async () => { const { service, mockRepo } = createTestService(); mockRepo.getAttachment = vi.fn().mockResolvedValue(null); diff --git a/src/app/service/agent/service_worker/opfs_service.ts b/src/app/service/agent/service_worker/opfs_service.ts index ac3a51d30..c082e5331 100644 --- a/src/app/service/agent/service_worker/opfs_service.ts +++ b/src/app/service/agent/service_worker/opfs_service.ts @@ -59,6 +59,9 @@ export class AgentOPFSService { return { path: safePath2, content: textContent, size: file2.size }; } case "readAttachment": { + if (!(await repo.isAttachmentAccessibleToScript(request.id, request.scriptUuid))) { + throw new Error(`Attachment access denied: ${request.id}`); + } const blob = await repo.getAttachment(request.id); if (!blob) { throw new Error(`Attachment not found: ${request.id}`); diff --git a/src/app/service/agent/service_worker/task_service.test.ts b/src/app/service/agent/service_worker/task_service.test.ts index 58e62a272..5c120b528 100644 --- a/src/app/service/agent/service_worker/task_service.test.ts +++ b/src/app/service/agent/service_worker/task_service.test.ts @@ -128,6 +128,7 @@ describe("AgentTaskService 任务生命周期", () => { function createMutationService() { const current = { id: "task-cas", + ownerScriptUuid: "script-a", generation: "generation-current", revision: 3, name: "current", @@ -142,6 +143,7 @@ describe("AgentTaskService 任务生命周期", () => { } as const; const taskRepo = { getTask: vi.fn().mockResolvedValue(current), + listTasks: vi.fn().mockResolvedValue([current]), createTask: vi.fn(async (candidate: any) => candidate), saveTask: vi.fn(async (candidate: any) => { if (candidate.generation !== current.generation || candidate.revision !== current.revision) { @@ -195,6 +197,55 @@ describe("AgentTaskService 任务生命周期", () => { expect(taskRepo.saveTask).toHaveBeenCalledWith(expect.objectContaining({ revision: 2 })); }); + it("脚本创建的任务只允许同一脚本读取和修改", async () => { + const { service, taskRepo, scheduler, current } = createMutationService(); + const other = { ...current, id: "task-other", ownerScriptUuid: "script-b" }; + taskRepo.listTasks.mockResolvedValue([current, other]); + + await expect(service.handleAgentTask({ action: "list" }, "script-a")).resolves.toEqual([current]); + await expect(service.handleAgentTask({ action: "get", id: current.id }, "script-b")).rejects.toThrow( + "Task not found" + ); + await expect( + service.handleAgentTask( + { + action: "update", + id: current.id, + generation: current.generation, + revision: current.revision, + task: { name: "forged edit" }, + }, + "script-b" + ) + ).rejects.toThrow("Task not found"); + await expect(service.handleAgentTask({ action: "runNow", id: current.id }, "script-b")).rejects.toThrow( + "Task not found" + ); + expect(scheduler.executeTask).not.toHaveBeenCalled(); + }); + + it("脚本创建的任务绑定创建者身份而不是请求体伪造的身份", async () => { + const { service, taskRepo } = createMutationService(); + + await service.handleAgentTask( + { + action: "create", + task: { + name: "owned task", + mode: "internal", + crontab: "0 9 * * *", + prompt: "hello", + enabled: true, + notify: false, + ownerScriptUuid: "script-b", + }, + } as any, + "script-a" + ); + + expect(taskRepo.createTask).toHaveBeenCalledWith(expect.objectContaining({ ownerScriptUuid: "script-a" })); + }); + it("delete 应先取消活动执行并使用客户端版本删除", async () => { const { service, taskRepo, scheduler } = createMutationService(); diff --git a/src/app/service/agent/service_worker/task_service.ts b/src/app/service/agent/service_worker/task_service.ts index 425787c20..184354286 100644 --- a/src/app/service/agent/service_worker/task_service.ts +++ b/src/app/service/agent/service_worker/task_service.ts @@ -265,17 +265,38 @@ export class AgentTaskService { }; } + private taskBelongsTo(task: AgentTask, ownerScriptUuid: string): boolean { + return ( + task.ownerScriptUuid === ownerScriptUuid || + (task.ownerScriptUuid === undefined && task.mode === "event" && task.sourceScriptUuid === ownerScriptUuid) + ); + } + + private assertTaskAccess(task: AgentTask | undefined, ownerScriptUuid: string | undefined): AgentTask { + if (!task || (ownerScriptUuid !== undefined && !this.taskBelongsTo(task, ownerScriptUuid))) { + throw new Error("Task not found"); + } + return task; + } + // 处理定时任务 CRUD 及 run 操作 - async handleAgentTask(params: AgentTaskApiRequest): Promise { + async handleAgentTask(params: AgentTaskApiRequest, ownerScriptUuid?: string): Promise { switch (params.action) { - case "list": - return this.taskRepo.listTasks(); - case "get": - return this.taskRepo.getTask(params.id); + case "list": { + const tasks = await this.taskRepo.listTasks(); + return ownerScriptUuid === undefined + ? tasks + : tasks.filter((task) => this.taskBelongsTo(task, ownerScriptUuid)); + } + case "get": { + const task = await this.taskRepo.getTask(params.id); + return this.assertTaskAccess(task, ownerScriptUuid); + } case "create": { const now = Date.now(); const task = { ...params.task, + ownerScriptUuid, id: uuidv4(), createtime: now, updatetime: now, @@ -300,11 +321,11 @@ export class AgentTaskService { return this.taskRepo.createTask(task); } case "update": { - const existing = await this.taskRepo.getTask(params.id); - if (!existing) throw new Error("Task not found"); + const existing = this.assertTaskAccess(await this.taskRepo.getTask(params.id), ownerScriptUuid); const updated = { ...existing, ...params.task, + ownerScriptUuid: existing.ownerScriptUuid ?? ownerScriptUuid, id: params.id, generation: params.generation, revision: params.revision, @@ -332,17 +353,17 @@ export class AgentTaskService { return this.taskRepo.saveTask(updated); } case "delete": { + const task = this.assertTaskAccess(await this.taskRepo.getTask(params.id), ownerScriptUuid); // 先中止正在运行的执行,再清理元数据/运行记录:cancelTask 是同步的 abort(),必须最先 // 发生,否则被删除的任务会在 removeTask(含 run-history 清理)完成前继续调用 LLM/工具/ // 产生外部副作用;若 removeTask 之后才 cancel,一旦 removeTask 因清理失败而抛出, // cancelTask 根本不会被调用,执行也就永远不会被中止 - this.taskScheduler?.cancelTask(params.id); + this.taskScheduler?.cancelTask(task.id); await this.taskRepo.removeTask(params.id, params.generation, params.revision); return true; } case "enable": { - const task = await this.taskRepo.getTask(params.id); - if (!task) throw new Error("Task not found"); + const task = this.assertTaskAccess(await this.taskRepo.getTask(params.id), ownerScriptUuid); const updated = { ...task, enabled: params.enabled, @@ -361,19 +382,22 @@ export class AgentTaskService { return this.taskRepo.saveTask(updated); } case "runNow": { - const task = await this.taskRepo.getTask(params.id); - if (!task) throw new Error("Task not found"); + const task = this.assertTaskAccess(await this.taskRepo.getTask(params.id), ownerScriptUuid); // 不 await,立即返回 const now = Date.now(); const claimScheduled = Boolean(task.enabled && task.nextruntime && task.nextruntime <= now); this.taskScheduler?.executeTask(task, claimScheduled, now).catch(() => {}); return true; } - case "listRuns": + case "listRuns": { + this.assertTaskAccess(await this.taskRepo.getTask(params.taskId), ownerScriptUuid); return this.taskRunRepo.listRuns(params.taskId, params.limit); - case "clearRuns": + } + case "clearRuns": { + this.assertTaskAccess(await this.taskRepo.getTask(params.taskId), ownerScriptUuid); await this.taskRunRepo.clearRuns(params.taskId); return true; + } default: throw new Error(`Unknown agentTask action: ${(params as any).action}`); } diff --git a/src/app/service/agent/service_worker/test-helpers.ts b/src/app/service/agent/service_worker/test-helpers.ts index 3944c5e4a..38422e813 100644 --- a/src/app/service/agent/service_worker/test-helpers.ts +++ b/src/app/service/agent/service_worker/test-helpers.ts @@ -90,6 +90,7 @@ export function createTestService() { getTasks: vi.fn().mockResolvedValue([]), getTaskSnapshot: vi.fn().mockResolvedValue({ generation: "test-generation", revision: 0, tasks: [] }), saveTasks: vi.fn().mockResolvedValue(undefined), + isAttachmentAccessibleToScript: vi.fn().mockResolvedValue(true), getAttachment: vi.fn().mockResolvedValue(null), saveAttachment: vi.fn().mockResolvedValue(0), deleteAttachment: vi.fn().mockResolvedValue(undefined), diff --git a/src/app/service/content/create_context.test.ts b/src/app/service/content/create_context.test.ts index 058e59c95..b727499c9 100644 --- a/src/app/service/content/create_context.test.ts +++ b/src/app/service/content/create_context.test.ts @@ -2,7 +2,9 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { ScriptLoadInfo, TScriptInfo } from "@App/app/repo/scripts"; import { encodeRValue } from "@App/pkg/utils/message_value"; import { createContext, createProxyContext, shouldFnBind, type RealmRoots } from "./create_context"; +import { GMContextApiGet } from "./gm_api/gm_context"; import { trimScriptInfo } from "./utils"; +import { Native } from "./global"; type AnyRecord = Record; @@ -304,6 +306,155 @@ describe("shouldFnBind", () => { }); describe("createContext: capability and lifecycle contract", () => { + it("does not expose broker state on the script-facing context", () => { + const context = createTestContext(["GM_getValue"]); + + expect(context).not.toHaveProperty("message"); + expect(context).not.toHaveProperty("scriptRes"); + expect(context).not.toHaveProperty("valueChangeListener"); + expect(context).not.toHaveProperty("EE"); + expect(context).not.toHaveProperty("grantSet"); + }); + + it("does not let page prototype pollution hide granted APIs", () => { + const descriptor = Object.getOwnPropertyDescriptor(Object.prototype, "GM_getValue"); + try { + Object.defineProperty(Object.prototype, "GM_getValue", { + configurable: true, + value: true, + }); + + const context = createTestContext(["GM_getValue"]); + + expect(context.GM_getValue).toBeTypeOf("function"); + } finally { + if (descriptor) Object.defineProperty(Object.prototype, "GM_getValue", descriptor); + else Reflect.deleteProperty(Object.prototype, "GM_getValue"); + } + }); + + it("creates collection instances from frozen captured-method subclasses", () => { + const set = new Native.Set(["grant"]); + const map = new Native.Map(); + const weakMap = new Native.WeakMap(); + + expect(set).toBeInstanceOf(Native.Set); + expect(map).toBeInstanceOf(Native.Map); + expect(weakMap).toBeInstanceOf(Native.WeakMap); + expect(Object.hasOwn(Object.getPrototypeOf(set), "add")).toBe(true); + expect(Object.hasOwn(Object.getPrototypeOf(map), "get")).toBe(true); + expect(Object.hasOwn(Object.getPrototypeOf(weakMap), "get")).toBe(true); + expect(Object.isFrozen(Object.getPrototypeOf(set))).toBe(true); + expect(Object.isFrozen(Object.getPrototypeOf(map))).toBe(true); + expect(Object.isFrozen(Object.getPrototypeOf(weakMap))).toBe(true); + }); + + it("keeps grant construction on captured Set and iterator intrinsics", () => { + const NativeSet = Set; + const nativeArrayIsArray = Array.isArray; + const nativeArrayIterator = Array.prototype[Symbol.iterator]; + const nativeSetIterator = Set.prototype[Symbol.iterator]; + const grants = new NativeSet(); + NativeSet.prototype.add.call(grants, "GM_getValue"); + const poisonedIterator = function () { + let first = true; + return { + next() { + if (!first) return { value: undefined, done: true }; + first = false; + return { value: "GM_cookie", done: false }; + }, + }; + }; + try { + Array.isArray = (() => false) as unknown as typeof Array.isArray; + Object.defineProperty(Array.prototype, Symbol.iterator, { configurable: true, value: poisonedIterator }); + Object.defineProperty(NativeSet.prototype, Symbol.iterator, { configurable: true, value: poisonedIterator }); + (globalThis as typeof globalThis & { Set: typeof Set }).Set = class PoisonedSet { + constructor() { + throw new Error("page replaced Set"); + } + } as unknown as typeof Set; + + const arrayBackedSet = new Native.Set(["GM_getValue"]); + expect(arrayBackedSet.has("GM_getValue")).toBe(true); + + const context = createContext( + createScriptInfo({ grant: ["GM_getValue"] }), + { script: { name: "create-context-test" }, scriptMetaStr: "" }, + "vitest", + undefined as any, + undefined as any, + grants + ); + + expect(context.GM_getValue).toBeTypeOf("function"); + expect(context.GM_cookie).toBeUndefined(); + } finally { + Array.isArray = nativeArrayIsArray; + Object.defineProperty(Array.prototype, Symbol.iterator, { configurable: true, value: nativeArrayIterator }); + Object.defineProperty(NativeSet.prototype, Symbol.iterator, { configurable: true, value: nativeSetIterator }); + (globalThis as typeof globalThis & { Set: typeof Set }).Set = NativeSet; + } + }); + + it("uses the service-worker execution run flag for value acknowledgments", async () => { + const script = { + ...createScriptInfo({ grant: ["GM_setValue"] }), + executionRunFlag: "canonical-run", + } as TScriptInfo; + const message = { + sendMessage: vi.fn().mockResolvedValue({ code: 0, data: "bar" }), + }; + const context = createContext( + script, + { script: { name: "create-context-test" }, scriptMetaStr: "" }, + "vitest", + message as any, + undefined as any, + new Set(["GM_setValue"]) + ); + + context.GM_setValue("foo", "next"); + expect(message.sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ runFlag: "canonical-run" }), + }) + ); + }); + + it("installs capabilities without looking up a page-patchable Function.prototype.bind", () => { + const apiValues = GMContextApiGet("GM_getValue")!; + const originalApi = apiValues[0].api; + const replacement = function (_ctx: unknown, key: string, fallback?: unknown) { + return fallback; + }; + Object.defineProperty(replacement, "bind", { configurable: true, value: undefined }); + apiValues[0].api = replacement; + try { + const context = createTestContext(["GM_getValue"]); + expect(context.GM_getValue("key", "fallback")).toBe("fallback"); + } finally { + apiValues[0].api = originalApi; + } + }); + + it("uses captured object operations when page code replaces assign and keys", () => { + const assign = vi.spyOn(Object, "assign").mockImplementation(() => { + throw new Error("page replacement"); + }); + const keys = vi.spyOn(Object, "keys").mockImplementation(() => { + throw new Error("page replacement"); + }); + try { + const context = createTestContext(["GM_getValue"]); + expect(context.GM_getValue("foo", "fallback")).toBe("bar"); + } finally { + assign.mockRestore(); + keys.mockRestore(); + } + }); + const resourceGrantChecks: Array<{ grant: string; read: (context: ReturnType) => unknown; @@ -355,9 +506,6 @@ describe("createContext: capability and lifecycle contract", () => { expect(context.GM_cookie.list).toBeTypeOf("function"); expect(context.GM_cookie.delete).toBeTypeOf("function"); expect(context.not_exist).toBeUndefined(); - expect(context.grantSet.has("not_exist")).toBe(false); - expect(context.grantSet.has("GM_getValue")).toBe(true); - expect(context.grantSet.has("GM.getValue")).toBe(true); }); it.each(["GM.cookie", "GM_cookie"] as const)("雙向注入 cookie API:輸入 %s 時兩種公開形狀都可用", (grant) => { @@ -371,8 +519,6 @@ describe("createContext: capability and lifecycle contract", () => { expect(context.GM_cookie.set).toBeTypeOf("function"); expect(context.GM_cookie.list).toBeTypeOf("function"); expect(context.GM_cookie.delete).toBeTypeOf("function"); - expect(context.grantSet.has("GM.cookie")).toBe(true); - expect(context.grantSet.has("GM_cookie")).toBe(true); }); it("將 window grant 留在 context.window,投影時才暴露到 sandbox", () => { @@ -399,8 +545,7 @@ describe("createContext: capability and lifecycle contract", () => { await Promise.resolve(); expect(loaded).toBe(false); - const loadScriptResolve = (context as unknown as AnyRecord).loadScriptResolve as () => void; - loadScriptResolve(); + context.resolveLoadScript(); await loadedPromise; expect(loaded).toBe(true); }); @@ -439,20 +584,31 @@ describe("createContext: capability and lifecycle contract", () => { update("remote-1", "next", 7); expect(listener).toHaveBeenCalledWith("foo", "bar", "next", true, 7); - const contextValues = context as unknown as AnyRecord; - const runFlag = contextValues.runFlag; context.setInvalidContext(); context.setInvalidContext(); expect(context.isInvalidContext()).toBe(true); - expect(contextValues.runFlag).not.toBe(runFlag); - expect(contextValues.runFlag).toContain("(invalid)"); - expect(contextValues.message).toBeNull(); - expect(contextValues.scriptRes).toBeNull(); update("remote-2", "again", 8); expect(listener).toHaveBeenCalledTimes(1); }); + + it("事件回调收到独立快照,不能改写传输中的事件数据", () => { + const context = createTestContext(["CAT.agent.task"]); + let observed: { nested: { value: number } } | undefined; + const received = vi.fn((data: { nested: { value: number } }) => { + observed = { nested: { value: data.nested.value } }; + data.nested.value = 99; + }); + + context.CAT.agent.task.addListener("task-a", received); + const eventData = { nested: { value: 1 } }; + context.emitEvent("agentTask", "task-a", eventData); + + expect(received).toHaveBeenCalledTimes(1); + expect(observed).toEqual({ nested: { value: 1 } }); + expect(eventData).toEqual({ nested: { value: 1 } }); + }); }); describe.sequential("createProxyContext: module default split roots", () => { @@ -904,6 +1060,25 @@ describe("createProxyContext: deterministic realm contract", () => { expect(third).toHaveBeenCalledTimes(1); }); + it("事件 callback 的 call 屬性被頁面改寫時仍保留 sandbox this", () => { + const fixture = createSplitRealmRoots(); + const sandbox = createProxyContext(Object.create(null), fixture.roots); + const handler = vi.fn(function (this: unknown) { + expect(this).toBe(sandbox); + }); + Object.defineProperty(handler, "call", { + configurable: true, + value: () => { + throw new Error("poisoned call"); + }, + }); + + sandbox.onload = handler; + fixture.hostWindow.dispatchEvent(new fixture.TestEvent("load")); + + expect(handler).toHaveBeenCalledTimes(1); + }); + it("split realm 下 self/window/globalThis 寫入都留在當前 sandbox", () => { const fixture = createSplitRealmRoots(); const sandbox = createProxyContext(Object.create(null), fixture.roots); diff --git a/src/app/service/content/create_context.ts b/src/app/service/content/create_context.ts index 44d4ccf20..75333db34 100644 --- a/src/app/service/content/create_context.ts +++ b/src/app/service/content/create_context.ts @@ -6,11 +6,56 @@ import { GMContextApiGet, protect } from "./gm_api/gm_context"; import { getGrantCandidates } from "./gm_api/grant"; import { isEarlyStartScript } from "./utils"; import { ListenerManager } from "./listener_manager"; -import { createGMBase } from "./gm_api/gm_api"; +import { createGMBase, type IGM_Base } from "./gm_api/gm_api"; import { attachNavigateHandler, type UrlChangeEvent } from "./gm_api/navigation_handle"; +import { nativeCall, Native } from "./global"; + +const createCapability = (api: (...args: any[]) => any, receiver: object) => { + // 由闭包提供上下文,脚本侧只传 API 自身的参数。 + /* eslint-disable prefer-rest-params -- 以固定参数转发保留调用参数数量,避免每次调用创建 rest 数组。 */ + const capability = function (this: unknown) { + switch (arguments.length) { + case 0: + return api(receiver); + case 1: + return api(receiver, arguments[0]); + case 2: + return api(receiver, arguments[0], arguments[1]); + case 3: + return api(receiver, arguments[0], arguments[1], arguments[2]); + case 4: + return api(receiver, arguments[0], arguments[1], arguments[2], arguments[3]); + default: { + const args = new Array(arguments.length + 1); + args[0] = receiver; + for (let i = 0; i < arguments.length; i += 1) args[i + 1] = arguments[i]; + return Native.reflectApply(api, undefined, args); + } + } + }; + /* eslint-enable prefer-rest-params */ + Native.objectDefineProperty(capability, "name", { + configurable: true, + value: api.name, + }); + Native.objectDefineProperty(capability, "length", { configurable: true, value: 0 }); + return capability; +}; // 不要使用 {}, 改使用 Object.create(null) - 避免在页面生成沙盒时,受到 Object.prototype 被注入的影响 +export type ScriptContext = IGM_Base & { + [key: string]: any; + setExecutionRunFlag(runFlag: string): void; + resolveLoadScript(): void; +}; + +type InternalScriptContext = IGM_Base & { + [key: string]: any; + runFlag: string; + loadScriptResolve?: () => void; +}; + // 构建沙盒上下文 export const createContext = ( scriptRes: TScriptInfo, @@ -20,6 +65,8 @@ export const createContext = ( contentMsg: Message, scriptGrants: Set ) => { + // 复制授权集合并使用捕获的 Set 实现,避免页面改写迭代器后影响 API 注入。 + const scriptGrantSet = new Native.Set(scriptGrants); // 按照GMApi构建 const valueChangeListener = new ListenerManager(); const EE = new EventEmitter(); @@ -32,7 +79,7 @@ export const createContext = ( }); } let invalid = false; - const GM = Object.create(null); + const GM = Native.objectCreate(null); GM.info = GMInfo; const context = createGMBase({ prefix: envPrefix, @@ -41,12 +88,12 @@ export const createContext = ( scriptRes, valueChangeListener, EE, - runFlag: uuidv4(), + runFlag: scriptRes.executionRunFlag || uuidv4(), eventId: 10000, GM: GM, GM_info: GMInfo, - window: Object.create(null), - grantSet: new Set(), + window: Native.objectCreate(null), + grantSet: new Native.Set(), loadScriptPromise, loadScriptResolve, setInvalidContext() { @@ -64,48 +111,91 @@ export const createContext = ( isInvalidContext() { return invalid; }, + }) as unknown as InternalScriptContext; + const publicContext = Native.objectCreate(null) as ScriptContext; + publicContext.GM = GM; + publicContext.GM_info = GMInfo; + publicContext.window = Native.objectCreate(null); + publicContext.unsafeWindow = window; + + // 生命周期方法只供隔离执行器使用,不进入脚本可枚举的 facade。 + Native.objectDefineProperty(publicContext, "valueUpdate", { + configurable: false, + enumerable: false, + value: (data: any) => context.valueUpdate(data), + }); + Native.objectDefineProperty(publicContext, "emitEvent", { + configurable: false, + enumerable: false, + value: (event: string, eventId: string, data: any) => context.emitEvent(event, eventId, data), + }); + Native.objectDefineProperty(publicContext, "setInvalidContext", { + configurable: false, + enumerable: false, + value: () => context.setInvalidContext(), + }); + Native.objectDefineProperty(publicContext, "isInvalidContext", { + configurable: false, + enumerable: false, + value: () => context.isInvalidContext(), + }); + Native.objectDefineProperty(publicContext, "setExecutionRunFlag", { + configurable: false, + enumerable: false, + value: (runFlag: string) => { + context.runFlag = runFlag; + }, + }); + Native.objectDefineProperty(publicContext, "resolveLoadScript", { + configurable: false, + enumerable: false, + value: () => context.loadScriptResolve?.(), }); - const grantedAPIs: { [key: string]: any } = Object.create(null); + + const grantedAPIs: { [key: string]: any } = Native.objectCreate(null); const __methodInject__ = (grant: string): boolean => { const grantSet: Set = context.grantSet; const s = GMContextApiGet(grant); if (!s) return false; // @grant 的定义未实现,略过 (返回 false 表示 @grant 不存在) if (grantSet.has(grant)) return true; // 重复的@grant,略过 (返回 true 表示 @grant 存在) grantSet.add(grant); - for (const { fnKey, api, param } of s) { - grantedAPIs[fnKey] = api.bind(context); + for (let i = 0; i < s.length; i += 1) { + const { fnKey, api, param } = s[i]; + grantedAPIs[fnKey] = createCapability(api, context); const depend = param?.depend; if (depend) { - for (const grant of depend) { - __methodInject__(grant); - } + for (let j = 0; j < depend.length; j += 1) __methodInject__(depend[j]); } } return true; }; - for (const grant of scriptGrants) { - for (const candidate of getGrantCandidates(grant)) { + // 只能调用捕获的 forEach;此处不依赖页面提供的 Set iterator。 + scriptGrantSet.forEach((grant) => { + const candidates = getGrantCandidates(String(grant)); + for (let i = 0; i < candidates.length; i += 1) { + const candidate = candidates[i]; __methodInject__(candidate); } - } + }); // 兼容GM.Cookie.* - for (const fnKey of Object.keys(grantedAPIs)) { + const grantedKeys = Native.objectKeys(grantedAPIs); + for (let i = 0; i < grantedKeys.length; i += 1) { + const fnKey = grantedKeys[i]; const fnKeyArray = fnKey.split("."); const m = fnKeyArray.length; - let g = context; + let g = publicContext; let s = ""; for (let i = 0; i < m; i++) { const part = fnKeyArray[i]; s += `${i ? "." : ""}${part}`; - g = g[part] || (g[part] = grantedAPIs[s] || Object.create(null)); + g = g[part] || (g[part] = grantedAPIs[s] || Native.objectCreate(null)); } } - context.unsafeWindow = window; - if (scriptGrants.has("window.onurlchange") && context.onurlchange === undefined) { - context.onurlchange = null; + if (scriptGrantSet.has("window.onurlchange") && context.onurlchange === undefined) { + publicContext.onurlchange = null; attachNavigateHandler(window as any); } - return context; + return publicContext; }; const noEval = false; @@ -161,11 +251,13 @@ const getAllPropertyDescriptors = ( callback: (key: string | symbol, descriptor: PropertyDescriptor) => void ) => { while (obj && obj !== Object) { - const descs = Object.getOwnPropertyDescriptors(obj); - for (const key of Reflect.ownKeys(descs)) { + const descs = Native.objectGetOwnPropertyDescriptors(obj); + const keys = Native.reflectOwnKeys(descs); + for (let i = 0; i < keys.length; i += 1) { + const key = keys[i]; callback(key, descs[key as keyof typeof descs]); } - obj = Object.getPrototypeOf(obj); + obj = Native.objectGetPrototypeOf(obj); } }; @@ -178,21 +270,19 @@ const isConstructorOrInterface = (value: unknown) => { }; // 避免 host/Xray function 的 .bind lookup 不可靠 -const bindFn = Function.prototype.bind; - const materializeDescriptor = (descriptor: PropertyDescriptor, receiver: DescriptorOwner): PropertyDescriptor => { if ("value" in descriptor) { if (typeof descriptor.value !== "function" || isConstructorOrInterface(descriptor.value)) return descriptor; return { ...descriptor, - value: bindFn.call(descriptor.value, receiver), + value: Native.bind(descriptor.value, receiver), }; } if (!descriptor.get && !descriptor.set) return descriptor; return { ...descriptor, - get: descriptor.get ? bindFn.call(descriptor.get, receiver) : undefined, - set: descriptor.set ? bindFn.call(descriptor.set, receiver) : undefined, + get: descriptor.get ? Native.bind(descriptor.get, receiver) : undefined, + set: descriptor.set ? Native.bind(descriptor.set, receiver) : undefined, }; }; @@ -209,25 +299,27 @@ export type RealmRoots = { const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSnapshot => { // 在 CacheSet 加入的 propKeys 将会在 mySandbox 实装阶段时设置。 // 先处理的 descriptor 覆盖后续父类。 - const descsCache: Set = new Set(["eval", "window", "self", "globalThis", "top", "parent"]); + const descsCache: Set = new Native.Set(["eval", "window", "self", "globalThis", "top", "parent"]); // realmGlobal own descriptor 优先,hostWindow descriptor 只补足 host 成员。 - const initOwnDescs = Object.getOwnPropertyDescriptors(realmGlobal); + const initOwnDescs = Native.objectGetOwnPropertyDescriptors(realmGlobal); // overriddenDescs 将以物件 OwnPropertyDescriptor 方式进行物件属性修改。 // 覆盖原有的 OwnPropertyDescriptor 定义或父类的 PropertyDescriptor 定义。 - const overriddenDescs: DescriptorMap = Object.create(null); + const overriddenDescs: DescriptorMap = Native.objectCreate(null); // 记录原生 onxxxxx 的 property key。 - const eventKeys = new Set(); + const eventKeys = new Native.Set(); // 在 USE_PSEUDO_WINDOW 情况下,由于没有类的 prototype,父类的成员要手动传下去。 - const protoBaseDescs: DescriptorMap = Object.create(null); + const protoBaseDescs: DescriptorMap = Native.objectCreate(null); const collectRealmDescriptors = () => { // 只读取 realmGlobal own descriptors,避免混合 Firefox 的两个 realm。 - const descriptors = Object.getOwnPropertyDescriptors(realmGlobal); - for (const key of Object.keys(descriptors)) { + const descriptors = Native.objectGetOwnPropertyDescriptors(realmGlobal); + const keys = Native.objectKeys(descriptors); + for (let i = 0; i < keys.length; i += 1) { + const key = keys[i]; const desc = descriptors[key]; if (descsCache.has(key)) continue; descsCache.add(key); // realm own descriptors take precedence over host descriptors @@ -273,7 +365,7 @@ const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSn if (shouldFnBind(desc.value)) { overriddenDescs[key] = materializeDescriptor(desc, hostWindow); descsCache.add(key); - } else if (!(key in initOwnDescs) && !Object.hasOwn(realmGlobal, key) && !protoBaseDescs[key]) { + } else if (!(key in initOwnDescs) && !Native.objectHasOwn(realmGlobal, key) && !protoBaseDescs[key]) { protoBaseDescs[key] = materializeDescriptor(desc, hostWindow); } return; @@ -325,13 +417,13 @@ const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSn }); const sharedInitCopy = USE_PSEUDO_WINDOW - ? Object.create(null, { + ? Native.objectCreate(null, { ...protoBaseDescs, // 较快的 @unwrap 注入时有机会改变 EventTarget.prototype - ...Object.getOwnPropertyDescriptors(PseudoWindowPrototype), + ...Native.objectGetOwnPropertyDescriptors(PseudoWindowPrototype), ...initOwnDescs, ...overriddenDescs, }) - : Object.create(Object.getPrototypeOf(realmGlobal), { + : Native.objectCreate(Native.objectGetPrototypeOf(realmGlobal), { ...initOwnDescs, ...overriddenDescs, }); @@ -342,7 +434,7 @@ const createGlobalSnapshot = ({ realmGlobal, hostWindow }: RealmRoots): GlobalSn const defaultGlobalSnapshot = createGlobalSnapshot({ realmGlobal: global, hostWindow: window }); // 把沙盒的 console 和网页的 console 隔离 -const initConsoleDescs = Object.getOwnPropertyDescriptors(console); +const initConsoleDescs = Native.objectGetOwnPropertyDescriptors(console); const ConsolePrototype = Object.getPrototypeOf(console); type GMWorldContext = typeof globalThis & Record; @@ -359,12 +451,12 @@ export const createProxyContext = ( const { sharedInitCopy, eventKeys } = roots.realmGlobal === global && roots.hostWindow === window ? defaultGlobalSnapshot : createGlobalSnapshot(roots); - const ownDescs = Object.getOwnPropertyDescriptors(sharedInitCopy); + const ownDescs = Native.objectGetOwnPropertyDescriptors(sharedInitCopy); // mySandbox: ScriptCat各脚本独自使用 let mySandbox: typeof sharedInitCopy | undefined = undefined; - const hostAddEventListener = roots.hostWindow.addEventListener.bind(roots.hostWindow); - const hostRemoveEventListener = roots.hostWindow.removeEventListener.bind(roots.hostWindow); + const hostAddEventListener = Native.bind(roots.hostWindow.addEventListener, roots.hostWindow); + const hostRemoveEventListener = Native.bind(roots.hostWindow.removeEventListener, roots.hostWindow); // 用 eventHandling 机制模拟 onxxxxxxx 事件设置 // 监听事件实际上的方法是eventObject.handleEvent @@ -379,7 +471,7 @@ export const createProxyContext = ( hostRemoveEventListener(eventName, eventObject); this.fn = null; } else { - fn.call(mySandbox, event); + nativeCall(fn, mySandbox, event); } }, }; @@ -411,7 +503,13 @@ export const createProxyContext = ( }; }; - for (const key of eventKeys) { + // 事件键只需传入沙盒属性;先用捕获的 forEach 转成数组,避免跨 realm 读取 iterator。 + const eventKeyList: string[] = []; + eventKeys.forEach((key) => { + eventKeyList[eventKeyList.length] = String(key); + }); + for (let i = 0; i < eventKeyList.length; i += 1) { + const key = eventKeyList[i]; const eventSetterGetter = createEventProp(key); ownDescs[key] = { ...ownDescs[key], @@ -420,7 +518,9 @@ export const createProxyContext = ( } // split realm 下 hostWindow 可能经由 realmGlobal.window 暴露;这些别名必须始终留在当前 sandbox 内。 - for (const key of ["window", "self", "globalThis"]) { + const sandboxAliases = ["window", "self", "globalThis"]; + for (let i = 0; i < sandboxAliases.length; i += 1) { + const key = sandboxAliases[i]; ownDescs[key] = { configurable: true, enumerable: true, @@ -429,9 +529,11 @@ export const createProxyContext = ( }, }; } - for (const key of ["top", "parent", "frames"]) { + const windowAliases = ["top", "parent", "frames"]; + for (let i = 0; i < windowAliases.length; i += 1) { + const key = windowAliases[i]; const descriptor = ownDescs[key]; - const hostValue = Reflect.get(roots.hostWindow, key, roots.hostWindow); + const hostValue = Native.reflectGet(roots.hostWindow, key, roots.hostWindow); if (hostValue === undefined && !descriptor) continue; ownDescs[key] = { @@ -439,7 +541,7 @@ export const createProxyContext = ( configurable: true, enumerable: descriptor?.enumerable ?? true, get() { - const value = Reflect.get(roots.hostWindow, key, roots.hostWindow); + const value = Native.reflectGet(roots.hostWindow, key, roots.hostWindow); return value === roots.hostWindow || value === roots.realmGlobal ? mySandbox : value; }, set: undefined, @@ -472,20 +574,21 @@ export const createProxyContext = ( get() { return currentValue; }, - set(nv) { - if (typeof nv !== "function") nv = null; - currentValue = nv; + set(nv: unknown) { + currentValue = typeof nv === "function" ? (nv as (this: GlobalEventHandlers, ev: UrlChangeEvent) => any) : null; return true; }, }; } // 把初始Copy加上特殊变量后,生成一份新Copy - mySandbox = Object.create(Object.getPrototypeOf(sharedInitCopy), ownDescs) as typeof globalThis & + mySandbox = Native.objectCreate(Native.objectGetPrototypeOf(sharedInitCopy), ownDescs) as typeof globalThis & Record; // 处理特殊关键字,不能穿越出沙盒,也不能被外部修改 - for (const key of ["define", "module", "exports"]) { + const moduleKeys = ["define", "module", "exports"]; + for (let i = 0; i < moduleKeys.length; i += 1) { + const key = moduleKeys[i]; mySandbox[key] = undefined; } @@ -493,8 +596,10 @@ export const createProxyContext = ( // 把 GM Api (或其他全域API) 复制到 脚本window // 请手动检查避开key,防止与window的属性setter有冲突 或 属性名重复 - for (const key of Object.keys(context)) { - if (key in protect || key === "window") continue; + const contextKeys = Native.objectKeys(context); + for (let i = 0; i < contextKeys.length; i += 1) { + const key = contextKeys[i]; + if (Native.objectHasOwn(protect, key) || key === "window") continue; mySandbox[key] = context[key]; // window以外 } @@ -517,11 +622,11 @@ export const createProxyContext = ( const handle = function (this: Window & Record, e: UrlChangeEvent) { this.onurlchange?.(e); } as EventListener; - (roots.hostWindow).addEventListener("urlchange", handle.bind(mySandbox), false); + (roots.hostWindow).addEventListener("urlchange", Native.bind(handle, mySandbox), false); } // 从网页 console 隔离出来的沙盒 console - mySandbox.console = Object.create(ConsolePrototype, initConsoleDescs); + mySandbox.console = Native.objectCreate(ConsolePrototype, initConsoleDescs); return mySandbox; }; diff --git a/src/app/service/content/exec_script.test.ts b/src/app/service/content/exec_script.test.ts index 528999b35..01d9e9299 100644 --- a/src/app/service/content/exec_script.test.ts +++ b/src/app/service/content/exec_script.test.ts @@ -68,6 +68,22 @@ describe.concurrent("GM_info", () => { expect(ret.GM_info.script.version).toEqual("1.0.0"); expect(ret._this).not.toEqual(global); }); + + it.concurrent("does not resolve a mutable script function call property", async () => { + const { exec } = makeExec("return this;"); + const scriptFunc = function (_token: string, context: unknown) { + return context; + } as ScriptFunc & { call?: unknown }; + Object.defineProperty(scriptFunc, "call", { + configurable: true, + value: () => { + throw new Error("poisoned call"); + }, + }); + exec.scriptFunc = scriptFunc; + + expect(await exec.exec()).toBe(exec.execContext); + }); }); describe.concurrent("unsafeWindow", () => { diff --git a/src/app/service/content/exec_script.ts b/src/app/service/content/exec_script.ts index 6c5b93730..aca46a871 100644 --- a/src/app/service/content/exec_script.ts +++ b/src/app/service/content/exec_script.ts @@ -1,13 +1,16 @@ import LoggerCore from "@App/app/logger/core"; import type Logger from "@App/app/logger/logger"; -import { createContext, createProxyContext } from "./create_context"; +import { createContext, createProxyContext, type ScriptContext } from "./create_context"; import type { GMInfoEnv, ScriptFunc } from "./types"; import { compileScript, isContextMenuScript } from "./utils"; import type { Message } from "@Packages/message/types"; import type { ValueUpdateDataEncoded } from "./types"; import { evaluateGMInfo } from "./gm_api/gm_info"; -import type { IGM_Base } from "./gm_api/gm_api"; import type { TScriptInfo } from "@App/app/repo/scripts"; +import { Native } from "./global"; + +// 编译函数只在收到本次构建的密钥时执行,避免页面直接复用包装器。 +const fnStrIntegrity = process.env.SC_RANDOM_FNKEY!; // 执行脚本,控制脚本执行与停止 export default class ExecScript { @@ -19,7 +22,7 @@ export default class ExecScript { // proxyContext: typeof globalThis; - sandboxContext?: IGM_Base & { [key: string]: any }; + sandboxContext?: ScriptContext; named?: { [key: string]: any }; @@ -48,7 +51,7 @@ export default class ExecScript { } else { this.scriptFunc = code; } - const grantSet = new Set(scriptRes.metadata.grant || []); + const grantSet = new Native.Set(scriptRes.metadata.grant || []); if (isContextMenuScript(scriptRes.metadata)) { grantSet.add("GM_registerMenuCommand"); grantSet.delete("none"); @@ -57,14 +60,14 @@ export default class ExecScript { // 不注入任何GM api // ScriptCat行为:GM.info 和 GM_info 同时注入 // 在不改变 Context 的情况下,以 named 传入多个全域变量 - const GM = Object.create(null); + const GM = Native.objectCreate(null); GM.info = GM_info; this.named = { GM, GM_info }; } else { // 构建脚本GM上下文 this.sandboxContext = createContext(scriptRes, GM_info, envPrefix, message, contentMsg, grantSet); if (globalInjection) { - Object.assign(this.sandboxContext, globalInjection); + Native.objectAssign(this.sandboxContext, globalInjection); } } } @@ -88,15 +91,32 @@ export default class ExecScript { this.logger.debug("script start"); const sandboxContext = this.sandboxContext; this.execContext = sandboxContext ? createProxyContext(sandboxContext) : global; // this.$ 只能执行一次 - return this.scriptFunc.call(this.execContext, this.named, this.scriptRes.name); + return this.scriptFunc(fnStrIntegrity, this.execContext, this.named, this.scriptRes.name); }; // 早期启动的脚本,处理GM API - updateEarlyScriptGMInfo(envInfo: GMInfoEnv) { + updateEarlyScriptGMInfo(envInfo: GMInfoEnv, scriptInfo?: TScriptInfo) { + if (scriptInfo) { + // 预注入事件可被页面观察,只携带空的用户值和配置;pageLoad 到达后再补回权威副本。 + this.scriptRes.value = scriptInfo.value; + this.scriptRes.config = scriptInfo.config; + this.scriptRes.metadata = scriptInfo.metadata; + this.scriptRes.resource = scriptInfo.resource; + this.scriptRes.requireCssResource = scriptInfo.requireCssResource; + } + if (scriptInfo?.executionHandle && scriptInfo.executionEnvTag) { + // early-start 先执行后取得绑定;此处补写同一绑定,使后续 RPC 与首次注册一致。 + this.scriptRes.executionHandle = scriptInfo.executionHandle; + this.scriptRes.executionEnvTag = scriptInfo.executionEnvTag; + this.scriptRes.executionRunFlag = scriptInfo.executionRunFlag; + if (this.sandboxContext && scriptInfo.executionRunFlag) { + this.sandboxContext.setExecutionRunFlag(scriptInfo.executionRunFlag); + } + } let GM_info; if (this.sandboxContext) { // 触发loadScriptResolve - this.sandboxContext["loadScriptResolve"]?.(); + this.sandboxContext.resolveLoadScript(); GM_info = this.execContext["GM_info"]; } else { GM_info = this.named?.GM_info; diff --git a/src/app/service/content/external.ts b/src/app/service/content/external.ts index 2ff7aa223..8aa255266 100644 --- a/src/app/service/content/external.ts +++ b/src/app/service/content/external.ts @@ -14,10 +14,12 @@ const isExternalWhitelisted = (hostname: string) => { }; // 生成暴露给页面的 Scriptcat 外部接口 -const createScriptcatExpose = (msg: Message) => { +const createScriptcatExpose = (msg: Message, messagePrefix: string) => { const scriptExpose: App.ExternalScriptCat = { isInstalled(name: string, namespace: string, callback: (res: App.IsInstalledResponse | undefined) => unknown) { - sendMessage(msg, "scripting/script/isInstalled", { name, namespace }).then(callback); + sendMessage(msg, `${messagePrefix}/script/isInstalled`, { name, namespace }).then( + callback + ); }, }; return scriptExpose; @@ -63,7 +65,7 @@ const patchTampermonkeyIsInstalled = (external: any, scriptExpose: App.ExternalS }; // inject 环境 pageLoad 后执行:按白名单对页面注入 external 接口 -export const onInjectPageLoaded = (msg: Message) => { +export const onInjectPageLoaded = (msg: Message, messagePrefix = "scripting") => { const hostname = window.location.hostname; // 不在白名单则不对外暴露接口 @@ -73,7 +75,7 @@ export const onInjectPageLoaded = (msg: Message) => { const external: External = (window.external || (window.external = {} as External)) as External; // 创建 Scriptcat 暴露对象 - const scriptExpose = createScriptcatExpose(msg); + const scriptExpose = createScriptcatExpose(msg, messagePrefix); // 尝试设置 external.Scriptcat safeSetExternal(external, "Scriptcat", scriptExpose); diff --git a/src/app/service/content/global.ts b/src/app/service/content/global.ts index 3b78911ff..12c16d2e3 100644 --- a/src/app/service/content/global.ts +++ b/src/app/service/content/global.ts @@ -1,36 +1,189 @@ // 避免在全局页面环境中,内置处理函数被篡改或重写 -const unsupportedAPI = () => { - throw "unsupportedAPI"; + +// 在页面或用户脚本替换调用内建函数前完成捕获。 +export const nativeReflectApply = Reflect.apply; +const nativeFunctionBind = Function.prototype.bind; +// structuredClone 不用 bind globalThis; nativeStructuredClone 在初期化時捕获。 +const nativeStructuredClone = typeof structuredClone === "function" ? structuredClone : undefined; +const nativeSetConstructor = Set; +const nativeSetAdd = Set.prototype.add; +const nativeSetHas = Set.prototype.has; +const nativeSetDelete = Set.prototype.delete; +const nativeSetClear = Set.prototype.clear; +const nativeSetForEach = Set.prototype.forEach; +const nativeSetValues = Set.prototype.values; +const nativeArrayIsArray = Array.isArray; +const nativeMapConstructor = Map; +const nativeMapGet = Map.prototype.get; +const nativeMapSet = Map.prototype.set; +const nativeMapHas = Map.prototype.has; +const nativeMapDelete = Map.prototype.delete; +const nativeMapClear = Map.prototype.clear; +const nativeMapForEach = Map.prototype.forEach; +const nativeWeakMapConstructor = WeakMap; +const nativeWeakMapGet = WeakMap.prototype.get; +const nativeWeakMapSet = WeakMap.prototype.set; +const nativeWeakMapHas = WeakMap.prototype.has; +const nativeWeakMapDelete = WeakMap.prototype.delete; +const nativeObjectFreeze = Object.freeze; +const nativeReflectOwnKeys = Reflect.ownKeys; +const nativeObjectGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; +const nativeDocumentCreateElement = typeof Document === "undefined" ? undefined : Document.prototype.createElement; +const nativeOwnFragment = typeof DocumentFragment === "undefined" ? undefined : new DocumentFragment(); + +// Keep the captured methods on private subclasses. Instances can then be created +// without reassigning every method, while the subclass prototypes remain outside +// the page's mutable built-in prototypes. +const NativeSetConstructor = class extends nativeSetConstructor { + constructor(values?: readonly T[] | Set | null) { + // 不把 values 传给 Set 构造器:它会读取 values 的 @@iterator,而页面可改写该方法。 + super(); + if (nativeArrayIsArray(values)) { + for (let i = 0; i < values.length; i += 1) this.add(values[i]); + } else if (values) { + nativeReflectApply(nativeSetForEach, values, [(value: T) => this.add(value)]); + } + } }; +NativeSetConstructor.prototype.add = nativeSetAdd; +NativeSetConstructor.prototype.has = nativeSetHas; +NativeSetConstructor.prototype.delete = nativeSetDelete; +NativeSetConstructor.prototype.clear = nativeSetClear; +NativeSetConstructor.prototype.forEach = nativeSetForEach; +NativeSetConstructor.prototype.values = nativeSetValues; +nativeObjectFreeze(NativeSetConstructor.prototype); + +const NativeMapConstructor = class extends nativeMapConstructor {}; +NativeMapConstructor.prototype.get = nativeMapGet; +NativeMapConstructor.prototype.set = nativeMapSet; +NativeMapConstructor.prototype.has = nativeMapHas; +NativeMapConstructor.prototype.delete = nativeMapDelete; +NativeMapConstructor.prototype.clear = nativeMapClear; +NativeMapConstructor.prototype.forEach = nativeMapForEach; +nativeObjectFreeze(NativeMapConstructor.prototype); + +const NativeWeakMapConstructor = class extends nativeWeakMapConstructor {}; +NativeWeakMapConstructor.prototype.get = nativeWeakMapGet; +NativeWeakMapConstructor.prototype.set = nativeWeakMapSet; +NativeWeakMapConstructor.prototype.has = nativeWeakMapHas; +NativeWeakMapConstructor.prototype.delete = nativeWeakMapDelete; +nativeObjectFreeze(NativeWeakMapConstructor.prototype); + +const nativeFunctionApply = nativeReflectApply(nativeFunctionBind, Function.prototype.apply, [ + Function.prototype.apply, +]) as (fn: (...args: any[]) => any, receiver: any, args: any[]) => any; +const nativeFunctionCall = nativeReflectApply(nativeFunctionBind, Function.prototype.call, [ + Function.prototype.call, +]) as (fn: (...args: any[]) => any, receiver: any, ...args: any[]) => any; + +export const nativeApply = nativeFunctionApply; +export const nativeCall = nativeFunctionCall; +export const nativeBind = (fn: (...args: any[]) => any, receiver: any, ...args: any[]) => + nativeFunctionCall(nativeFunctionBind, fn, receiver, ...args); export const Native = { - structuredClone: typeof structuredClone === "function" ? structuredClone : unsupportedAPI, - jsonStringify: JSON.stringify.bind(JSON), - jsonParse: JSON.parse.bind(JSON), - createElement: Document.prototype.createElement, - ownFragment: new DocumentFragment(), - objectCreate: Object.create.bind(Object), - objectGetOwnPropertyDescriptors: Object.getOwnPropertyDescriptors.bind(Object), - objectGetOwnPropertyDescriptor: Object.getOwnPropertyDescriptor.bind(Object), - objectGetPrototypeOf: Object.getPrototypeOf.bind(Object), + Set: NativeSetConstructor, + Map: NativeMapConstructor, + WeakMap: NativeWeakMapConstructor, + bind: nativeBind, + reflectApply: nativeReflectApply, + structuredClone: nativeStructuredClone, + jsonStringify: nativeBind(JSON.stringify, JSON), + jsonParse: nativeBind(JSON.parse, JSON), + createElement: nativeDocumentCreateElement, + ownFragment: nativeOwnFragment, + objectCreate: nativeBind(Object.create, Object), + objectAssign: nativeBind(Object.assign, Object), + arrayIsArray: nativeArrayIsArray, + objectKeys: nativeBind(Object.keys, Object), + objectHasOwn: nativeBind(Object.hasOwn, Object), + objectDefineProperty: nativeBind(Object.defineProperty, Object), + objectGetOwnPropertyDescriptors: nativeBind(Object.getOwnPropertyDescriptors, Object), + objectGetOwnPropertyDescriptor: nativeBind(Object.getOwnPropertyDescriptor, Object), + objectGetPrototypeOf: nativeBind(Object.getPrototypeOf, Object), + reflectOwnKeys: nativeBind(Reflect.ownKeys, Reflect), + reflectGet: nativeBind(Reflect.get, Reflect), } as const; export const customClone = (o: any) => { - // 非对象类型直接返回(包含 Symbol、undefined、基本类型等) + // 非对象类型直接返回(包含 undefined、基本类型等);函数和 Symbol 不可跨边界传输。 // 接受参数:阵列、物件、null - if (typeof o !== "object") return o; + if (o === null || typeof o !== "object") { + return typeof o === "function" || typeof o === "symbol" ? undefined : o; + } - try { - // 优先使用 structuredClone,支持大多数可克隆对象 - return Native.structuredClone(o); - } catch { - // 例如:被 Proxy 包装的对象(如 Vue 等框架处理过的 reactive 对象) - // structuredClone 可能会失败,忽略错误继续尝试其他方式 + // 先验证自有字段都是数据描述符,避免 JSON fallback 执行页面 getter 或 Proxy trap。 + const seen = new Native.WeakMap(); + const isDataOnly = (value: object): boolean => { + if (seen.has(value)) return true; + seen.set(value, true); + + // Map/Set 条目不在自有属性中,必须先检查,避免 structuredClone 遍历时触发嵌套访问器。 + try { + let valid = true; + nativeReflectApply(nativeMapForEach, value as Map, [ + (key: unknown, entry: unknown) => { + if (valid && (!isDataOnlyValue(key) || !isDataOnlyValue(entry))) valid = false; + }, + ]); + return valid; + } catch { + // 不是 Map,继续检查普通自有属性。 + } + try { + let valid = true; + nativeReflectApply(nativeSetForEach, value as Set, [ + (entry: unknown) => { + if (valid && !isDataOnlyValue(entry)) valid = false; + }, + ]); + return valid; + } catch { + // 不是 Set,继续检查普通自有属性。 + } + + let keys: PropertyKey[]; + try { + keys = nativeReflectOwnKeys(value); + } catch { + return false; + } + for (const key of keys) { + if (typeof key === "symbol") return false; + let descriptor: PropertyDescriptor | undefined; + try { + descriptor = nativeObjectGetOwnPropertyDescriptor(value, key); + } catch { + return false; + } + if (!descriptor || !("value" in descriptor)) return false; + if ( + typeof descriptor.value === "function" || + (descriptor.value !== null && typeof descriptor.value === "object" && !isDataOnly(descriptor.value)) + ) { + return false; + } + } + return true; + }; + const isDataOnlyValue = (value: unknown): boolean => { + if (value === null || typeof value !== "object") return true; + return isDataOnly(value); + }; + if (!isDataOnly(o)) return undefined; + + if (nativeStructuredClone) { + try { + // 优先使用 structuredClone,支持大多数可克隆对象 + return nativeStructuredClone(o); + } catch { + // structuredClone 拒绝的值不再退回会执行 getter 的 JSON 序列化。 + return undefined; + } } try { - // 退而求其次,使用 JSON 序列化方式进行深拷贝 - // 仅适用于可被 JSON 表示的普通对象 + // 旧浏览器没有 structuredClone 时,只复制已验证的数据属性。 return Native.jsonParse(Native.jsonStringify(o)); } catch { // 序列化失败,忽略错误 diff --git a/src/app/service/content/gm_api/cat_agent.test.ts b/src/app/service/content/gm_api/cat_agent.test.ts index 1ea74a921..18dfa728f 100644 --- a/src/app/service/content/gm_api/cat_agent.test.ts +++ b/src/app/service/content/gm_api/cat_agent.test.ts @@ -65,6 +65,33 @@ function createInstance( } describe("ConversationInstance 命令机制", () => { + it("不会把通用 GM 传输能力作为实例属性暴露", () => { + const { instance } = createInstance(); + const ownNames = Object.getOwnPropertyNames(instance); + + expect(ownNames).not.toContain("gmSendMessage"); + expect(ownNames).not.toContain("gmConnect"); + expect(ownNames).not.toContain("conv"); + expect(ownNames).not.toContain("scriptUuid"); + expect(ownNames).not.toContain("toolHandlers"); + expect(ownNames).not.toContain("toolDefs"); + expect(ownNames).not.toContain("messageHistory"); + }); + + it("does not let public mutation replace private conversation state", async () => { + const { instance } = createEphemeralInstance(); + const exposed = instance as unknown as Record; + exposed.messageHistory = [{ role: "user", content: "forged" }]; + exposed.toolHandlers = new Map([["forged", vi.fn()]]); + exposed.toolDefs = [{ name: "forged", description: "forged", parameters: {} }]; + + await instance.chat("real"); + + const messages = await instance.getMessages(); + expect(messages[0]).toMatchObject({ role: "user", content: "real" }); + expect(messages).not.toContainEqual({ role: "user", content: "forged" }); + }); + it("内置 /new 命令清空消息历史", async () => { const { instance, gmSendMessage } = createInstance(); diff --git a/src/app/service/content/gm_api/cat_agent.ts b/src/app/service/content/gm_api/cat_agent.ts index 8f63e06cd..4c6911def 100644 --- a/src/app/service/content/gm_api/cat_agent.ts +++ b/src/app/service/content/gm_api/cat_agent.ts @@ -18,6 +18,7 @@ import type { MessageContent, } from "@App/app/service/agent/core/types"; import { getTextContent } from "@App/app/service/agent/core/content_utils"; +import { Native } from "../global"; export type ConversationStreamChunk = | StreamChunk @@ -81,27 +82,36 @@ function resolveToolCall( // 对话实例,暴露给用户脚本 // 导出供测试使用 +type ConversationPrivateState = { + conv: Conversation; + gmSendMessage: (api: string, params: any[]) => Promise; + gmConnect: (api: string, params: any[]) => Promise; + scriptUuid: string; + commandHandlers: Map; + cache?: boolean; + systemPrompt?: string; + background: boolean; +}; + export class ConversationInstance { - public toolHandlers: Map = new Map(); - public toolDefs: ToolDefinition[] = []; - private commandHandlers: Map = new Map(); - public ephemeral: boolean; - private cache?: boolean; - private systemPrompt?: string; - public messageHistory: Array<{ + // 私有状态包含跨 context 的发送函数;用 private field 隐藏它,避免脚本读取或替换传输入口。 + #state: ConversationPrivateState; + + #toolHandlers: Map = new Map(); + #toolDefs: ToolDefinition[] = []; + #ephemeral: boolean; + #messageHistory: Array<{ role: MessageRole; content: MessageContent; toolCallId?: string; toolCalls?: ToolCall[]; }> = []; - private background: boolean; - constructor( - private conv: Conversation, - private gmSendMessage: (api: string, params: any[]) => Promise, - private gmConnect: (api: string, params: any[]) => Promise, - private scriptUuid: string, + conv: Conversation, + gmSendMessage: (api: string, params: any[]) => Promise, + gmConnect: (api: string, params: any[]) => Promise, + scriptUuid: string, initialTools?: ConversationCreateOptions["tools"], commands?: Record, ephemeral?: boolean, @@ -109,19 +119,27 @@ export class ConversationInstance { cache?: boolean, background?: boolean ) { - this.ephemeral = ephemeral || false; - this.background = background || false; - this.cache = cache; - this.systemPrompt = system; + const state: ConversationPrivateState = { + conv, + gmSendMessage, + gmConnect, + scriptUuid, + commandHandlers: new Map(), + cache, + systemPrompt: system, + background: background || false, + }; + this.#state = state; + this.#ephemeral = ephemeral || false; if (initialTools) { for (const tool of initialTools) { - this.toolHandlers.set(tool.name, tool.handler); - this.toolDefs.push({ name: tool.name, description: tool.description, parameters: tool.parameters }); + this.#toolHandlers.set(tool.name, tool.handler); + this.#toolDefs.push({ name: tool.name, description: tool.description, parameters: tool.parameters }); } } // 注册内置 /new 命令 - this.commandHandlers.set("/new", async () => { + state.commandHandlers.set("/new", async () => { await this.clear(); return "对话已清空"; }); @@ -129,21 +147,21 @@ export class ConversationInstance { // 用户传入的 commands 覆盖内置命令 if (commands) { for (const [name, handler] of Object.entries(commands)) { - this.commandHandlers.set(name, handler); + state.commandHandlers.set(name, handler); } } } get id() { - return this.conv.id; + return this.#state.conv.id; } get title() { - return this.conv.title; + return this.#state.conv.title; } get modelId() { - return this.conv.modelId; + return this.#state.conv.modelId; } // 发送消息并获取回复(内置 tool calling 循环) @@ -154,42 +172,43 @@ export class ConversationInstance { if (cmdResult !== undefined) return cmdResult; const { toolDefs, handlers } = this.mergeTools(options?.tools); + const state = this.#state; // ephemeral 模式:追加 user message 到内存历史 - if (this.ephemeral) { - this.messageHistory.push({ role: "user", content }); + if (this.#ephemeral) { + this.#messageHistory.push({ role: "user", content }); } // 通过 GM API connect 建立流式连接 const connectParams: Record = { - conversationId: this.conv.id, - generation: this.conv.generation, + conversationId: state.conv.id, + generation: state.conv.generation, message: content, tools: toolDefs.length > 0 ? toolDefs : undefined, - scriptUuid: this.scriptUuid, + scriptUuid: state.scriptUuid, }; - if (this.cache !== undefined) { - connectParams.cache = this.cache; + if (state.cache !== undefined) { + connectParams.cache = state.cache; } - if (this.background) { + if (state.background) { connectParams.background = true; } - if (this.ephemeral) { + if (this.#ephemeral) { connectParams.ephemeral = true; - connectParams.messages = this.messageHistory; - connectParams.system = this.systemPrompt; - connectParams.modelId = this.conv.modelId; + connectParams.messages = this.#messageHistory; + connectParams.system = state.systemPrompt; + connectParams.modelId = state.conv.modelId; } - const conn = await this.gmConnect("CAT_agentConversationChat", [connectParams]); + const conn = await state.gmConnect("CAT_agentConversationChat", [connectParams]); const reply = await this.processChat(conn, handlers); // ephemeral 模式:中间轮次(带 tool calls)已在 processChat 内按 new_message 边界追加到内存历史, // 这里只需追加不含 tool calls 的最终回复(done 事件保证到达时已无待处理的 tool calls)。 - if (this.ephemeral) { - this.messageHistory.push({ role: "assistant", content: reply.content }); + if (this.#ephemeral) { + this.#messageHistory.push({ role: "assistant", content: reply.content }); } return reply; @@ -221,39 +240,40 @@ export class ConversationInstance { } const { toolDefs, handlers } = this.mergeTools(options?.tools); + const state = this.#state; // ephemeral 模式:追加 user message 到内存历史 - if (this.ephemeral) { - this.messageHistory.push({ role: "user", content }); + if (this.#ephemeral) { + this.#messageHistory.push({ role: "user", content }); } const connectParams: Record = { - conversationId: this.conv.id, - generation: this.conv.generation, + conversationId: state.conv.id, + generation: state.conv.generation, message: content, tools: toolDefs.length > 0 ? toolDefs : undefined, - scriptUuid: this.scriptUuid, + scriptUuid: state.scriptUuid, }; - if (this.cache !== undefined) { - connectParams.cache = this.cache; + if (state.cache !== undefined) { + connectParams.cache = state.cache; } - if (this.background) { + if (state.background) { connectParams.background = true; } - if (this.ephemeral) { + if (this.#ephemeral) { connectParams.ephemeral = true; - connectParams.messages = this.messageHistory; - connectParams.system = this.systemPrompt; - connectParams.modelId = this.conv.modelId; + connectParams.messages = this.#messageHistory; + connectParams.system = state.systemPrompt; + connectParams.modelId = state.conv.modelId; } - const conn = await this.gmConnect("CAT_agentConversationChat", [connectParams]); + const conn = await state.gmConnect("CAT_agentConversationChat", [connectParams]); // chat 连接不会收到 sync 事件(sync 快照仅由 attach 的 SW 端发出), // 公开签名与 scriptcat.d.ts 保持一致:chatStream 只产出 StreamChunk // ephemeral 模式:包装 stream 以收集 assistant 消息到内存历史 - if (this.ephemeral) { + if (this.#ephemeral) { return this.processStreamEphemeral(conn, handlers) as AsyncIterable; } @@ -274,7 +294,7 @@ export class ConversationInstance { const parsed = this.parseCommand(content); if (!parsed) return undefined; - const handler = this.commandHandlers.get(parsed.name); + const handler = this.#state.commandHandlers.get(parsed.name); if (!handler) return undefined; const result = await handler(parsed.args, this); @@ -284,8 +304,8 @@ export class ConversationInstance { // 合并实例级别和调用级别的工具定义(调用级同名工具同时替换 schema 与 handler) protected mergeTools(callTools?: ChatOptions["tools"]) { - const toolDefs: ToolDefinition[] = [...this.toolDefs]; - const handlers = new Map(this.toolHandlers); + const toolDefs: ToolDefinition[] = [...this.#toolDefs]; + const handlers = new Map(this.#toolHandlers); for (const tool of callTools || []) { const definition = { name: tool.name, @@ -302,11 +322,12 @@ export class ConversationInstance { // 获取对话历史 async getMessages(): Promise { - if (this.ephemeral) { + const state = this.#state; + if (this.#ephemeral) { // ephemeral 模式:从内存历史转换为 ChatMessage 格式 - return this.messageHistory.map((msg, idx) => ({ + return this.#messageHistory.map((msg, idx) => ({ id: `ephemeral-${idx}`, - conversationId: this.conv.id, + conversationId: state.conv.id, role: msg.role, content: msg.content, toolCallId: msg.toolCallId, @@ -314,12 +335,12 @@ export class ConversationInstance { createtime: Date.now(), })); } - const messages = await this.gmSendMessage("CAT_agentConversation", [ + const messages = await state.gmSendMessage("CAT_agentConversation", [ { action: "getMessages", - conversationId: this.conv.id, - generation: this.conv.generation, - scriptUuid: this.scriptUuid, + conversationId: state.conv.id, + generation: state.conv.generation, + scriptUuid: state.scriptUuid, } as ConversationApiRequest, ]); return messages || []; @@ -327,36 +348,39 @@ export class ConversationInstance { // 清空对话消息历史 async clear(): Promise { - if (this.ephemeral) { - this.messageHistory = []; + if (this.#ephemeral) { + this.#messageHistory = []; return; } - await this.gmSendMessage("CAT_agentConversation", [ + const state = this.#state; + await state.gmSendMessage("CAT_agentConversation", [ { action: "clearMessages", - conversationId: this.conv.id, - generation: this.conv.generation, - scriptUuid: this.scriptUuid, + conversationId: state.conv.id, + generation: state.conv.generation, + scriptUuid: state.scriptUuid, } as ConversationApiRequest, ]); } // 持久化对话 async save(): Promise { - await this.gmSendMessage("CAT_agentConversation", [ + const state = this.#state; + await state.gmSendMessage("CAT_agentConversation", [ { action: "save", - conversationId: this.conv.id, - generation: this.conv.generation, - scriptUuid: this.scriptUuid, + conversationId: state.conv.id, + generation: state.conv.generation, + scriptUuid: state.scriptUuid, } as ConversationApiRequest, ]); } // 附加到后台运行中的会话,返回流式事件(首个 chunk 为 sync 快照) async attach(): Promise> { - const conn = await this.gmConnect("CAT_agentAttachToConversation", [ - { conversationId: this.conv.id, generation: this.conv.generation, scriptUuid: this.scriptUuid }, + const state = this.#state; + const conn = await state.gmConnect("CAT_agentAttachToConversation", [ + { conversationId: state.conv.id, generation: state.conv.generation, scriptUuid: state.scriptUuid }, ]); return this.processStream(conn, new Map()); } @@ -381,15 +405,15 @@ export class ConversationInstance { const finalContent = buildContent(content, blocks); const round = ordered.map(cloneToolCall); aggregate.push(...round); - if (this.ephemeral && record && (content || blocks.length || round.length)) { - this.messageHistory.push({ + if (this.#ephemeral && record && (content || blocks.length || round.length)) { + this.#messageHistory.push({ role: "assistant", content: finalContent, toolCalls: round.length ? round : undefined, }); for (const toolCall of round) { if (toolCall.result !== undefined) { - this.messageHistory.push({ + this.#messageHistory.push({ role: "tool", content: toolCall.result, toolCallId: toolCall.id, @@ -775,13 +799,13 @@ export class ConversationInstance { result: JSON.stringify({ error: "Tool call cancelled: stream ended before it completed" }), }); }); - this.messageHistory.push({ + this.#messageHistory.push({ role: "assistant", content: buildContent(text, blocks), toolCalls: finalized.length ? finalized : undefined, }); for (const toolCall of finalized) { - this.messageHistory.push({ + this.#messageHistory.push({ role: "tool", content: toolCall.result!, toolCallId: toolCall.id, @@ -879,24 +903,25 @@ export class ConversationInstance { } } -// 运行时 this 是 GM_Base 实例,定义其实际拥有的字段类型 +// API 显式接收 GM_Base 上下文。 interface GMBaseContext { sendMessage: (api: string, params: unknown[]) => Promise; connect: (api: string, params: unknown[]) => Promise; scriptRes?: { uuid: string }; } -// 构建 ConversationInstance,独立函数避免 this 绑定问题 -// (装饰器方法运行时 this 是 GM_Base 实例,不是 CATAgentApi) +// 构建 ConversationInstance,保留 GM_Base 的消息上下文。 function buildInstance( ctx: GMBaseContext, conv: Conversation, options?: ConversationCreateOptions ): ConversationInstance { + const sendMessage = Native.bind(ctx.sendMessage, ctx); + const connect = Native.bind(ctx.connect, ctx); return new ConversationInstance( conv, - ctx.sendMessage.bind(ctx), - ctx.connect.bind(ctx), + sendMessage, + connect, ctx.scriptRes?.uuid || "", options?.tools, options?.commands, @@ -922,7 +947,10 @@ export default class CATAgentApi { // CAT.agent.conversation.create() @GMContext.API({ follow: "CAT.agent.conversation" }) - public "CAT.agent.conversation.create"(options: ConversationCreateOptions = {}): Promise { + public "CAT.agent.conversation.create"( + ctx: GMBaseContext, + options: ConversationCreateOptions = {} + ): Promise { return (async () => { if (options.ephemeral) { // ephemeral 模式:不发请求到 SW,直接在脚本端构造 @@ -934,26 +962,26 @@ export default class CATAgentApi { createtime: Date.now(), updatetime: Date.now(), }; - return buildInstance(this as unknown as GMBaseContext, conv, options); + return buildInstance(ctx as unknown as GMBaseContext, conv, options); } const { tools: _tools, ephemeral: _ephemeral, ...serverOptions } = options; - const conv = (await this.sendMessage("CAT_agentConversation", [ - { action: "create", options: serverOptions, scriptUuid: this.scriptRes?.uuid || "" } as ConversationApiRequest, + const conv = (await ctx.sendMessage("CAT_agentConversation", [ + { action: "create", options: serverOptions, scriptUuid: ctx.scriptRes?.uuid || "" } as ConversationApiRequest, ])) as Conversation; - return buildInstance(this as unknown as GMBaseContext, conv, options); + return buildInstance(ctx as unknown as GMBaseContext, conv, options); })(); } // CAT.agent.conversation.get() @GMContext.API({ follow: "CAT.agent.conversation" }) - public "CAT.agent.conversation.get"(id: string): Promise { + public "CAT.agent.conversation.get"(ctx: GMBaseContext, id: string): Promise { return (async () => { - const conv = (await this.sendMessage("CAT_agentConversation", [ - { action: "get", id, scriptUuid: this.scriptRes?.uuid || "" } as ConversationApiRequest, + const conv = (await ctx.sendMessage("CAT_agentConversation", [ + { action: "get", id, scriptUuid: ctx.scriptRes?.uuid || "" } as ConversationApiRequest, ])) as Conversation | null; if (!conv) return null; - return buildInstance(this as unknown as GMBaseContext, conv); + return buildInstance(ctx as unknown as GMBaseContext, conv); })(); } } diff --git a/src/app/service/content/gm_api/cat_agent_dom.ts b/src/app/service/content/gm_api/cat_agent_dom.ts index 1208d39ed..914b16f37 100644 --- a/src/app/service/content/gm_api/cat_agent_dom.ts +++ b/src/app/service/content/gm_api/cat_agent_dom.ts @@ -23,7 +23,7 @@ import type { MonitorStatus, } from "@App/app/service/agent/core/types"; -// 运行时 this 是 GM_Base 实例 +// API 显式接收 GM_Base 上下文。 interface GMBaseContext { sendMessage: (api: string, params: unknown[]) => Promise; scriptRes?: { uuid: string }; @@ -37,96 +37,105 @@ export default class CATAgentDomApi { protected scriptRes?: any; @GMContext.API({ follow: "CAT.agent.dom" }) - public "CAT.agent.dom.listTabs"(): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.dom.listTabs"(ctx: GMBaseContext): Promise { return ctx.sendMessage("CAT_agentDom", [ { action: "listTabs", scriptUuid: ctx.scriptRes?.uuid || "" } as DomApiRequest, ]); } @GMContext.API({ follow: "CAT.agent.dom" }) - public "CAT.agent.dom.navigate"(url: string, options?: NavigateOptions): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.dom.navigate"(ctx: GMBaseContext, url: string, options?: NavigateOptions): Promise { return ctx.sendMessage("CAT_agentDom", [ { action: "navigate", url, options, scriptUuid: ctx.scriptRes?.uuid || "" } as DomApiRequest, ]); } @GMContext.API({ follow: "CAT.agent.dom" }) - public "CAT.agent.dom.readPage"(options?: ReadPageOptions): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.dom.readPage"(ctx: GMBaseContext, options?: ReadPageOptions): Promise { return ctx.sendMessage("CAT_agentDom", [ { action: "readPage", options, scriptUuid: ctx.scriptRes?.uuid || "" } as DomApiRequest, ]); } @GMContext.API({ follow: "CAT.agent.dom" }) - public "CAT.agent.dom.screenshot"(options?: ScreenshotOptions): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.dom.screenshot"(ctx: GMBaseContext, options?: ScreenshotOptions): Promise { return ctx.sendMessage("CAT_agentDom", [ { action: "screenshot", options, scriptUuid: ctx.scriptRes?.uuid || "" } as DomApiRequest, ]); } @GMContext.API({ follow: "CAT.agent.dom" }) - public "CAT.agent.dom.click"(selector: string, options?: DomActionOptions): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.dom.click"( + ctx: GMBaseContext, + selector: string, + options?: DomActionOptions + ): Promise { return ctx.sendMessage("CAT_agentDom", [ { action: "click", selector, options, scriptUuid: ctx.scriptRes?.uuid || "" } as DomApiRequest, ]); } @GMContext.API({ follow: "CAT.agent.dom" }) - public "CAT.agent.dom.fill"(selector: string, value: string, options?: DomActionOptions): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.dom.fill"( + ctx: GMBaseContext, + selector: string, + value: string, + options?: DomActionOptions + ): Promise { return ctx.sendMessage("CAT_agentDom", [ { action: "fill", selector, value, options, scriptUuid: ctx.scriptRes?.uuid || "" } as DomApiRequest, ]); } @GMContext.API({ follow: "CAT.agent.dom" }) - public "CAT.agent.dom.scroll"(direction: ScrollDirection, options?: ScrollOptions): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.dom.scroll"( + ctx: GMBaseContext, + direction: ScrollDirection, + options?: ScrollOptions + ): Promise { return ctx.sendMessage("CAT_agentDom", [ { action: "scroll", direction, options, scriptUuid: ctx.scriptRes?.uuid || "" } as DomApiRequest, ]); } @GMContext.API({ follow: "CAT.agent.dom" }) - public "CAT.agent.dom.waitFor"(selector: string, options?: WaitForOptions): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.dom.waitFor"( + ctx: GMBaseContext, + selector: string, + options?: WaitForOptions + ): Promise { return ctx.sendMessage("CAT_agentDom", [ { action: "waitFor", selector, options, scriptUuid: ctx.scriptRes?.uuid || "" } as DomApiRequest, ]); } @GMContext.API({ follow: "CAT.agent.dom" }) - public "CAT.agent.dom.executeScript"(code: string, options?: ExecuteScriptOptions): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.dom.executeScript"( + ctx: GMBaseContext, + code: string, + options?: ExecuteScriptOptions + ): Promise { return ctx.sendMessage("CAT_agentDom", [ { action: "executeScript", code, options, scriptUuid: ctx.scriptRes?.uuid || "" } as DomApiRequest, ]); } @GMContext.API({ follow: "CAT.agent.dom" }) - public "CAT.agent.dom.startMonitor"(tabId: number): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.dom.startMonitor"(ctx: GMBaseContext, tabId: number): Promise { return ctx.sendMessage("CAT_agentDom", [ { action: "startMonitor", tabId, scriptUuid: ctx.scriptRes?.uuid || "" } as DomApiRequest, ]); } @GMContext.API({ follow: "CAT.agent.dom" }) - public "CAT.agent.dom.stopMonitor"(tabId: number): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.dom.stopMonitor"(ctx: GMBaseContext, tabId: number): Promise { return ctx.sendMessage("CAT_agentDom", [ { action: "stopMonitor", tabId, scriptUuid: ctx.scriptRes?.uuid || "" } as DomApiRequest, ]); } @GMContext.API({ follow: "CAT.agent.dom" }) - public "CAT.agent.dom.peekMonitor"(tabId: number): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.dom.peekMonitor"(ctx: GMBaseContext, tabId: number): Promise { return ctx.sendMessage("CAT_agentDom", [ { action: "peekMonitor", tabId, scriptUuid: ctx.scriptRes?.uuid || "" } as DomApiRequest, ]); diff --git a/src/app/service/content/gm_api/cat_agent_model.test.ts b/src/app/service/content/gm_api/cat_agent_model.test.ts index 614342ae4..f9746766f 100644 --- a/src/app/service/content/gm_api/cat_agent_model.test.ts +++ b/src/app/service/content/gm_api/cat_agent_model.test.ts @@ -31,7 +31,7 @@ describe.concurrent("CATAgentModelApi", () => { const apis = GMContextApiGet("CAT.agent.model")!; const listApi = apis.find((a) => a.fnKey === "CAT.agent.model.list")!; - const result = await listApi.api.call(ctx); + const result = await listApi.api(ctx); expect(mockSendMessage).toHaveBeenCalledWith("CAT_agentModel", [ { action: "list", scriptUuid: "test-uuid" } as ModelApiRequest, @@ -57,7 +57,7 @@ describe.concurrent("CATAgentModelApi", () => { const apis = GMContextApiGet("CAT.agent.model")!; const getApi = apis.find((a) => a.fnKey === "CAT.agent.model.get")!; - const result = await getApi.api.call(ctx, "m1"); + const result = await getApi.api(ctx, "m1"); expect(mockSendMessage).toHaveBeenCalledWith("CAT_agentModel", [ { action: "get", id: "m1", scriptUuid: "test-uuid" } as ModelApiRequest, @@ -75,7 +75,7 @@ describe.concurrent("CATAgentModelApi", () => { const apis = GMContextApiGet("CAT.agent.model")!; const getDefaultApi = apis.find((a) => a.fnKey === "CAT.agent.model.getDefault")!; - const result = await getDefaultApi.api.call(ctx); + const result = await getDefaultApi.api(ctx); expect(mockSendMessage).toHaveBeenCalledWith("CAT_agentModel", [ { action: "getDefault", scriptUuid: "test-uuid" } as ModelApiRequest, @@ -93,7 +93,7 @@ describe.concurrent("CATAgentModelApi", () => { const apis = GMContextApiGet("CAT.agent.model")!; const listApi = apis.find((a) => a.fnKey === "CAT.agent.model.list")!; - await listApi.api.call(ctx); + await listApi.api(ctx); expect(mockSendMessage).toHaveBeenCalledWith("CAT_agentModel", [ { action: "list", scriptUuid: "" } as ModelApiRequest, diff --git a/src/app/service/content/gm_api/cat_agent_model.ts b/src/app/service/content/gm_api/cat_agent_model.ts index 3cc53c8e8..fe3d7d5f2 100644 --- a/src/app/service/content/gm_api/cat_agent_model.ts +++ b/src/app/service/content/gm_api/cat_agent_model.ts @@ -1,7 +1,7 @@ import type { AgentModelSafeConfig, ModelApiRequest } from "@App/app/service/agent/core/types"; import GMContext from "./gm_context"; -// 运行时 this 是 GM_Base 实例 +// API 显式接收 GM_Base 上下文。 interface GMBaseContext { sendMessage: ( api: string, @@ -23,32 +23,28 @@ export default class CATAgentModelApi { protected scriptRes?: { uuid: string }; @GMContext.API({ follow: "CAT.agent.model" }) - public "CAT.agent.model.list"(): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.model.list"(ctx: GMBaseContext): Promise { return ctx.sendMessage("CAT_agentModel", [ { action: "list", scriptUuid: ctx.scriptRes?.uuid || "" } as ModelApiRequest, ]) as Promise; } @GMContext.API({ follow: "CAT.agent.model" }) - public "CAT.agent.model.get"(id: string): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.model.get"(ctx: GMBaseContext, id: string): Promise { return ctx.sendMessage("CAT_agentModel", [ { action: "get", id, scriptUuid: ctx.scriptRes?.uuid || "" } as ModelApiRequest, ]) as Promise; } @GMContext.API({ follow: "CAT.agent.model" }) - public "CAT.agent.model.getDefault"(): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.model.getDefault"(ctx: GMBaseContext): Promise { return ctx.sendMessage("CAT_agentModel", [ { action: "getDefault", scriptUuid: ctx.scriptRes?.uuid || "" } as ModelApiRequest, ]) as Promise; } @GMContext.API({ follow: "CAT.agent.model" }) - public "CAT.agent.model.getSummary"(): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.model.getSummary"(ctx: GMBaseContext): Promise { return ctx.sendMessage("CAT_agentModel", [ { action: "getSummary", scriptUuid: ctx.scriptRes?.uuid || "" } as ModelApiRequest, ]) as Promise; diff --git a/src/app/service/content/gm_api/cat_agent_opfs.test.ts b/src/app/service/content/gm_api/cat_agent_opfs.test.ts index d189ccf6e..0b87bfea2 100644 --- a/src/app/service/content/gm_api/cat_agent_opfs.test.ts +++ b/src/app/service/content/gm_api/cat_agent_opfs.test.ts @@ -24,7 +24,7 @@ describe.concurrent("CATAgentOPFSApi", () => { const apis = GMContextApiGet("CAT.agent.opfs")!; const writeApi = apis.find((a) => a.fnKey === "CAT.agent.opfs.write")!; - const result = await writeApi.api.call(ctx, "hello.txt", "Hello"); + const result = await writeApi.api(ctx, "hello.txt", "Hello"); expect(mockSendMessage).toHaveBeenCalledWith("CAT_agentOPFS", [ { action: "write", path: "hello.txt", content: "Hello", scriptUuid: "test-uuid" } as OPFSApiRequest, @@ -38,7 +38,7 @@ describe.concurrent("CATAgentOPFSApi", () => { const apis = GMContextApiGet("CAT.agent.opfs")!; const readApi = apis.find((a) => a.fnKey === "CAT.agent.opfs.read")!; - const result = await readApi.api.call(ctx, "f.txt"); + const result = await readApi.api(ctx, "f.txt"); expect(mockSendMessage).toHaveBeenCalledWith("CAT_agentOPFS", [ { action: "read", path: "f.txt", scriptUuid: "test-uuid" } as OPFSApiRequest, @@ -54,14 +54,14 @@ describe.concurrent("CATAgentOPFSApi", () => { const listApi = apis.find((a) => a.fnKey === "CAT.agent.opfs.list")!; // 不带 path - await listApi.api.call(ctx); + await listApi.api(ctx); expect(mockSendMessage).toHaveBeenCalledWith("CAT_agentOPFS", [ { action: "list", path: undefined, scriptUuid: "test-uuid" } as OPFSApiRequest, ]); // 带 path mockSendMessage.mockClear(); - await listApi.api.call(ctx, "sub"); + await listApi.api(ctx, "sub"); expect(mockSendMessage).toHaveBeenCalledWith("CAT_agentOPFS", [ { action: "list", path: "sub", scriptUuid: "test-uuid" } as OPFSApiRequest, ]); @@ -73,7 +73,7 @@ describe.concurrent("CATAgentOPFSApi", () => { const apis = GMContextApiGet("CAT.agent.opfs")!; const deleteApi = apis.find((a) => a.fnKey === "CAT.agent.opfs.delete")!; - const result = await deleteApi.api.call(ctx, "old.txt"); + const result = await deleteApi.api(ctx, "old.txt"); expect(mockSendMessage).toHaveBeenCalledWith("CAT_agentOPFS", [ { action: "delete", path: "old.txt", scriptUuid: "test-uuid" } as OPFSApiRequest, @@ -87,7 +87,7 @@ describe.concurrent("CATAgentOPFSApi", () => { const apis = GMContextApiGet("CAT.agent.opfs")!; const listApi = apis.find((a) => a.fnKey === "CAT.agent.opfs.list")!; - await listApi.api.call(ctx); + await listApi.api(ctx); expect(mockSendMessage).toHaveBeenCalledWith("CAT_agentOPFS", [ { action: "list", path: undefined, scriptUuid: "" } as OPFSApiRequest, @@ -108,7 +108,7 @@ describe.concurrent("CATAgentOPFSApi", () => { const apis = GMContextApiGet("CAT.agent.opfs")!; const readAttachmentApi = apis.find((a) => a.fnKey === "CAT.agent.opfs.readAttachment")!; - const result = await readAttachmentApi.api.call(ctx, "att-1"); + const result = await readAttachmentApi.api(ctx, "att-1"); expect(mockSendMessage).toHaveBeenCalledTimes(1); expect((result as any).data).toBe(testBlob); @@ -126,7 +126,7 @@ describe.concurrent("CATAgentOPFSApi", () => { const apis = GMContextApiGet("CAT.agent.opfs")!; const readApi = apis.find((a) => a.fnKey === "CAT.agent.opfs.read")!; - const result = await readApi.api.call(ctx, "img.png", "blob"); + const result = await readApi.api(ctx, "img.png", "blob"); expect(mockSendMessage).toHaveBeenCalledTimes(1); expect((result as any).data).toBe(testBlob); @@ -154,7 +154,7 @@ describe.concurrent("CATAgentOPFSApi", () => { const apis = GMContextApiGet("CAT.agent.opfs")!; const readAttachmentApi = apis.find((a) => a.fnKey === "CAT.agent.opfs.readAttachment")!; - const result = await readAttachmentApi.api.call(ctx, "att-1"); + const result = await readAttachmentApi.api(ctx, "att-1"); expect(mockSendMessage).toHaveBeenCalledWith("CAT_fetchBlob", ["blob:chrome-extension://test/123"]); expect((result as any).data).toBe(testBlob); @@ -181,7 +181,7 @@ describe.concurrent("CATAgentOPFSApi", () => { const apis = GMContextApiGet("CAT.agent.opfs")!; const readApi = apis.find((a) => a.fnKey === "CAT.agent.opfs.read")!; - const result = await readApi.api.call(ctx, "img.png", "blob"); + const result = await readApi.api(ctx, "img.png", "blob"); expect(mockSendMessage).toHaveBeenCalledWith("CAT_fetchBlob", ["blob:chrome-extension://test/456"]); expect((result as any).data).toBe(testBlob); diff --git a/src/app/service/content/gm_api/cat_agent_opfs.ts b/src/app/service/content/gm_api/cat_agent_opfs.ts index 6355e5517..549919162 100644 --- a/src/app/service/content/gm_api/cat_agent_opfs.ts +++ b/src/app/service/content/gm_api/cat_agent_opfs.ts @@ -1,7 +1,7 @@ import type { OPFSApiRequest } from "@App/app/service/agent/core/types"; import GMContext from "./gm_context"; -// 运行时 this 是 GM_Base 实例 +// API 显式接收 GM_Base 上下文。 interface GMBaseContext { sendMessage: (api: string, params: any[]) => Promise; scriptRes?: { uuid: string }; @@ -17,8 +17,11 @@ export default class CATAgentOPFSApi { protected scriptRes?: { uuid: string }; @GMContext.API({ follow: "CAT.agent.opfs" }) - public "CAT.agent.opfs.write"(path: string, content: string | Blob): Promise<{ path: string; size: number }> { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.opfs.write"( + ctx: GMBaseContext, + path: string, + content: string | Blob + ): Promise<{ path: string; size: number }> { return ctx.sendMessage("CAT_agentOPFS", [ { action: "write", path, content, scriptUuid: ctx.scriptRes?.uuid || "" } as OPFSApiRequest, ]) as Promise<{ path: string; size: number }>; @@ -26,10 +29,10 @@ export default class CATAgentOPFSApi { @GMContext.API({ follow: "CAT.agent.opfs" }) public async "CAT.agent.opfs.read"( + ctx: GMBaseContext, path: string, format?: "text" | "blob" ): Promise<{ path: string; content?: string; data?: Blob; size: number; mimeType?: string }> { - const ctx = this as unknown as GMBaseContext; const result = await ctx.sendMessage("CAT_agentOPFS", [ { action: "read", path, format, scriptUuid: ctx.scriptRes?.uuid || "" } as OPFSApiRequest, ]); @@ -42,8 +45,10 @@ export default class CATAgentOPFSApi { } @GMContext.API({ follow: "CAT.agent.opfs" }) - public "CAT.agent.opfs.list"(path?: string): Promise> { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.opfs.list"( + ctx: GMBaseContext, + path?: string + ): Promise> { return ctx.sendMessage("CAT_agentOPFS", [ { action: "list", path, scriptUuid: ctx.scriptRes?.uuid || "" } as OPFSApiRequest, ]) as Promise>; @@ -51,9 +56,9 @@ export default class CATAgentOPFSApi { @GMContext.API({ follow: "CAT.agent.opfs" }) public async "CAT.agent.opfs.readAttachment"( + ctx: GMBaseContext, id: string ): Promise<{ id: string; data: Blob; size: number; mimeType?: string }> { - const ctx = this as unknown as GMBaseContext; const result = await ctx.sendMessage("CAT_agentOPFS", [ { action: "readAttachment", id, scriptUuid: ctx.scriptRes?.uuid || "" } as OPFSApiRequest, ]); @@ -66,8 +71,7 @@ export default class CATAgentOPFSApi { } @GMContext.API({ follow: "CAT.agent.opfs" }) - public "CAT.agent.opfs.delete"(path: string): Promise<{ success: true }> { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.opfs.delete"(ctx: GMBaseContext, path: string): Promise<{ success: true }> { return ctx.sendMessage("CAT_agentOPFS", [ { action: "delete", path, scriptUuid: ctx.scriptRes?.uuid || "" } as OPFSApiRequest, ]) as Promise<{ success: true }>; diff --git a/src/app/service/content/gm_api/cat_agent_skills.ts b/src/app/service/content/gm_api/cat_agent_skills.ts index 8e9dd6314..be7715338 100644 --- a/src/app/service/content/gm_api/cat_agent_skills.ts +++ b/src/app/service/content/gm_api/cat_agent_skills.ts @@ -1,7 +1,7 @@ import type { SkillApiRequest, SkillRecord, SkillSummary } from "@App/app/service/agent/core/types"; import GMContext from "./gm_context"; -// 运行时 this 是 GM_Base 实例 +// API 显式接收 GM_Base 上下文。 interface GMBaseContext { sendMessage: ( api: string, @@ -23,16 +23,14 @@ export default class CATAgentSkillsApi { protected scriptRes?: { uuid: string }; @GMContext.API({ follow: "CAT.agent.skills" }) - public "CAT.agent.skills.list"(): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.skills.list"(ctx: GMBaseContext): Promise { return ctx.sendMessage("CAT_agentSkills", [ { action: "list", scriptUuid: ctx.scriptRes?.uuid || "" } as SkillApiRequest, ]) as Promise; } @GMContext.API({ follow: "CAT.agent.skills" }) - public "CAT.agent.skills.get"(name: string): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.skills.get"(ctx: GMBaseContext, name: string): Promise { return ctx.sendMessage("CAT_agentSkills", [ { action: "get", name, scriptUuid: ctx.scriptRes?.uuid || "" } as SkillApiRequest, ]) as Promise; @@ -40,11 +38,11 @@ export default class CATAgentSkillsApi { @GMContext.API({ follow: "CAT.agent.skills" }) public "CAT.agent.skills.install"( + ctx: GMBaseContext, skillMd: string, scripts?: Array<{ name: string; code: string }>, references?: Array<{ name: string; content: string }> ): Promise { - const ctx = this as unknown as GMBaseContext; return ctx.sendMessage("CAT_agentSkills", [ { action: "install", @@ -57,8 +55,7 @@ export default class CATAgentSkillsApi { } @GMContext.API({ follow: "CAT.agent.skills" }) - public "CAT.agent.skills.remove"(name: string): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.skills.remove"(ctx: GMBaseContext, name: string): Promise { return ctx.sendMessage("CAT_agentSkills", [ { action: "remove", name, scriptUuid: ctx.scriptRes?.uuid || "" } as SkillApiRequest, ]) as Promise; @@ -66,11 +63,11 @@ export default class CATAgentSkillsApi { @GMContext.API({ follow: "CAT.agent.skills" }) public "CAT.agent.skills.call"( + ctx: GMBaseContext, skillName: string, scriptName: string, params?: Record ): Promise { - const ctx = this as unknown as GMBaseContext; return ctx.sendMessage("CAT_agentSkills", [ { action: "call", diff --git a/src/app/service/content/gm_api/cat_agent_task.ts b/src/app/service/content/gm_api/cat_agent_task.ts index 70321ee91..4a0d9cd73 100644 --- a/src/app/service/content/gm_api/cat_agent_task.ts +++ b/src/app/service/content/gm_api/cat_agent_task.ts @@ -7,8 +7,9 @@ import type { EventAgentTask, } from "@App/app/service/agent/core/types"; import type EventEmitter from "eventemitter3"; +import { Native } from "../global"; -// 运行时 this 是 GM_Base 实例 +// API 显式接收 GM_Base 上下文。 interface GMBaseContext { sendMessage: (api: string, params: unknown[]) => Promise; scriptRes?: { uuid: string }; @@ -17,8 +18,18 @@ interface GMBaseContext { // 内部 listener 计数器 let listenerCounter = 0; -// listener id → { eventName, callback } 映射,供 removeListener 使用 -const listenerMap = new Map void }>(); +type ListenerRecord = { id: number; eventName: string; callback: (...args: any[]) => void }; +const listenerMaps = new Native.WeakMap>(); +// 监听记录按 GM context 隔离;WeakMap 让脚本结束后不会因监听表反向持有 context。 + +const getListenerRecords = (owner: object): Map => { + let records = listenerMaps.get(owner); + if (!records) { + records = new Native.Map(); + listenerMaps.set(owner, records); + } + return records; +}; // CAT.agent.task API,注入到脚本上下文 export default class CATAgentTaskApi { @@ -33,11 +44,11 @@ export default class CATAgentTaskApi { @GMContext.API({ follow: "CAT.agent.task" }) public "CAT.agent.task.create"( + ctx: GMBaseContext, options: | Omit | Omit ): Promise { - const ctx = this as unknown as GMBaseContext; // event 模式:自动注入 sourceScriptUuid(脚本无需手动传入) const task = options.mode === "event" ? { ...options, sourceScriptUuid: ctx.scriptRes?.uuid || "" } : { ...options }; @@ -50,14 +61,12 @@ export default class CATAgentTaskApi { } @GMContext.API({ follow: "CAT.agent.task" }) - public "CAT.agent.task.list"(): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.task.list"(ctx: GMBaseContext): Promise { return ctx.sendMessage("CAT_agentTask", [{ action: "list" } as AgentTaskApiRequest]) as Promise; } @GMContext.API({ follow: "CAT.agent.task" }) - public "CAT.agent.task.get"(id: string): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.task.get"(ctx: GMBaseContext, id: string): Promise { return ctx.sendMessage("CAT_agentTask", [{ action: "get", id } as AgentTaskApiRequest]) as Promise< AgentTask | undefined >; @@ -66,8 +75,7 @@ export default class CATAgentTaskApi { // task 必须携带 get()/list() 返回的 generation/revision(乐观并发版本号), // 否则服务端无法区分"修改的是当前这个任务"还是"ID 被删除重建后的另一个任务" @GMContext.API({ follow: "CAT.agent.task" }) - public "CAT.agent.task.update"(id: string, task: Partial): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.task.update"(ctx: GMBaseContext, id: string, task: Partial): Promise { if (task.generation === undefined || task.revision === undefined) { throw new Error( "CAT.agent.task.update: task must include the generation/revision returned by CAT.agent.task.get() or list() — spread the fetched task before applying changes." @@ -79,8 +87,11 @@ export default class CATAgentTaskApi { } @GMContext.API({ follow: "CAT.agent.task" }) - public "CAT.agent.task.remove"(id: string, task: Pick): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.task.remove"( + ctx: GMBaseContext, + id: string, + task: Pick + ): Promise { if (task?.generation === undefined || task?.revision === undefined) { throw new Error( "CAT.agent.task.remove: task must include the generation/revision returned by CAT.agent.task.get() or list()." @@ -92,16 +103,18 @@ export default class CATAgentTaskApi { } @GMContext.API({ follow: "CAT.agent.task" }) - public "CAT.agent.task.runNow"(id: string): Promise { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.task.runNow"(ctx: GMBaseContext, id: string): Promise { return ctx.sendMessage("CAT_agentTask", [{ action: "runNow", id } as AgentTaskApiRequest]) as Promise; } // 监听任务触发事件 // 利用 EE.on("agentTask:{taskId}", callback) 注册监听 @GMContext.API({ follow: "CAT.agent.task" }) - public "CAT.agent.task.addListener"(taskId: string, callback: (trigger: AgentTaskTrigger) => void): number { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.task.addListener"( + ctx: GMBaseContext, + taskId: string, + callback: (trigger: AgentTaskTrigger) => void + ): number { if (!ctx.EE) return 0; const listenerId = ++listenerCounter; @@ -112,20 +125,21 @@ export default class CATAgentTaskApi { }; ctx.EE.on(eventName, wrappedCallback); - listenerMap.set(listenerId, { eventName, callback: wrappedCallback }); + getListenerRecords(ctx).set(listenerId, { id: listenerId, eventName, callback: wrappedCallback }); return listenerId; } @GMContext.API({ follow: "CAT.agent.task" }) - public "CAT.agent.task.removeListener"(listenerId: number): void { - const ctx = this as unknown as GMBaseContext; + public "CAT.agent.task.removeListener"(ctx: GMBaseContext, listenerId: number): void { if (!ctx.EE) return; - const entry = listenerMap.get(listenerId); + const records = getListenerRecords(ctx); + const entry = records.get(listenerId); if (entry) { + // 记录事件名和包装回调后可直接移除,不必扫描所有任务监听器。 + records.delete(listenerId); ctx.EE.off(entry.eventName, entry.callback); - listenerMap.delete(listenerId); } } } diff --git a/src/app/service/content/gm_api/gm_api.test.ts b/src/app/service/content/gm_api/gm_api.test.ts index cf926aed6..47fc63e33 100644 --- a/src/app/service/content/gm_api/gm_api.test.ts +++ b/src/app/service/content/gm_api/gm_api.test.ts @@ -3,11 +3,12 @@ import ExecScript from "../exec_script"; import type { ScriptLoadInfo } from "@App/app/service/service_worker/types"; import type { GMInfoEnv, ScriptFunc } from "../types"; import { compileScript, compileScriptCode } from "../utils"; -import type { Message } from "@Packages/message/types"; +import type { Message, MessageConnect } from "@Packages/message/types"; import { encodeRValue } from "@App/pkg/utils/message_value"; import { uuidv4 } from "@App/pkg/utils/uuid"; import type { ScriptRunResource } from "@App/app/repo/scripts"; import GMApi from "./gm_api"; +import { parseSerializedDocumentResponse } from "./gm_xhr"; const nilFn: ScriptFunc = () => {}; const scriptRes = { @@ -32,6 +33,133 @@ const envInfo: GMInfoEnv = { isIncognito: false, }; +describe("early-start page RPC", () => { + it("waits for the page binding before opening a long-lived connection", async () => { + let release!: () => void; + const ready = new Promise((resolve) => { + release = resolve; + }); + const connection = {} as MessageConnect; + const connectMessage = vi.fn().mockResolvedValue(connection); + const script = { + ...scriptRes, + uuid: "early-start-script", + executionHandle: "page-binding", + executionEnvTag: "it", + } as ScriptLoadInfo; + const api = new GMApi("scripting", { connect: connectMessage } as unknown as Message, {} as Message, script); + Object.defineProperty(api, "loadScriptPromise", { configurable: true, value: ready, writable: true }); + + const pending = api.connect("GM_xmlhttpRequest", []); + expect(connectMessage).not.toHaveBeenCalled(); + + release(); + await expect(pending).resolves.toBe(connection); + expect(connectMessage).toHaveBeenCalledWith({ + action: "scripting/runtime/gmApi", + data: expect.objectContaining({ + api: "GM_xmlhttpRequest", + handle: "page-binding", + version: 1, + requestId: expect.any(String), + }), + }); + }); + + it("uses the authoritative run flag for early-start async value acknowledgments", async () => { + const script = { + ...scriptRes, + uuid: "early-start-value-script", + metadata: { grant: ["GM.setValue"], "early-start": [""], "run-at": ["document-start"] }, + executionHandle: undefined, + executionEnvTag: undefined, + executionRunFlag: undefined, + } as ScriptLoadInfo; + const mockSendMessage = vi.fn().mockResolvedValue({ code: 0 }); + const exec = new ExecScript(script, { + envPrefix: "scripting", + message: { sendMessage: mockSendMessage } as unknown as Message, + contentMsg: undefined as any, + code: nilFn, + envInfo, + }); + + exec.scriptFunc = function (_token: string, context: any) { + return context.GM.setValue("a", 123); + } as unknown as ScriptFunc; + const result = exec.exec(); + await Promise.resolve(); + expect(mockSendMessage).not.toHaveBeenCalled(); + + exec.updateEarlyScriptGMInfo(envInfo, { + ...script, + executionHandle: "page-binding", + executionEnvTag: "it", + executionRunFlag: "canonical-run", + }); + await Promise.resolve(); + expect(mockSendMessage).toHaveBeenCalledTimes(1); + + const request = mockSendMessage.mock.calls[0][0].data; + exec.valueUpdate({ + id: request.params[0], + entries: [["a", encodeRValue(123), encodeRValue(undefined)]], + uuid: script.uuid, + storageName: script.uuid, + sender: { runFlag: "canonical-run", tabId: -2 }, + valueUpdated: true, + }); + + await expect(result).resolves.toBeUndefined(); + }); +}); + +describe("CAT_fetchDocument", () => { + it("rebuilds documents from a data-only response instead of a relatedTarget reference", async () => { + const script = Object.assign({}, scriptRes, { + executionEnvTag: "it", + metadata: { grant: ["CAT_fetchDocument"] }, + }) as ScriptLoadInfo; + const sendMessage = vi.fn().mockResolvedValue({ + code: 0, + data: { + text: '
ok
', + contentType: "text/html", + }, + }); + const api = new GMApi("scripting", { sendMessage } as unknown as Message, {} as Message, script); + + const document = await api.CAT_fetchDocument(api, "https://example.test/document"); + + expect(document?.querySelector("main")?.getAttribute("data-source")).toBe("serialized"); + expect(sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ + action: "scripting/runtime/gmApi", + data: expect.objectContaining({ api: "CAT_fetchDocument", params: ["https://example.test/document", false] }), + }) + ); + }); + + it("does not execute accessors in a forged serialized response", () => { + const getter = vi.fn(() => "secret"); + const data = { contentType: "text/html" } as Record; + Object.defineProperty(data, "text", { configurable: true, enumerable: true, get: getter }); + + expect(parseSerializedDocumentResponse(data)).toBeUndefined(); + expect(getter).not.toHaveBeenCalled(); + + const proxy = new Proxy( + { text: "", contentType: "text/html" }, + { + getOwnPropertyDescriptor: () => { + throw new Error("proxy trap"); + }, + } + ); + expect(parseSerializedDocumentResponse(proxy)).toBeUndefined(); + }); +}); + const makeResource = (url: string, content: string, type: "require" | "require-css" | "resource") => ({ url, content, @@ -59,10 +187,10 @@ describe("GM Resource API", () => { } as unknown as ScriptRunResource; const api = new GMApi("test", {} as Message, {} as Message, script); - expect(api.GM_getResourceText(name)).toBe("declared resource"); - expect(api.GM_getResourceURL(name)).toContain("ZGVjbGFyZWQgcmVzb3VyY2U="); - expect(await api["GM.getResourceText"](name)).toBe("declared resource"); - expect(await api["GM.getResourceUrl"](name)).toContain("ZGVjbGFyZWQgcmVzb3VyY2U="); + expect(api.GM_getResourceText(api, name)).toBe("declared resource"); + expect(api.GM_getResourceURL(api, name)).toContain("ZGVjbGFyZWQgcmVzb3VyY2U="); + expect(await api["GM.getResourceText"](api, name)).toBe("declared resource"); + expect(await api["GM.getResourceUrl"](api, name)).toContain("ZGVjbGFyZWQgcmVzb3VyY2U="); const legacyScript = { ...script, @@ -71,7 +199,7 @@ describe("GM Resource API", () => { } as unknown as ScriptRunResource; const legacyApi = new GMApi("test", {} as Message, {} as Message, legacyScript); - expect(legacyApi.GM_getResourceText(name)).toBe("legacy resource"); + expect(legacyApi.GM_getResourceText(legacyApi, name)).toBe("legacy resource"); }); }); @@ -117,23 +245,23 @@ describe.concurrent("@grant GM", () => { exec.scriptFunc = compileScript(compileScriptCode(script)); const ret = await exec.exec(); // getValue - expect(ret.GM_getValue?.name).toEqual("bound GM_getValue"); + expect(ret.GM_getValue?.name).toEqual("GM_getValue"); // getTab / getTabs / saveTab - expect(ret.GM_getTab?.name).toEqual("bound GM_getTab"); - expect(ret.GM_getTabs?.name).toEqual("bound GM_getTabs"); - expect(ret.GM_saveTab?.name).toEqual("bound GM_saveTab"); + expect(ret.GM_getTab?.name).toEqual("GM_getTab"); + expect(ret.GM_getTabs?.name).toEqual("GM_getTabs"); + expect(ret.GM_saveTab?.name).toEqual("GM_saveTab"); // cookie - expect(ret.GM_cookie?.name).toEqual("bound GM_cookie"); - expect(ret["GM_cookie.list"]?.name).toEqual("bound GM_cookie.list"); + expect(ret.GM_cookie?.name).toEqual("GM_cookie"); + expect(ret["GM_cookie.list"]?.name).toEqual("GM_cookie.list"); // GM_与GM.应该都在 - expect(ret["GM_addElement"]?.name).toEqual("bound GM_addElement"); - expect(ret["GM.addElement"]?.name).toEqual("bound GM.addElement"); - expect(ret["GM_openInTab"]?.name).toEqual("bound GM_openInTab"); - expect(ret["GM.openInTab"]?.name).toEqual("bound GM.openInTab"); - expect(ret["GM_log"]?.name).toEqual("bound GM_log"); - expect(ret["GM.log"]?.name).toEqual("bound GM.log"); - expect(ret["GM_notification"]?.name).toEqual("bound GM_notification"); - expect(ret["GM.notification"]?.name).toEqual("bound GM.notification"); + expect(ret["GM_addElement"]?.name).toEqual("GM_addElement"); + expect(ret["GM.addElement"]?.name).toEqual("GM.addElement"); + expect(ret["GM_openInTab"]?.name).toEqual("GM_openInTab"); + expect(ret["GM.openInTab"]?.name).toEqual("GM.openInTab"); + expect(ret["GM_log"]?.name).toEqual("GM_log"); + expect(ret["GM.log"]?.name).toEqual("GM.log"); + expect(ret["GM_notification"]?.name).toEqual("GM_notification"); + expect(ret["GM.notification"]?.name).toEqual("GM.notification"); // 没有grant应返回 nil expect(ret["GM_xmlhttpRequest"]?.name).toEqual("nil"); expect(ret["GM.xmlhttpRequest"]?.name).toEqual("nil"); @@ -179,23 +307,23 @@ describe.concurrent("@grant GM", () => { exec.scriptFunc = compileScript(compileScriptCode(script)); const ret = await exec.exec(); // getValue - expect(ret["GM.getValue"]?.name).toEqual("bound GM.getValue"); + expect(ret["GM.getValue"]?.name).toEqual("GM.getValue"); // getTab / getTabs / saveTab - expect(ret["GM.getTab"]?.name).toEqual("bound GM.getTab"); - expect(ret["GM.getTabs"]?.name).toEqual("bound GM.getTabs"); - expect(ret["GM.saveTab"]?.name).toEqual("bound GM.saveTab"); + expect(ret["GM.getTab"]?.name).toEqual("GM.getTab"); + expect(ret["GM.getTabs"]?.name).toEqual("GM.getTabs"); + expect(ret["GM.saveTab"]?.name).toEqual("GM.saveTab"); // cookie - expect(ret["GM.cookie"]?.name).toEqual("bound GM.cookie"); - expect(ret["GM.cookie"]?.list?.name).toEqual("bound GM.cookie.list"); + expect(ret["GM.cookie"]?.name).toEqual("GM.cookie"); + expect(ret["GM.cookie"]?.list?.name).toEqual("GM.cookie.list"); // GM_与GM.应该都在 - expect(ret["GM_addElement"]?.name).toEqual("bound GM_addElement"); - expect(ret["GM.addElement"]?.name).toEqual("bound GM.addElement"); - expect(ret["GM_openInTab"]?.name).toEqual("bound GM_openInTab"); - expect(ret["GM.openInTab"]?.name).toEqual("bound GM.openInTab"); - expect(ret["GM_log"]?.name).toEqual("bound GM_log"); - expect(ret["GM.log"]?.name).toEqual("bound GM.log"); - expect(ret["GM_notification"]?.name).toEqual("bound GM_notification"); - expect(ret["GM.notification"]?.name).toEqual("bound GM.notification"); + expect(ret["GM_addElement"]?.name).toEqual("GM_addElement"); + expect(ret["GM.addElement"]?.name).toEqual("GM.addElement"); + expect(ret["GM_openInTab"]?.name).toEqual("GM_openInTab"); + expect(ret["GM.openInTab"]?.name).toEqual("GM.openInTab"); + expect(ret["GM_log"]?.name).toEqual("GM_log"); + expect(ret["GM.log"]?.name).toEqual("GM.log"); + expect(ret["GM_notification"]?.name).toEqual("GM_notification"); + expect(ret["GM.notification"]?.name).toEqual("GM.notification"); // 没有grant应返回 nil expect(ret["GM_xmlhttpRequest"]?.name).toEqual("nil"); expect(ret["GM.xmlhttpRequest"]?.name).toEqual("nil"); @@ -470,6 +598,39 @@ describe.concurrent("GM_menu", () => { expect(await retPromise).toEqual(123); }); + it.concurrent("注册菜单不会执行选项 getter", async () => { + const script = Object.assign({}, scriptRes) as ScriptLoadInfo; + script.metadata.grant = ["GM_registerMenuCommand"]; + script.code = ` + let getterCalls = 0; + const options = { accessKey: "s" }; + Object.defineProperty(options, "secret", { enumerable: true, get() { getterCalls += 1; return "forged"; } }); + GM_registerMenuCommand("safe", () => {}, options); + return getterCalls; + `; + const mockSendMessage = vi.fn().mockResolvedValue({ code: 0 }); + const mockMessage = { sendMessage: mockSendMessage } as unknown as Message; + const exec = new ExecScript(script, { + envPrefix: "scripting", + message: mockMessage, + contentMsg: undefined as any, + code: nilFn, + envInfo, + }); + exec.scriptFunc = compileScript(compileScriptCode(script)); + + await expect(exec.exec()).resolves.toBe(0); + expect(mockSendMessage).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + api: "GM_registerMenuCommand", + params: [expect.any(String), "safe", expect.objectContaining({ accessKey: "s" })], + }), + }) + ); + expect(mockSendMessage.mock.calls[0][0].data.params[2]).not.toHaveProperty("secret"); + }); + it.concurrent("取消注册菜单", async () => { const script = Object.assign({}, scriptRes) as ScriptLoadInfo; script.metadata.grant = ["GM_registerMenuCommand", "GM_unregisterMenuCommand"]; @@ -591,6 +752,47 @@ describe.concurrent("GM_menu", () => { }); describe.concurrent("GM_value", () => { + it("stores __proto__ as a value key instead of changing the value store prototype", () => { + const script = Object.assign({}, scriptRes, { + metadata: { grant: ["GM_getValue", "GM_setValue"] }, + value: {}, + }) as ScriptLoadInfo; + const sendMessage = vi.fn().mockResolvedValue({ code: 0 }); + const api = new GMApi("test", { sendMessage } as unknown as Message, {} as Message, script as any); + const stored = { leaked: "secret" }; + + api.GM_setValue(api, "__proto__", stored); + + expect(Object.prototype.hasOwnProperty.call(script.value, "__proto__")).toBe(true); + expect(Object.getPrototypeOf(script.value)).toBe(Object.prototype); + expect(api.GM_getValue(api, "__proto__")).toEqual(stored); + expect(api.GM_getValue(api, "leaked")).toBeUndefined(); + }); + + it("returns __proto__ as an own key without changing the result prototype", () => { + const script = Object.assign({}, scriptRes, { + metadata: { grant: ["GM_getValue", "GM_setValue", "GM_getValues"] }, + value: {}, + }) as ScriptLoadInfo; + const sendMessage = vi.fn().mockResolvedValue({ code: 0 }); + const api = new GMApi("test", { sendMessage } as unknown as Message, {} as Message, script as any); + const stored = { leaked: "secret" }; + + api.GM_setValue(api, "__proto__", stored); + + const selected = api.GM_getValues(api, ["__proto__"]); + const defaults = Object.create(null) as Record; + defaults.__proto__ = "fallback"; + const withDefaults = api.GM_getValues(api, defaults); + + expect(Object.getPrototypeOf(selected)).toBeNull(); + expect(Object.prototype.hasOwnProperty.call(selected, "__proto__")).toBe(true); + expect(selected.__proto__).toEqual(stored); + expect(Object.getPrototypeOf(withDefaults)).toBeNull(); + expect(Object.prototype.hasOwnProperty.call(withDefaults, "__proto__")).toBe(true); + expect(withDefaults.__proto__).toEqual(stored); + }); + it.concurrent("GM_setValue", async () => { const script = Object.assign({}, scriptRes) as ScriptLoadInfo; script.metadata.grant = ["GM_getValue", "GM_setValue"]; @@ -659,7 +861,7 @@ describe.concurrent("GM_value", () => { action: "scripting/runtime/gmApi", data: { api: "GM_setValue", - params: [expect.any(String), "proxy-key", {}], // Proxy 会被转换为空对象 + params: [expect.any(String), "proxy-key"], // Proxy 无法通过 data-only clone,按删除处理 runFlag: expect.any(String), uuid: undefined, }, @@ -683,7 +885,7 @@ describe.concurrent("GM_value", () => { expect(ret).toEqual({ ret1: 123, ret2: 456, - ret3: {}, + ret3: undefined, ret4: undefined, }); }); @@ -846,6 +1048,107 @@ return { value1, value2, value3, values1,values2, allValues1, allValues2, value4 }); }); + it("拒绝带 getter 的值,且不会在克隆时执行 getter", () => { + const script = Object.assign({}, scriptRes) as ScriptLoadInfo; + script.metadata.grant = ["GM_setValue"]; + const sendMessage = vi.fn().mockResolvedValue({ code: 0 }); + const api = new GMApi("test", { sendMessage } as unknown as Message, {} as Message, script as any); + const getter = vi.fn(() => "secret"); + const payload = {} as Record; + Object.defineProperty(payload, "secret", { configurable: true, enumerable: true, get: getter }); + + api.GM_setValue(api, "hostile", payload); + + expect(getter).not.toHaveBeenCalled(); + expect(script.value.hostile).toBeUndefined(); + expect(sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ params: [expect.any(String), "hostile"] }) }) + ); + }); + + it("GM_setValues skips accessor fields without invoking them", () => { + const script = Object.assign({}, scriptRes, { + metadata: { grant: ["GM_setValues"] }, + value: {}, + }) as ScriptLoadInfo; + const sendMessage = vi.fn().mockResolvedValue({ code: 0 }); + const api = new GMApi("test", { sendMessage } as unknown as Message, {} as Message, script as any); + const getter = vi.fn(() => "secret"); + const payload = { valid: 1 } as Record; + Object.defineProperty(payload, "secret", { configurable: true, enumerable: true, get: getter }); + + api.GM_setValues(api, payload); + + expect(getter).not.toHaveBeenCalled(); + expect(script.value).toEqual({ valid: 1 }); + }); + + it("GM_setValues does not trust a hooked Array.prototype.push for transport", () => { + const script = Object.assign({}, scriptRes, { + metadata: { grant: ["GM_setValues"] }, + value: {}, + }) as ScriptLoadInfo; + const sendMessage = vi.fn().mockResolvedValue({ code: 0 }); + const api = new GMApi("test", { sendMessage } as unknown as Message, {} as Message, script as any); + const originalPush = Array.prototype.push; + Array.prototype.push = function (...items: unknown[]): number { + return originalPush.call(this, ...items, ["injected", encodeRValue("forged")]); + }; + + try { + api.GM_setValues(api, { valid: 1 }); + } finally { + Array.prototype.push = originalPush; + } + + expect(script.value).toEqual({ valid: 1 }); + expect(sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ params: [expect.any(String), [["valid", [0, 1]]]] }) }) + ); + }); + + it("拒绝可执行值,且不会把函数写入本地存储或传输层", () => { + const script = Object.assign({}, scriptRes) as ScriptLoadInfo; + script.metadata.grant = ["GM_setValue"]; + const sendMessage = vi.fn().mockResolvedValue({ code: 0 }); + const api = new GMApi("test", { sendMessage } as unknown as Message, {} as Message, script as any); + const executable = () => "secret"; + + api.GM_setValue(api, "executable", executable); + + expect(script.value.executable).toBeUndefined(); + expect(sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ params: [expect.any(String), "executable"] }) }) + ); + }); + + it("拒绝 Symbol 值,避免把不可结构化克隆的数据写入本地存储", () => { + const script = Object.assign({}, scriptRes) as ScriptLoadInfo; + script.metadata.grant = ["GM_setValue"]; + const sendMessage = vi.fn().mockResolvedValue({ code: 0 }); + const api = new GMApi("test", { sendMessage } as unknown as Message, {} as Message, script as any); + + api.GM_setValue(api, "symbol", Symbol("secret")); + + expect(script.value.symbol).toBeUndefined(); + expect(sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ params: [expect.any(String), "symbol"] }) }) + ); + }); + + it("GM_setValues deletes existing falsy values when given undefined", () => { + const script = Object.assign({}, scriptRes, { + metadata: { grant: ["GM_setValues"] }, + value: { zero: 0, no: false, empty: "", nil: null }, + }) as ScriptLoadInfo; + const sendMessage = vi.fn().mockResolvedValue({ code: 0 }); + const api = new GMApi("test", { sendMessage } as unknown as Message, {} as Message, script as any); + + api.GM_setValues(api, { zero: undefined, no: undefined, empty: undefined, nil: undefined }); + + expect(script.value).toEqual({}); + }); + it.concurrent("GM_setValues", async () => { const script = Object.assign({}, scriptRes) as ScriptLoadInfo; script.metadata.grant = ["GM_getValues", "GM_setValues"]; @@ -939,7 +1242,7 @@ return { value1, value2, value3, values1,values2, allValues1, allValues2, value4 // event id expect.stringMatching(/^.+::\d+$/), // the object payload - [["proxy-key", encodeRValue({})]], + [["proxy-key", encodeRValue(undefined)]], ], runFlag: expect.any(String), uuid: undefined, @@ -974,7 +1277,7 @@ return { value1, value2, value3, values1,values2, allValues1, allValues2, value4 expect(ret).toEqual({ ret1: { a: 123, b: 456, c: "789" }, ret2: { b: 456 }, - ret3: { "proxy-key": {} }, + ret3: { "proxy-key": undefined }, ret4: { window: undefined }, }); }); @@ -1137,6 +1440,9 @@ return { value1, value2, value3, values1,values2, allValues1, allValues2, value4 const script = Object.assign({ uuid: uuidv4() }, scriptRes) as ScriptLoadInfo; script.metadata.grant = ["GM_getValue", "GM_setValue", "GM_addValueChangeListener"]; script.metadata.storageName = ["testStorage"]; + script.executionHandle = "page-binding"; + script.executionEnvTag = "it"; + script.executionRunFlag = "canonical-run"; script.code = ` return new Promise(resolve=>{ GM_addValueChangeListener("param1", (name, oldValue, newValue, remote)=>{ @@ -1165,7 +1471,7 @@ return { value1, value2, value3, values1,values2, allValues1, allValues2, value4 entries: [["param1", encodeRValue(123), encodeRValue(undefined)]], uuid: script.uuid, storageName: script.uuid, - sender: { runFlag: exec.sandboxContext!.runFlag, tabId: -2 }, + sender: { runFlag: script.executionRunFlag, tabId: -2 }, valueUpdated: true, }); const ret = await retPromise; @@ -1211,6 +1517,27 @@ return { value1, value2, value3, values1,values2, allValues1, allValues2, value4 const ret2 = await retPromise; expect(ret2).toEqual({ name: "param2", oldValue: undefined, newValue: 456, remote: true }); }); + + it.concurrent("value change listeners receive snapshots instead of the cached object", () => { + const script = Object.assign({ uuid: uuidv4() }, scriptRes) as ScriptLoadInfo; + script.metadata.grant = ["GM_getValue", "GM_addValueChangeListener"]; + script.value = {}; + const api = new GMApi("test", {} as Message, {} as Message, script); + api.GM_addValueChangeListener(api, "snapshot", (_name, _oldValue, newValue) => { + const snapshot = newValue as { nested: { value: number } }; + snapshot.nested.value = 99; + }); + + api.valueUpdate({ + entries: [["snapshot", encodeRValue({ nested: { value: 1 } }), encodeRValue(undefined)]], + uuid: script.uuid, + storageName: script.uuid, + sender: { runFlag: "remote", tabId: -2 }, + valueUpdated: true, + }); + + expect(api.GM_getValue(api, "snapshot")).toEqual({ nested: { value: 1 } }); + }); it.concurrent("异步GM.setValue,等待回调", async () => { const script = Object.assign({}, scriptRes) as ScriptLoadInfo; script.metadata.grant = ["GM.getValue", "GM.setValue"]; @@ -1245,7 +1572,7 @@ return { value1, value2, value3, values1,values2, allValues1, allValues2, value4 entries: [["a", encodeRValue(123), encodeRValue(undefined)]], uuid: script.uuid, storageName: script.uuid, - sender: { runFlag: exec.sandboxContext!.runFlag, tabId: -2 }, + sender: { runFlag: actualCall.data.runFlag, tabId: -2 }, valueUpdated: true, }); @@ -1254,6 +1581,45 @@ return { value1, value2, value3, values1,values2, allValues1, allValues2, value4 }); }); +describe("GM_openInTab DTO", () => { + it("does not execute accessor options", () => { + const script = Object.assign({}, scriptRes) as ScriptLoadInfo; + script.metadata.grant = ["GM_openInTab"]; + const getter = vi.fn(() => "forged"); + const options = { active: true } as Record; + Object.defineProperty(options, "secret", { enumerable: true, configurable: true, get: getter }); + const sendMessage = vi.fn().mockResolvedValue(1); + const api = new GMApi("test", { sendMessage } as unknown as Message, {} as Message, script); + + api.GM_openInTab(api, "https://example.com", options as never); + + expect(getter).not.toHaveBeenCalled(); + const sentOptions = sendMessage.mock.calls[0][0].data.params[1]; + expect(sentOptions.active).toBe(true); + expect(Object.getOwnPropertyDescriptor(sentOptions, "secret")).toBeUndefined(); + }); +}); + +describe("GM_notification DTO", () => { + it("does not execute accessor details", async () => { + const script = Object.assign({}, scriptRes) as ScriptLoadInfo; + script.metadata.grant = ["GM_notification"]; + const getter = vi.fn(() => "forged"); + const details = { text: "safe" } as Record; + Object.defineProperty(details, "secret", { enumerable: true, configurable: true, get: getter }); + const sendMessage = vi.fn().mockResolvedValue("notification-id"); + const api = new GMApi("test", { sendMessage } as unknown as Message, {} as Message, script); + + api.GM_notification(api, details as never); + await Promise.resolve(); + + expect(getter).not.toHaveBeenCalled(); + const sentDetails = sendMessage.mock.calls[0][0].data.params[0]; + expect(sentDetails.text).toBe("safe"); + expect(Object.getOwnPropertyDescriptor(sentDetails, "secret")).toBeUndefined(); + }); +}); + describe("@grant GM_download", () => { it("空 url 应触发 onerror 而不是发起下载(GM_download)", async () => { const script = Object.assign({}, scriptRes) as ScriptLoadInfo; diff --git a/src/app/service/content/gm_api/gm_api.ts b/src/app/service/content/gm_api/gm_api.ts index d9c52c8e4..2e517987a 100644 --- a/src/app/service/content/gm_api/gm_api.ts +++ b/src/app/service/content/gm_api/gm_api.ts @@ -1,4 +1,4 @@ -import { customClone, Native } from "../global"; +import { customClone, nativeApply, Native } from "../global"; import type { Message, MessageConnect } from "@Packages/message/types"; import type { CustomEventMessage } from "@Packages/message/custom_event_message"; import type { @@ -9,9 +9,9 @@ import type { SWScriptMenuItemOption, TScriptMenuItemID, TScriptMenuItemKey, - MessageRequest, } from "@App/app/service/service_worker/types"; import { base64ToBlob, randNum, randomMessageFlag, strToBase64 } from "@App/pkg/utils/utils"; +import { uuidv4 } from "@App/pkg/utils/uuid"; import LoggerCore from "@App/app/logger/core"; import EventEmitter from "eventemitter3"; import GMContext from "./gm_context"; @@ -19,12 +19,13 @@ import { type ScriptRunResource } from "@App/app/repo/scripts"; import type { ValueUpdateDataEncoded } from "../types"; import { connect, sendMessage } from "@Packages/message/client"; import { ScriptEnvTag } from "@Packages/message/consts"; +import { isExtensionBlobUrl } from "../page_rpc"; import { getStorageName } from "@App/pkg/utils/utils"; import { ListenerManager } from "../listener_manager"; import { decodeRValue, encodeRValue, type REncoded } from "@App/pkg/utils/message_value"; import { type TGMKeyValue } from "@App/app/repo/value"; import type { ContextType } from "./gm_xhr"; -import { convObjectToURL, GM_xmlhttpRequest, toBlobURL, urlToDocumentInContentPage } from "./gm_xhr"; +import { convObjectToURL, GM_xmlhttpRequest, parseSerializedDocumentResponse, toBlobURL } from "./gm_xhr"; // 导入 CAT Agent API 以触发装饰器注册 // 注意:不能使用 import "./cat_agent",sideEffects 配置会导致 tree-shaking 移除纯副作用导入 import CATAgentApi from "./cat_agent"; @@ -59,12 +60,47 @@ let valChangeCounterId = 0; let valChangeRandomId = `${randNum(8e11, 2e12).toString(36)}`; -const valueChangePromiseMap = new Map(); +const copyOwnEnumerableDataProperties = (value: object): Record => { + const result = Native.objectCreate(null) as Record; + const keys = Native.reflectOwnKeys(value); + for (let index = 0; index < keys.length; index += 1) { + const key = keys[index]; + if (typeof key !== "string") continue; + const descriptor = Native.objectGetOwnPropertyDescriptor(value, key); + if (!descriptor || !descriptor.enumerable || !("value" in descriptor)) continue; + result[key] = descriptor.value; + } + return result; +}; + +// 回调表不暴露 Map 原型,避免页面改写 Map 方法后影响值更新确认。 +const valueChangePromiseMap: Record void> = Object.create(null); + +const setOwnValue = (store: Record, key: string, value: any): void => { + Native.objectDefineProperty(store, key, { + configurable: true, + enumerable: true, + writable: true, + value, + }); +}; + +// 通知 ID 只属于对应 GM context;WeakMap 不让脚本结束后残留监听状态。 +const notificationTagMaps = new Native.WeakMap>(); + +const getNotificationTagMap = (owner: object): Map => { + let map = notificationTagMaps.get(owner); + if (!map) { + map = new Native.Map(); + notificationTagMaps.set(owner, map); + } + return map; +}; const execEnvInit = (execEnv: GMApi) => { if (!execEnv.contentEnvKey) { execEnv.contentEnvKey = randomMessageFlag(); // 不重复识别字串。用于区分 mainframe subframe 等执行环境 - execEnv.menuKeyRegistered = new Set(); + execEnv.menuKeyRegistered = new Native.Set(); execEnv.menuIdCounter = 0; execEnv.regMenuCounter = 0; } @@ -115,7 +151,7 @@ class GM_Base implements IGM_Base { constructor(options: any = null, obj: any = null) { if (obj !== integrity) throw new TypeError("Illegal invocation"); - Object.assign(this, options); + Native.objectAssign(this, options); } @GMContext.protected() @@ -136,14 +172,37 @@ class GM_Base implements IGM_Base { if (this.loadScriptPromise) { await this.loadScriptPromise; } + // USER_SCRIPT 自己的 realm 已有 DOM 与 fetch;这些辅助操作必须留在本地, + // 不能改走只有隔离 broker 才实现的内部 CAT service worker 请求。 + if (this.scriptRes.executionEnvTag === ScriptEnvTag.content) { + if (api === "CAT_fetchBlob") { + if (!isExtensionBlobUrl(params[0])) throw new Error("CAT_fetchBlob expects an extension blob URL"); + return fetch(params[0]).then((response) => response.blob()); + } + if (api === "CAT_createBlobUrl") { + if (typeof URL.createObjectURL !== "function") throw new Error("Blob URLs are unavailable in USER_SCRIPT"); + return URL.createObjectURL(params[0] as Blob); + } + } let ret; try { - ret = await sendMessage(this.message, `${this.prefix}/runtime/gmApi`, { - uuid: this.scriptRes.uuid, - api, - params, - runFlag: this.runFlag, - } as MessageRequest); + // 有页面句柄时走版本化 RPC;后台脚本和未迁移上下文继续使用旧请求形状。 + const request = this.scriptRes.executionHandle + ? { + version: 1 as const, + requestId: uuidv4(), + handle: this.scriptRes.executionHandle, + ...(this.scriptRes.executionEnvTag === "ct" ? { executionHandle: this.scriptRes.executionHandle } : {}), + api, + params, + } + : { + uuid: this.scriptRes.uuid, + api, + params, + runFlag: this.runFlag, + }; + ret = await sendMessage(this.message, `${this.prefix}/runtime/gmApi`, request); } catch (e: any) { if (`${e?.message || e}`.includes("Extension context invalidated.")) { this.setInvalidContext(); // 之后不再进行 sendMessage 跟 EE操作 @@ -157,14 +216,29 @@ class GM_Base implements IGM_Base { // 长连接使用,connect只用于接受消息,不发送消息 @GMContext.protected() - public connect(api: string, params: any[]) { + public async connect(api: string, params: any[]) { if (!this.message || !this.scriptRes) return new Promise(() => {}); - return connect(this.message, `${this.prefix}/runtime/gmApi`, { - uuid: this.scriptRes.uuid, - api, - params, - runFlag: this.runFlag, - } as MessageRequest); + if (this.loadScriptPromise) { + await this.loadScriptPromise; + } + if (!this.message || !this.scriptRes) return new Promise(() => {}); + // 长连接也必须携带同一页面句柄,否则 broker 无法把连接绑定回脚本和文档。 + const request = this.scriptRes.executionHandle + ? { + version: 1 as const, + requestId: uuidv4(), + handle: this.scriptRes.executionHandle, + ...(this.scriptRes.executionEnvTag === "ct" ? { executionHandle: this.scriptRes.executionHandle } : {}), + api, + params, + } + : { + uuid: this.scriptRes.uuid, + api, + params, + runFlag: this.runFlag, + }; + return connect(this.message, `${this.prefix}/runtime/gmApi`, request); } @GMContext.protected() @@ -176,9 +250,9 @@ class GM_Base implements IGM_Base { const valueStore = scriptRes.value; const remote = sender.runFlag !== this.runFlag; if (!remote && id) { - const fn = valueChangePromiseMap.get(id); + const fn = valueChangePromiseMap[id]; if (fn) { - valueChangePromiseMap.delete(id); + delete valueChangePromiseMap[id]; fn(); } } @@ -189,13 +263,16 @@ class GM_Base implements IGM_Base { const oldValue = decodeRValue(rTyped2); // 触发,并更新值 if (value === undefined) { - if (valueStore[key] !== undefined) { + if (Native.objectHasOwn(valueStore, key)) { delete valueStore[key]; } } else { - valueStore[key] = value; + setOwnValue(valueStore, key, value); } - this.valueChangeListener.execute(key, oldValue, value, remote, sender.tabId); + // 监听器属于脚本,传副本避免回调修改 GM 存储或跨 context 共享对象。 + const listenerValue = value && typeof value === "object" ? customClone(value) : value; + const listenerOldValue = oldValue && typeof oldValue === "object" ? customClone(oldValue) : oldValue; + this.valueChangeListener.execute(key, listenerOldValue, listenerValue, remote, sender.tabId); } } } @@ -204,17 +281,14 @@ class GM_Base implements IGM_Base { @GMContext.protected() emitEvent(event: string, eventId: string, data: any) { if (!this.EE) return; - this.EE.emit(`${event}:${eventId}`, data); + // 事件回调同样不能拿到 broker 内部对象的可变引用。 + const callbackData = data && typeof data === "object" ? customClone(data) : data; + this.EE.emit(`${event}:${eventId}`, callbackData); } } // GMApi 定义 外部用API函数。不使用@protected export default class GMApi extends GM_Base { - /** - * - */ - notificationTagMap?: Map; - constructor( public prefix: string, public message: Message, @@ -232,7 +306,6 @@ export default class GMApi extends GM_Base { scriptRes, valueChangeListener, EE, - notificationTagMap: new Map(), eventId: 0, setInvalidContext() { if (invalid) return; @@ -255,7 +328,7 @@ export default class GMApi extends GM_Base { static _GM_getValue(a: GMApi, key: string, defaultValue?: any) { if (!a.scriptRes) return undefined; - const ret = a.scriptRes.value[key]; + const ret = Native.objectHasOwn(a.scriptRes.value, key) ? a.scriptRes.value[key] : undefined; if (ret !== undefined) { if (ret && typeof ret === "object") { return customClone(ret)!; @@ -267,15 +340,15 @@ export default class GMApi extends GM_Base { // 获取脚本的值,可以通过@storageName让多个脚本共享一个储存空间 @GMContext.API() - public GM_getValue(key: string, defaultValue?: any) { - return _GM_getValue(this, key, defaultValue); + public GM_getValue(ctx: GMApi, key: string, defaultValue?: any) { + return _GM_getValue(ctx, key, defaultValue); } @GMContext.API() - public "GM.getValue"(key: string, defaultValue?: any): Promise { + public "GM.getValue"(ctx: GMApi, key: string, defaultValue?: any): Promise { // 兼容GM.getValue return new Promise((resolve) => { - const ret = _GM_getValue(this, key, defaultValue); + const ret = _GM_getValue(ctx, key, defaultValue); resolve(ret); }); } @@ -290,18 +363,18 @@ export default class GMApi extends GM_Base { } const id = `${valChangeRandomId}::${++valChangeCounterId}`; if (promise) { - valueChangePromiseMap.set(id, promise); + valueChangePromiseMap[id] = promise; } if (value === undefined) { delete a.scriptRes.value[key]; a.sendMessage("GM_setValue", [id, key]); } else { - // 对object的value进行一次转化 - if (value && typeof value === "object") { + // 对对象或函数值进行一次转化 + if (typeof value === "function" || typeof value === "symbol" || (value !== null && typeof value === "object")) { value = customClone(value); } // customClone 可能返回 undefined - a.scriptRes.value[key] = value; + setOwnValue(a.scriptRes.value, key, value); if (value === undefined) { a.sendMessage("GM_setValue", [id, key]); } else { @@ -320,108 +393,122 @@ export default class GMApi extends GM_Base { } const id = `${valChangeRandomId}::${++valChangeCounterId}`; if (promise) { - valueChangePromiseMap.set(id, promise); + valueChangePromiseMap[id] = promise; } const valueStore = a.scriptRes.value; const keyValuePairs = [] as [string, REncoded][]; - for (const [key, value] of Object.entries(values)) { + const valueEntries: [string, unknown][] = []; + const valueKeys = Native.reflectOwnKeys(values); + for (let index = 0; index < valueKeys.length; index += 1) { + const key = valueKeys[index]; + if (typeof key !== "string") continue; + const descriptor = Native.objectGetOwnPropertyDescriptor(values, key); + if (!descriptor || !descriptor.enumerable || !("value" in descriptor)) continue; + valueEntries[valueEntries.length] = [key, descriptor.value]; + } + for (let index = 0; index < valueEntries.length; index += 1) { + const [key, value] = valueEntries[index]; let value_ = value; if (value_ === undefined) { - if (valueStore[key]) delete valueStore[key]; + if (Native.objectHasOwn(valueStore, key)) delete valueStore[key]; } else { - // 对object的value进行一次转化 - if (value_ && typeof value_ === "object") { + // 对对象或函数值进行一次转化 + if ( + typeof value_ === "function" || + typeof value_ === "symbol" || + (value_ !== null && typeof value_ === "object") + ) { value_ = customClone(value_); } // customClone 可能返回 undefined - valueStore[key] = value_; + setOwnValue(valueStore, key, value_); } // 避免undefined 等空值流失,先进行映射处理 - keyValuePairs.push([key, encodeRValue(value_)]); + keyValuePairs[keyValuePairs.length] = [key, encodeRValue(value_)]; } a.sendMessage("GM_setValues", [id, keyValuePairs]); return id; } @GMContext.API() - public GM_setValue(key: string, value: any) { - _GM_setValue(this, null, key, value); + public GM_setValue(ctx: GMApi, key: string, value: any) { + _GM_setValue(ctx, null, key, value); } @GMContext.API() - public "GM.setValue"(key: string, value: any): Promise { + public "GM.setValue"(ctx: GMApi, key: string, value: any): Promise { // Asynchronous wrapper for GM_setValue to support GM.setValue return new Promise((resolve) => { - _GM_setValue(this, resolve, key, value); + _GM_setValue(ctx, resolve, key, value); }); } @GMContext.API() - public GM_deleteValue(key: string): void { - _GM_setValue(this, null, key, undefined); + public GM_deleteValue(ctx: GMApi, key: string): void { + _GM_setValue(ctx, null, key, undefined); } @GMContext.API() - public "GM.deleteValue"(key: string): Promise { + public "GM.deleteValue"(ctx: GMApi, key: string): Promise { // Asynchronous wrapper for GM_deleteValue to support GM.deleteValue return new Promise((resolve) => { - _GM_setValue(this, resolve, key, undefined); + _GM_setValue(ctx, resolve, key, undefined); }); } @GMContext.API() - public GM_listValues(): string[] { - if (!this.scriptRes) return []; - const keys = Object.keys(this.scriptRes.value); + public GM_listValues(ctx: GMApi): string[] { + if (!ctx.scriptRes) return []; + const keys = Native.objectKeys(ctx.scriptRes.value); return keys; } @GMContext.API() - public "GM.listValues"(): Promise { + public "GM.listValues"(ctx: GMApi): Promise { // Asynchronous wrapper for GM_listValues to support GM.listValues return new Promise((resolve) => { - if (!this.scriptRes) return resolve([]); - const keys = Object.keys(this.scriptRes.value); + if (!ctx.scriptRes) return resolve([]); + const keys = Native.objectKeys(ctx.scriptRes.value); resolve(keys); }); } @GMContext.API() - public GM_setValues(values: TGMKeyValue) { + public GM_setValues(ctx: GMApi, values: TGMKeyValue) { if (!values || typeof values !== "object") { throw new Error("GM_setValues: values must be an object"); } - _GM_setValues(this, null, values); + _GM_setValues(ctx, null, values); } @GMContext.API() - public GM_getValues(keysOrDefaults: TGMKeyValue | string[] | null | undefined) { - if (!this.scriptRes) return {}; + public GM_getValues(ctx: GMApi, keysOrDefaults: TGMKeyValue | string[] | null | undefined) { + if (!ctx.scriptRes) return {}; if (!keysOrDefaults) { // Returns all values - return customClone(this.scriptRes.value)!; + return customClone(ctx.scriptRes.value)!; } - const result: TGMKeyValue = {}; - if (Array.isArray(keysOrDefaults)) { + const result: TGMKeyValue = Native.objectCreate(null); + if (Native.arrayIsArray(keysOrDefaults)) { // 键名数组 // Handle array of keys (e.g., ['foo', 'bar']) for (let index = 0; index < keysOrDefaults.length; index++) { const key = keysOrDefaults[index]; - if (key in this.scriptRes.value) { + if (Native.objectHasOwn(ctx.scriptRes.value, key)) { // 对object的value进行一次转化 - let value = this.scriptRes.value[key]; + let value = ctx.scriptRes.value[key]; if (value && typeof value === "object") { value = customClone(value)!; } - result[key] = value; + setOwnValue(result, key, value); } } } else { // 对象 键: 默认值 // Handle object with default values (e.g., { foo: 1, bar: 2, baz: 3 }) - for (const key of Object.keys(keysOrDefaults)) { + for (const key of Native.objectKeys(keysOrDefaults)) { const defaultValue = keysOrDefaults[key]; - result[key] = _GM_getValue(this, key, defaultValue); + setOwnValue(result, key, _GM_getValue(ctx, key, defaultValue)); } } return result; @@ -429,29 +516,29 @@ export default class GMApi extends GM_Base { // Asynchronous wrapper for GM.getValues @GMContext.API({ depend: ["GM_getValues"] }) - public "GM.getValues"(keysOrDefaults: TGMKeyValue | string[] | null | undefined): Promise { - if (!this.scriptRes) return new Promise(() => {}); + public "GM.getValues"(ctx: GMApi, keysOrDefaults: TGMKeyValue | string[] | null | undefined): Promise { + if (!ctx.scriptRes) return new Promise(() => {}); return new Promise((resolve) => { - const ret = this.GM_getValues(keysOrDefaults); + const ret = GMApi.prototype.GM_getValues(ctx, keysOrDefaults); resolve(ret); }); } @GMContext.API() - public "GM.setValues"(values: { [key: string]: any }): Promise { - if (!this.scriptRes) return new Promise(() => {}); + public "GM.setValues"(ctx: GMApi, values: { [key: string]: any }): Promise { + if (!ctx.scriptRes) return new Promise(() => {}); return new Promise((resolve) => { if (!values || typeof values !== "object") { throw new Error("GM.setValues: values must be an object"); } - _GM_setValues(this, resolve, values); + _GM_setValues(ctx, resolve, values); }); } @GMContext.API() - public GM_deleteValues(keys: string[]) { - if (!this.scriptRes) return; - if (!Array.isArray(keys)) { + public GM_deleteValues(ctx: GMApi, keys: string[]) { + if (!ctx.scriptRes) return; + if (!Native.arrayIsArray(keys)) { console.warn("GM_deleteValues: keys must be string[]"); return; } @@ -459,94 +546,111 @@ export default class GMApi extends GM_Base { for (const key of keys) { req[key] = undefined; } - _GM_setValues(this, null, req); + _GM_setValues(ctx, null, req); } // Asynchronous wrapper for GM.deleteValues @GMContext.API() - public "GM.deleteValues"(keys: string[]): Promise { - if (!this.scriptRes) return new Promise(() => {}); + public "GM.deleteValues"(ctx: GMApi, keys: string[]): Promise { + if (!ctx.scriptRes) return new Promise(() => {}); return new Promise((resolve) => { - if (!Array.isArray(keys)) { + if (!Native.arrayIsArray(keys)) { throw new Error("GM.deleteValues: keys must be string[]"); } else { const req = {} as Record; for (const key of keys) { req[key] = undefined; } - _GM_setValues(this, resolve, req); + _GM_setValues(ctx, resolve, req); } }); } @GMContext.API() - public GM_addValueChangeListener(name: string, listener: GMTypes.ValueChangeListener): number { - if (!this.valueChangeListener) return 0; - return this.valueChangeListener.add(name, listener); + public GM_addValueChangeListener(ctx: GMApi, name: string, listener: GMTypes.ValueChangeListener): number { + if (!ctx.valueChangeListener) return 0; + return ctx.valueChangeListener.add(name, listener); } @GMContext.API({ depend: ["GM_addValueChangeListener"] }) - public "GM.addValueChangeListener"(name: string, listener: GMTypes.ValueChangeListener): Promise { + public "GM.addValueChangeListener"(ctx: GMApi, name: string, listener: GMTypes.ValueChangeListener): Promise { return new Promise((resolve) => { - const ret = this.GM_addValueChangeListener(name, listener); + const ret = GMApi.prototype.GM_addValueChangeListener(ctx, name, listener); resolve(ret); }); } @GMContext.API() - public GM_removeValueChangeListener(listenerId: number): void { - if (!this.valueChangeListener) return; - this.valueChangeListener.remove(listenerId); + public GM_removeValueChangeListener(ctx: GMApi, listenerId: number): void { + if (!ctx.valueChangeListener) return; + ctx.valueChangeListener.remove(listenerId); } @GMContext.API({ depend: ["GM_removeValueChangeListener"] }) - public "GM.removeValueChangeListener"(listenerId: number): Promise { + public "GM.removeValueChangeListener"(ctx: GMApi, listenerId: number): Promise { return new Promise((resolve) => { - this.GM_removeValueChangeListener(listenerId); + GMApi.prototype.GM_removeValueChangeListener(ctx, listenerId); resolve(); }); } @GMContext.API() - public GM_log(message: string, level: GMTypes.LoggerLevel = "info", ...labels: GMTypes.LoggerLabel[]): void { - if (this.isInvalidContext()) return; + public GM_log( + ctx: GMApi, + message: string, + level: GMTypes.LoggerLevel = "info", + ...labels: GMTypes.LoggerLabel[] + ): void { + if (ctx.isInvalidContext()) return; if (typeof message !== "string") { message = Native.jsonStringify(message); } - this.sendMessage("GM_log", [`${message}`, `${level}`, labels]); + ctx.sendMessage("GM_log", [`${message}`, `${level}`, labels]); } @GMContext.API({ depend: ["GM_log"] }) public "GM.log"( + ctx: GMApi, message: string, level: GMTypes.LoggerLevel = "info", ...labels: GMTypes.LoggerLabel[] ): Promise { return new Promise((resolve) => { - this.GM_log(message, level, ...labels); + GMApi.prototype.GM_log(ctx, message, level, ...labels); resolve(); }); } @GMContext.API() - public CAT_createBlobUrl(blob: Blob): Promise { - return Promise.resolve(toBlobURL(this, blob)); + public CAT_createBlobUrl(ctx: GMApi, blob: Blob): Promise { + return Promise.resolve(toBlobURL(ctx, blob)); } // 辅助GM_xml获取blob数据 @GMContext.API() - public CAT_fetchBlob(url: string): Promise { - return this.sendMessage("CAT_fetchBlob", [`${url}`]); + public CAT_fetchBlob(ctx: GMApi, url: string): Promise { + return ctx.sendMessage("CAT_fetchBlob", [`${url}`]); } @GMContext.API() - public async CAT_fetchDocument(url: string): Promise { + public async CAT_fetchDocument(ctx: GMApi, url: string): Promise { // 上下文已失效时直接返回,避免访问已释放的 message 造成异常 - if (this.isInvalidContext()) return undefined; + if (ctx.isInvalidContext()) return undefined; + + const isContentEnv = ctx.scriptRes?.executionEnvTag === ScriptEnvTag.content; + if (isContentEnv) { + // USER_SCRIPT 可直接在 content realm 创建 Document;跨到 scripting 只会丢失节点引用。 + return new Promise((resolve) => { + const xhr = new XMLHttpRequest(); + xhr.responseType = "document"; + xhr.open("GET", url); + xhr.onloadend = () => resolve((xhr.response as Document | null) || undefined); + xhr.onerror = () => resolve(undefined); + xhr.send(); + }); + } - const message = this.message as CustomEventMessage | null; - const isContentEnv = !!message && message.envTag === ScriptEnvTag.content; - return urlToDocumentInContentPage(this, url, isContentEnv); + return parseSerializedDocumentResponse(await ctx.sendMessage("CAT_fetchDocument", [`${url}`, isContentEnv])); } static _GM_cookie( @@ -583,36 +687,36 @@ export default class GMApi extends GM_Base { } @GMContext.API() - public "GM.cookie"(action: string, details: GMTypes.CookieDetails) { + public "GM.cookie"(ctx: GMApi, action: string, details: GMTypes.CookieDetails) { return new Promise((resolve, reject) => { - _GM_cookie(this, action, details, (cookie, error) => { + _GM_cookie(ctx, action, details, (cookie, error) => { error ? reject(error) : resolve(cookie); }); }); } @GMContext.API({ follow: "GM.cookie" }) - public "GM.cookie.set"(details: GMTypes.CookieDetails) { + public "GM.cookie.set"(ctx: GMApi, details: GMTypes.CookieDetails) { return new Promise((resolve, reject) => { - _GM_cookie(this, "set", details, (cookie, error) => { + _GM_cookie(ctx, "set", details, (cookie, error) => { error ? reject(error) : resolve(cookie); }); }); } @GMContext.API({ follow: "GM.cookie" }) - public "GM.cookie.list"(details: GMTypes.CookieDetails) { + public "GM.cookie.list"(ctx: GMApi, details: GMTypes.CookieDetails) { return new Promise((resolve, reject) => { - _GM_cookie(this, "list", details, (cookie, error) => { + _GM_cookie(ctx, "list", details, (cookie, error) => { error ? reject(error) : resolve(cookie); }); }); } @GMContext.API({ follow: "GM.cookie" }) - public "GM.cookie.delete"(details: GMTypes.CookieDetails) { + public "GM.cookie.delete"(ctx: GMApi, details: GMTypes.CookieDetails) { return new Promise((resolve, reject) => { - _GM_cookie(this, "delete", details, (cookie, error) => { + _GM_cookie(ctx, "delete", details, (cookie, error) => { error ? reject(error) : resolve(cookie); }); }); @@ -620,35 +724,39 @@ export default class GMApi extends GM_Base { @GMContext.API({ follow: "GM_cookie" }) public "GM_cookie.set"( + ctx: GMApi, details: GMTypes.CookieDetails, done: (cookie: GMTypes.Cookie[] | any, error: any | undefined) => void ) { - _GM_cookie(this, "set", details, done); + _GM_cookie(ctx, "set", details, done); } @GMContext.API({ follow: "GM_cookie" }) public "GM_cookie.list"( + ctx: GMApi, details: GMTypes.CookieDetails, done: (cookie: GMTypes.Cookie[] | any, error: any | undefined) => void ) { - _GM_cookie(this, "list", details, done); + _GM_cookie(ctx, "list", details, done); } @GMContext.API({ follow: "GM_cookie" }) public "GM_cookie.delete"( + ctx: GMApi, details: GMTypes.CookieDetails, done: (cookie: GMTypes.Cookie[] | any, error: any | undefined) => void ) { - _GM_cookie(this, "delete", details, done); + _GM_cookie(ctx, "delete", details, done); } @GMContext.API() public GM_cookie( + ctx: GMApi, action: string, details: GMTypes.CookieDetails, done: (cookie: GMTypes.Cookie[] | any, error: any | undefined) => void ) { - _GM_cookie(this, action, details, done); + _GM_cookie(ctx, action, details, done); } // 已注册的「菜单唯一键」集合,用于去重与解除绑定。 @@ -670,32 +778,43 @@ export default class GMApi extends GM_Base { @GMContext.API() public GM_registerMenuCommand( + ctx: GMApi, name: string, listener?: (inputValue?: any) => void, options_or_accessKey?: ScriptMenuItemOption | string ): TScriptMenuItemID { - if (!this.EE) return -1; - execEnvInit(this); - this.regMenuCounter! += 1; + if (!ctx.EE) return -1; + execEnvInit(ctx); + ctx.regMenuCounter! += 1; // 兼容 GM_registerMenuCommand(name, options_or_accessKey) if (!options_or_accessKey && typeof listener === "object") { options_or_accessKey = listener; listener = undefined; } // 浅拷贝避免修改/共用参数 - const options: SWScriptMenuItemOption = ( - typeof options_or_accessKey === "string" - ? { accessKey: options_or_accessKey } - : options_or_accessKey - ? { ...options_or_accessKey, id: undefined, individual: undefined } // id不直接储存在options (id 影响 groupKey 操作) - : {} - ) as ScriptMenuItemOption; + const optionObject = typeof options_or_accessKey === "object" && options_or_accessKey !== null; + let options: SWScriptMenuItemOption; + let optionId: string | number | undefined; + let optionIndividual: boolean | undefined; + if (typeof options_or_accessKey === "string") { + options = { accessKey: options_or_accessKey }; + } else if (optionObject) { + const safeOptions = copyOwnEnumerableDataProperties(options_or_accessKey as object); + optionId = safeOptions.id as string | number | undefined; + optionIndividual = safeOptions.individual as boolean | undefined; + // id不直接储存在options (id 影响 groupKey 操作) + safeOptions.id = undefined; + safeOptions.individual = undefined; + options = safeOptions as SWScriptMenuItemOption; + } else { + options = {}; + } const isSeparator = !listener && !name; - let isIndividual = typeof options_or_accessKey === "object" ? options_or_accessKey.individual : undefined; + let isIndividual = optionObject ? optionIndividual : undefined; if (isIndividual === undefined && isSeparator) { isIndividual = true; } - options.mIndividualKey = isIndividual ? this.regMenuCounter : 0; + options.mIndividualKey = isIndividual ? ctx.regMenuCounter : 0; if (options.autoClose === undefined) { options.autoClose = true; } @@ -710,54 +829,57 @@ export default class GMApi extends GM_Base { } else { options.mSeparator = false; } - let providedId: string | number | undefined = - typeof options_or_accessKey === "object" ? options_or_accessKey.id : undefined; - if (providedId === undefined) providedId = this.menuIdCounter! += 1; // 如无指定,使用累计器id + let providedId: string | number | undefined = optionObject ? optionId : undefined; + if (providedId === undefined) providedId = ctx.menuIdCounter! += 1; // 如无指定,使用累计器id const ret = providedId! as TScriptMenuItemID; providedId = `t${providedId!}`; // 见 TScriptMenuItemID 注释 - providedId = `${this.contentEnvKey!}.${providedId}` as TScriptMenuItemKey; // 区分 subframe mainframe,见 TScriptMenuItemKey 注释 + providedId = `${ctx.contentEnvKey!}.${providedId}` as TScriptMenuItemKey; // 区分 subframe mainframe,见 TScriptMenuItemKey 注释 const menuKey = providedId; // menuKey为唯一键:{环境识别符}.t{注册ID} // 检查之前有否注册 - if (menuKey && this.menuKeyRegistered!.has(menuKey)) { + if (menuKey && ctx.menuKeyRegistered!.has(menuKey)) { // 有注册过,先移除 listeners - this.EE.removeAllListeners("menuClick:" + menuKey); + ctx.EE.removeAllListeners("menuClick:" + menuKey); } else { // 没注册过,先记录一下 - this.menuKeyRegistered!.add(menuKey); + ctx.menuKeyRegistered!.add(menuKey); } if (listener) { // GM_registerMenuCommand("hi", undefined, {accessKey:"h"}) 时TM不会报错 - this.EE.addListener("menuClick:" + menuKey, listener); + ctx.EE.addListener("menuClick:" + menuKey, listener); } // 发送至 service worker 处理(唯一键,显示名字,不包括id的其他设定) - this.sendMessage("GM_registerMenuCommand", [menuKey, `${name}`, options] as GMRegisterMenuCommandParam); + ctx.sendMessage("GM_registerMenuCommand", [menuKey, `${name}`, options] as GMRegisterMenuCommandParam); return ret; } @GMContext.API({ depend: ["GM_registerMenuCommand"] }) public "GM.registerMenuCommand"( + ctx: GMApi, name: string, listener?: (inputValue?: any) => void, options_or_accessKey?: ScriptMenuItemOption | string ): Promise { return new Promise((resolve) => { - const ret = this.GM_registerMenuCommand(name, listener, options_or_accessKey); + const ret = GMApi.prototype.GM_registerMenuCommand(ctx, name, listener, options_or_accessKey); resolve(ret); }); } @GMContext.API({ depend: ["GM_registerMenuCommand"] }) - public CAT_registerMenuInput(...args: Parameters): TScriptMenuItemID { - return this.GM_registerMenuCommand(...args); + public CAT_registerMenuInput( + ctx: GMApi, + ...args: [name: string, listener?: (inputValue?: any) => void, options_or_accessKey?: ScriptMenuItemOption | string] + ): TScriptMenuItemID { + return GMApi.prototype.GM_registerMenuCommand(ctx, ...args); } @GMContext.API() - public GM_addStyle(css: string): Element | undefined { - if (!this.message || !this.scriptRes) return; + public GM_addStyle(ctx: GMApi, css: string): Element | undefined { + if (!ctx.message || !ctx.scriptRes) return; if (typeof css !== "string") throw new Error("The parameter 'css' of GM_addStyle shall be a string."); // 与content页的消息通讯实际是同步,此方法不需要经过background // 这里直接使用同步的方式去处理, 不要有promise - const resp = (this.contentMsg).syncSendMessage({ + const resp = (ctx.contentMsg).syncSendMessage({ action: `content/runtime/addElement`, data: { params: [ @@ -772,24 +894,25 @@ export default class GMApi extends GM_Base { if (resp.code) { throw new Error(resp.message); } - return (this.contentMsg).getAndDelRelatedTarget(resp.data) as Element; + return (ctx.contentMsg).getAndDelRelatedTarget(resp.data) as Element; } @GMContext.API({ depend: ["GM_addStyle"] }) - public "GM.addStyle"(css: string): Promise { + public "GM.addStyle"(ctx: GMApi, css: string): Promise { return new Promise((resolve) => { - const ret = this.GM_addStyle(css); + const ret = GMApi.prototype.GM_addStyle(ctx, css); resolve(ret); }); } @GMContext.API() public GM_addElement( + ctx: GMApi, parentNode: Node | string, tagName: string | Record, attrs: Record | null = {} ): Element | undefined { - if (!this.message || !this.scriptRes) return; + if (!ctx.message || !ctx.scriptRes) return; // 与content页的消息通讯实际是同步, 此方法不需要经过background // 这里直接使用同步的方式去处理, 不要有promise // 在content脚本执行的话,与直接 DOM 无异 @@ -799,7 +922,7 @@ export default class GMApi extends GM_Base { let parentNodeId: number | null; if (typeof parentNode !== "string") { - const id = (this.contentMsg).sendRelatedTarget(parentNode); + const id = (ctx.contentMsg).sendRelatedTarget(parentNode); parentNodeId = id; } else { parentNodeId = null; @@ -813,22 +936,30 @@ export default class GMApi extends GM_Base { } // 控制传送参数,避免参数出现 non-json-selizable - const attrsCT = {} as Record; - const setAttr = {} as Record; - for (const [key, value] of Object.entries(attrs as Record)) { - if (typeof value === "string" || typeof value === "number") { - // 数字不是标准的 attribute value type, 但常见于实际使用 - attrsCT[key] = value; - } else { - // property setter for non attribute (e.g. Function, Symbol, boolean, etc) - // Function, Symbol 无法跨环境传递 - setAttr[key] = value; + const attrsCT = Native.objectCreate(null) as Record; + const setAttr = Native.objectCreate(null) as Record; + if (attrs !== null) { + const keys = Native.reflectOwnKeys(attrs); + for (let index = 0; index < keys.length; index += 1) { + const key = keys[index]; + if (typeof key !== "string") continue; + const descriptor = Native.objectGetOwnPropertyDescriptor(attrs, key); + if (!descriptor || !descriptor.enumerable || !("value" in descriptor)) continue; + const value = descriptor.value; + if (typeof value === "string" || typeof value === "number") { + // 数字不是标准的 attribute value type, 但常见于实际使用 + attrsCT[key] = value; + } else { + // property setter for non attribute (e.g. Function, Symbol, boolean, etc) + // Function, Symbol 无法跨环境传递 + setAttr[key] = value; + } } } // 使用contentMsg同步发送消息到content脚本,由content脚本创建元素并返回 // 不使用message,因为message是在scripting环境处理的,会因为扩展的 CSP 而无法操作 DOM - const resp = (this.contentMsg).syncSendMessage({ + const resp = (ctx.contentMsg).syncSendMessage({ action: `content/runtime/addElement`, data: { params: [parentNodeId, tagName, attrsCT], @@ -838,7 +969,7 @@ export default class GMApi extends GM_Base { throw new Error(resp.message); } - const el = (this.contentMsg).getAndDelRelatedTarget(resp.data) as Element; + const el = (ctx.contentMsg).getAndDelRelatedTarget(resp.data) as Element; // 设置属性 for (const [key, value] of Object.entries(setAttr)) { (el as any)[key] = value; @@ -850,34 +981,35 @@ export default class GMApi extends GM_Base { @GMContext.API({ depend: ["GM_addElement"] }) public "GM.addElement"( + ctx: GMApi, parentNode: Node | string, tagName: string | Record, attrs: Record | null = {} ): Promise { return new Promise((resolve) => { - const ret = this.GM_addElement(parentNode, tagName, attrs); + const ret = GMApi.prototype.GM_addElement(ctx, parentNode, tagName, attrs); resolve(ret); }); } @GMContext.API() - public GM_unregisterMenuCommand(menuId: TScriptMenuItemID): void { - if (!this.EE) return; - if (!this.contentEnvKey) { + public GM_unregisterMenuCommand(ctx: GMApi, menuId: TScriptMenuItemID): void { + if (!ctx.EE) return; + if (!ctx.contentEnvKey) { return; } let menuKey = `t${menuId}`; // 见 TScriptMenuItemID 注释 - menuKey = `${this.contentEnvKey!}.${menuKey}` as TScriptMenuItemKey; // 区分 subframe mainframe,见 TScriptMenuItemKey 注释 - this.menuKeyRegistered!.delete(menuKey); - this.EE.removeAllListeners("menuClick:" + menuKey); + menuKey = `${ctx.contentEnvKey!}.${menuKey}` as TScriptMenuItemKey; // 区分 subframe mainframe,见 TScriptMenuItemKey 注释 + ctx.menuKeyRegistered!.delete(menuKey); + ctx.EE.removeAllListeners("menuClick:" + menuKey); // 发送至 service worker 处理(唯一键) - this.sendMessage("GM_unregisterMenuCommand", [menuKey] as GMUnRegisterMenuCommandParam); + ctx.sendMessage("GM_unregisterMenuCommand", [menuKey] as GMUnRegisterMenuCommandParam); } @GMContext.API({ depend: ["GM_unregisterMenuCommand"] }) - public "GM.unregisterMenuCommand"(menuId: TScriptMenuItemID): Promise { + public "GM.unregisterMenuCommand"(ctx: GMApi, menuId: TScriptMenuItemID): Promise { return new Promise((resolve) => { - this.GM_unregisterMenuCommand(menuId); + GMApi.prototype.GM_unregisterMenuCommand(ctx, menuId); resolve(); }); } @@ -885,21 +1017,21 @@ export default class GMApi extends GM_Base { @GMContext.API({ depend: ["GM_unregisterMenuCommand"], }) - public CAT_unregisterMenuInput(...args: Parameters): void { - this.GM_unregisterMenuCommand(...args); + public CAT_unregisterMenuInput(ctx: GMApi, menuId: TScriptMenuItemID): void { + GMApi.prototype.GM_unregisterMenuCommand(ctx, menuId); } @GMContext.API() - public CAT_userConfig() { - return this.sendMessage("CAT_userConfig", []); + public CAT_userConfig(ctx: GMApi) { + return ctx.sendMessage("CAT_userConfig", []); } @GMContext.API({ depend: ["CAT_fetchBlob"], }) - public async CAT_fileStorage(action: "list" | "download" | "upload" | "delete" | "config", details: any) { + public async CAT_fileStorage(ctx: GMApi, action: "list" | "download" | "upload" | "delete" | "config", details: any) { if (action === "config") { - this.sendMessage("CAT_fileStorage", ["config"]); + ctx.sendMessage("CAT_fileStorage", ["config"]); return; } const sendDetails: CATType.CATFileStorageDetails = { @@ -909,44 +1041,42 @@ export default class GMApi extends GM_Base { file: details.file, }; if (action === "upload") { - const url = await toBlobURL(this, details.data); + const url = await toBlobURL(ctx, details.data); sendDetails.data = url; } - this.sendMessage("CAT_fileStorage", [`${action}`, sendDetails]).then( - async (resp: { action: string; data: any }) => { - switch (resp.action) { - case "onload": { - if (action === "download") { - // 读取blob - const blob = await this.CAT_fetchBlob(resp.data); - details.onload && details.onload(blob); - } else { - details.onload && details.onload(resp.data); - } - break; + ctx.sendMessage("CAT_fileStorage", [`${action}`, sendDetails]).then(async (resp: { action: string; data: any }) => { + switch (resp.action) { + case "onload": { + if (action === "download") { + // 读取blob + const blob = await GMApi.prototype.CAT_fetchBlob(ctx, resp.data); + details.onload && details.onload(blob); + } else { + details.onload && details.onload(resp.data); } - case "error": { - if (typeof resp.data.code === "undefined") { - details.onerror && details.onerror({ code: -1, message: resp.data.message }); - return; - } - details.onerror && details.onerror(resp.data); + break; + } + case "error": { + if (typeof resp.data.code === "undefined") { + details.onerror && details.onerror({ code: -1, message: resp.data.message }); + return; } + details.onerror && details.onerror(resp.data); } } - ); + }); } // 用于脚本跨域请求,需要@connect domain指定允许的域名 @GMContext.API() - public GM_xmlhttpRequest(details: GMTypes.XHRDetails) { - const { abort } = GM_xmlhttpRequest(this, details, false); + public GM_xmlhttpRequest(ctx: GMApi, details: GMTypes.XHRDetails) { + const { abort } = GM_xmlhttpRequest(ctx, details, false); return { abort }; } @GMContext.API() - public "GM.xmlHttpRequest"(details: GMTypes.XHRDetails): Promise & GMRequestHandle { - const { retPromise, abort } = GM_xmlhttpRequest(this, details, true); + public "GM.xmlHttpRequest"(ctx: GMApi, details: GMTypes.XHRDetails): Promise & GMRequestHandle { + const { retPromise, abort } = GM_xmlhttpRequest(ctx, details, true); const ret = retPromise as Promise & GMRequestHandle; ret.abort = abort; return ret; @@ -1220,16 +1350,16 @@ export default class GMApi extends GM_Base { // 用于脚本跨域请求,需要@connect domain指定允许的域名 @GMContext.API() - public GM_download(arg1: GMTypes.DownloadDetails | string, arg2?: string) { + public GM_download(ctx: GMApi, arg1: GMTypes.DownloadDetails | string, arg2?: string) { const details = typeof arg1 === "string" ? { url: arg1, name: arg2 } : { ...arg1 }; - const { abort } = _GM_download(this, details as GMTypes.DownloadDetails, false); + const { abort } = _GM_download(ctx, details as GMTypes.DownloadDetails, false); return { abort }; } @GMContext.API() - public "GM.download"(arg1: GMTypes.DownloadDetails | string, arg2?: string) { + public "GM.download"(ctx: GMApi, arg1: GMTypes.DownloadDetails | string, arg2?: string) { const details = typeof arg1 === "string" ? { url: arg1, name: arg2 } : { ...arg1 }; - const { retPromise, abort } = _GM_download(this, details as GMTypes.DownloadDetails, true); + const { retPromise, abort } = _GM_download(ctx, details as GMTypes.DownloadDetails, true); const ret = retPromise as Promise & GMRequestHandle; ret.abort = abort; return ret; @@ -1243,7 +1373,7 @@ export default class GMApi extends GM_Base { onclick?: GMTypes.NotificationOnClick ): Promise { if (gmApi.isInvalidContext()) return Promise.resolve(); - const notificationTagMap: Map = gmApi.notificationTagMap || (gmApi.notificationTagMap = new Map()); + const notificationTagMap = getNotificationTagMap(gmApi); gmApi.eventId += 1; let data: GMTypes.NotificationDetails; if (typeof detail === "string") { @@ -1263,7 +1393,7 @@ export default class GMApi extends GM_Base { break; } } else { - data = Object.assign({}, detail); + data = copyOwnEnumerableDataProperties(detail) as GMTypes.NotificationDetails; data.ondone = data.ondone || ondone; } let click: GMTypes.NotificationOnClick; @@ -1288,7 +1418,7 @@ export default class GMApi extends GM_Base { gmApi.sendMessage("GM_notification", [customClone(data), notificationId]).then((id) => { if (!gmApi.EE) return; if (create) { - create.apply({ id }, [id]); + nativeApply(create, { id }, [id]); } if (typeof data.tag === "string") { notificationTagMap.set(data.tag, id); @@ -1325,8 +1455,8 @@ export default class GMApi extends GM_Base { title: data.title, url: data.url, }; - click && click.apply({ id }, [clickEvent]); - done && done.apply({ id }, []); + click && nativeApply(click, { id }, [clickEvent]); + done && nativeApply(done, { id }, []); if (!isPreventDefault) { if (typeof data.url === "string") { @@ -1339,7 +1469,7 @@ export default class GMApi extends GM_Base { break; } case "close": { - done && done.apply({ id }, [resp.params.byUser]); + done && nativeApply(done, { id }, [resp.params.byUser]); clearNotificationIdMap(); gmApi.EE.removeAllListeners("GM_notification:" + gmApi.eventId); break; @@ -1357,44 +1487,46 @@ export default class GMApi extends GM_Base { @GMContext.API() public async "GM.notification"( + ctx: GMApi, detail: GMTypes.NotificationDetails | string, ondone?: GMTypes.NotificationOnDone | string, image?: string, onclick?: GMTypes.NotificationOnClick ): Promise { - return _GM_notification(this, detail, ondone, image, onclick); + return _GM_notification(ctx, detail, ondone, image, onclick); } @GMContext.API() public GM_notification( + ctx: GMApi, detail: GMTypes.NotificationDetails | string, ondone?: GMTypes.NotificationOnDone | string, image?: string, onclick?: GMTypes.NotificationOnClick ): void { - _GM_notification(this, detail, ondone, image, onclick); + _GM_notification(ctx, detail, ondone, image, onclick); } // ScriptCat 额外API @GMContext.API({ alias: "GM.closeNotification" }) - public GM_closeNotification(id: string): void { - this.sendMessage("GM_closeNotification", [`${id}`]); + public GM_closeNotification(ctx: GMApi, id: string): void { + ctx.sendMessage("GM_closeNotification", [`${id}`]); } // ScriptCat 额外API @GMContext.API({ alias: "GM.updateNotification" }) - public GM_updateNotification(id: string, details: GMTypes.NotificationDetails): void { - this.sendMessage("GM_updateNotification", [`${id}`, customClone(details)]); + public GM_updateNotification(ctx: GMApi, id: string, details: GMTypes.NotificationDetails): void { + ctx.sendMessage("GM_updateNotification", [`${id}`, customClone(details)]); } @GMContext.API({ depend: ["GM_closeInTab"] }) - public GM_openInTab(url: string, param?: GMTypes.OpenTabOptions | boolean): GMTypes.Tab | undefined { - if (this.isInvalidContext()) return undefined; + public GM_openInTab(ctx: GMApi, url: string, param?: GMTypes.OpenTabOptions | boolean): GMTypes.Tab | undefined { + if (ctx.isInvalidContext()) return undefined; let option = {} as GMTypes.OpenTabOptions; if (typeof param === "boolean") { option.active = !param; // Greasemonkey 3.x loadInBackground } else if (param) { - option = { ...param } as GMTypes.OpenTabOptions; + option = copyOwnEnumerableDataProperties(param) as GMTypes.OpenTabOptions; } if (typeof option.active !== "boolean" && typeof option.loadInBackground === "boolean") { // TM 同时兼容 active 和 loadInBackground ( active 优先 ) @@ -1413,19 +1545,19 @@ export default class GMApi extends GM_Base { const ret: GMTypes.Tab = { close: () => { - tabid && this.GM_closeInTab(tabid); + tabid && GMApi.prototype.GM_closeInTab(ctx, tabid); }, closed: false, // 占位 onclose() {}, }; - this.sendMessage("GM_openInTab", [url, option as GMTypes.SWOpenTabOptions]).then((id) => { - if (!this.EE) return; + ctx.sendMessage("GM_openInTab", [url, option as GMTypes.SWOpenTabOptions]).then((id) => { + if (!ctx.EE) return; if (id) { tabid = id; - this.EE.addListener("GM_openInTab:" + id, (resp: any) => { - if (!this.EE) return; + ctx.EE.addListener("GM_openInTab:" + id, (resp: any) => { + if (!ctx.EE) return; switch (resp.event) { case "oncreate": tabid = resp.tabId; @@ -1433,7 +1565,7 @@ export default class GMApi extends GM_Base { case "onclose": ret.onclose && ret.onclose(); ret.closed = true; - this.EE.removeAllListeners("GM_openInTab:" + id); + ctx.EE.removeAllListeners("GM_openInTab:" + id); break; default: LoggerCore.logger().warn("GM_openInTab resp is error", { @@ -1452,74 +1584,78 @@ export default class GMApi extends GM_Base { } @GMContext.API({ depend: ["GM_openInTab", "GM_closeInTab"] }) - public "GM.openInTab"(url: string, param?: GMTypes.OpenTabOptions | boolean): Promise { + public "GM.openInTab"( + ctx: GMApi, + url: string, + param?: GMTypes.OpenTabOptions | boolean + ): Promise { return new Promise((resolve) => { - const ret = this.GM_openInTab(url, param); + const ret = GMApi.prototype.GM_openInTab(ctx, url, param); resolve(ret); }); } // ScriptCat 额外API @GMContext.API({ alias: "GM.closeInTab" }) - public GM_closeInTab(tabid: string) { - if (this.isInvalidContext()) return; - return this.sendMessage("GM_closeInTab", [tabid]); + public GM_closeInTab(ctx: GMApi, tabid: string) { + if (ctx.isInvalidContext()) return; + return ctx.sendMessage("GM_closeInTab", [tabid]); } @GMContext.API() - public GM_getTab(callback: (tabData: object) => void) { - if (this.isInvalidContext()) return; - this.sendMessage("GM_getTab", []).then((tabData) => { + public GM_getTab(ctx: GMApi, callback: (tabData: object) => void) { + if (ctx.isInvalidContext()) return; + ctx.sendMessage("GM_getTab", []).then((tabData) => { callback(tabData ?? {}); }); } @GMContext.API({ depend: ["GM_getTab"] }) - public "GM.getTab"(): Promise { + public "GM.getTab"(ctx: GMApi): Promise { return new Promise((resolve) => { - this.GM_getTab((data) => { + GMApi.prototype.GM_getTab(ctx, (data) => { resolve(data); }); }); } @GMContext.API() - public GM_saveTab(tabData: object): void { - if (this.isInvalidContext()) return; + public GM_saveTab(ctx: GMApi, tabData: object): void { + if (ctx.isInvalidContext()) return; if (typeof tabData === "object") { tabData = customClone(tabData); } - this.sendMessage("GM_saveTab", [tabData]); + ctx.sendMessage("GM_saveTab", [tabData]); } @GMContext.API({ depend: ["GM_saveTab"] }) - public "GM.saveTab"(tabData: object): Promise { + public "GM.saveTab"(ctx: GMApi, tabData: object): Promise { return new Promise((resolve) => { - this.GM_saveTab(tabData); + GMApi.prototype.GM_saveTab(ctx, tabData); resolve(); }); } @GMContext.API() - public GM_getTabs(callback: (tabsData: { [key: number]: object }) => any) { - if (this.isInvalidContext()) return; - this.sendMessage("GM_getTabs", []).then((tabsData) => { + public GM_getTabs(ctx: GMApi, callback: (tabsData: { [key: number]: object }) => any) { + if (ctx.isInvalidContext()) return; + ctx.sendMessage("GM_getTabs", []).then((tabsData) => { callback(tabsData); }); } @GMContext.API({ depend: ["GM_getTabs"] }) - public "GM.getTabs"(): Promise<{ [key: number]: object }> { + public "GM.getTabs"(ctx: GMApi): Promise<{ [key: number]: object }> { return new Promise<{ [key: number]: object }>((resolve) => { - this.GM_getTabs((tabsData) => { + GMApi.prototype.GM_getTabs(ctx, (tabsData) => { resolve(tabsData); }); }); } @GMContext.API() - public GM_setClipboard(data: string, info?: GMTypes.GMClipboardInfo, cb?: () => void) { - if (this.isInvalidContext()) return; + public GM_setClipboard(ctx: GMApi, data: string, info?: GMTypes.GMClipboardInfo, cb?: () => void) { + if (ctx.isInvalidContext()) return; // 物件参数意义不明。日后再检视特殊处理 // 未支持 TM4.19+ application/octet-stream // 参考: https://github.com/Tampermonkey/tampermonkey/issues/1250 @@ -1532,7 +1668,8 @@ export default class GMApi extends GM_Base { else if (mimetype === "html") mimetype = "text/html"; } data = `${data}`; // 强制 string type - this.sendMessage("GM_setClipboard", [data, mimetype]) + ctx + .sendMessage("GM_setClipboard", [data, mimetype]) .then(() => { if (typeof cb === "function") { cb(); @@ -1546,18 +1683,22 @@ export default class GMApi extends GM_Base { } @GMContext.API({ depend: ["GM_setClipboard"] }) - public "GM.setClipboard"(data: string, info?: string | { type?: string; mimetype?: string }): Promise { - if (this.isInvalidContext()) return new Promise(() => {}); + public "GM.setClipboard"( + ctx: GMApi, + data: string, + info?: string | { type?: string; mimetype?: string } + ): Promise { + if (ctx.isInvalidContext()) return new Promise(() => {}); return new Promise((resolve) => { - this.GM_setClipboard(data, info, () => { + GMApi.prototype.GM_setClipboard(ctx, data, info, () => { resolve(); }); }); } @GMContext.API() - public GM_getResourceText(name: string): string | undefined { - const r = (this.scriptRes?.resourceByType?.resource ?? this.scriptRes?.resource)?.[name]; + public GM_getResourceText(ctx: GMApi, name: string): string | undefined { + const r = (ctx.scriptRes?.resourceByType?.resource ?? ctx.scriptRes?.resource)?.[name]; if (r) { return r.content; } @@ -1565,17 +1706,17 @@ export default class GMApi extends GM_Base { } @GMContext.API({ depend: ["GM_getResourceText"] }) - public "GM.getResourceText"(name: string): Promise { + public "GM.getResourceText"(ctx: GMApi, name: string): Promise { // Asynchronous wrapper for GM_getResourceText to support GM.getResourceText return new Promise((resolve) => { - const ret = this.GM_getResourceText(name); + const ret = GMApi.prototype.GM_getResourceText(ctx, name); resolve(ret); }); } @GMContext.API() - public GM_getResourceURL(name: string, isBlobUrl?: boolean): string | undefined { - const r = (this.scriptRes?.resourceByType?.resource ?? this.scriptRes?.resource)?.[name]; + public GM_getResourceURL(ctx: GMApi, name: string, isBlobUrl?: boolean): string | undefined { + const r = (ctx.scriptRes?.resourceByType?.resource ?? ctx.scriptRes?.resource)?.[name]; if (r) { let base64 = r.base64; if (!base64) { @@ -1591,39 +1732,39 @@ export default class GMApi extends GM_Base { } @GMContext.API({ depend: ["GM_getResourceURL"] }) - public "GM.getResourceURL"(name: string, isBlobUrl?: boolean): Promise { + public "GM.getResourceURL"(ctx: GMApi, name: string, isBlobUrl?: boolean): Promise { return new Promise((resolve) => { - const ret = this.GM_getResourceURL(name, isBlobUrl); + const ret = GMApi.prototype.GM_getResourceURL(ctx, name, isBlobUrl); resolve(ret); }); } // GM_getResourceURL的异步版本,用来兼容GM.getResourceUrl @GMContext.API({ depend: ["GM_getResourceURL"] }) - public "GM.getResourceUrl"(name: string, isBlobUrl?: boolean): Promise { + public "GM.getResourceUrl"(ctx: GMApi, name: string, isBlobUrl?: boolean): Promise { // Asynchronous wrapper for GM_getResourceURL to support GM.getResourceURL return new Promise((resolve) => { - const ret = this.GM_getResourceURL(name, isBlobUrl); + const ret = GMApi.prototype.GM_getResourceURL(ctx, name, isBlobUrl); resolve(ret); }); } @GMContext.API() - public "window.close"() { - return this.sendMessage("window.close", []); + public "window.close"(ctx: GMApi) { + return ctx.sendMessage("window.close", []); } @GMContext.API() - public "window.focus"() { - return this.sendMessage("window.focus", []); + public "window.focus"(ctx: GMApi) { + return ctx.sendMessage("window.focus", []); } @GMContext.protected() apiLoadPromise: Promise | undefined; @GMContext.API() - public CAT_scriptLoaded() { - return this.loadScriptPromise; + public CAT_scriptLoaded(ctx: GMApi) { + return ctx.loadScriptPromise; } } diff --git a/src/app/service/content/gm_api/gm_context.ts b/src/app/service/content/gm_api/gm_context.ts index f127a99b8..d8ebc4a40 100644 --- a/src/app/service/content/gm_api/gm_context.ts +++ b/src/app/service/content/gm_api/gm_context.ts @@ -1,6 +1,14 @@ import type { ApiParam, ApiValue } from "../types"; +import { Native } from "../global"; -const apis: Map = new Map(); +const apiRegistry: Record = Native.objectCreate(null); +const apis = { + get: (name: string) => apiRegistry[name], + set: (name: string, values: ApiValue[]) => { + apiRegistry[name] = values; + }, + keys: () => Native.objectKeys(apiRegistry), +}; export function GMContextApiGet(name: string): ApiValue[] | undefined { // 回传 Api 列表 @@ -17,10 +25,10 @@ function GMContextApiSet(grant: string, fnKey: string, api: any, param: ApiParam // 一个 @grant 可以扩充多个 API 函数 let m: ApiValue[] | undefined = apis.get(grant); if (!m) apis.set(grant, (m = [])); - m.push({ fnKey, api, param }); + m[m.length] = { fnKey, api, param }; } -export const protect: { [key: string]: any } = {}; +export const protect: { [key: string]: any } = Native.objectCreate(null); export default class GMContext { public static protected(value: any = undefined) { diff --git a/src/app/service/content/gm_api/gm_xhr.test.ts b/src/app/service/content/gm_api/gm_xhr.test.ts new file mode 100644 index 000000000..16b095d12 --- /dev/null +++ b/src/app/service/content/gm_api/gm_xhr.test.ts @@ -0,0 +1,302 @@ +import { describe, expect, it, vi } from "vitest"; +import { initTestEnv } from "@Tests/utils"; +import { GM_xmlhttpRequest } from "./gm_xhr"; + +initTestEnv(); + +describe("GM_xmlhttpRequest callback cleanup", () => { + it("settles and disconnects when an error callback throws", async () => { + let onMessage!: (message: any) => void; + const connection = { + onMessage: vi.fn((callback: (message: any) => void) => { + onMessage = callback; + }), + disconnect: vi.fn(), + sendMessage: vi.fn(), + onDisconnect: vi.fn(), + }; + const onloadend = vi.fn(); + const onload = vi.fn(); + const api = { + isInvalidContext: () => false, + connect: vi.fn().mockResolvedValue(connection), + sendMessage: vi.fn(), + }; + const request = GM_xmlhttpRequest( + api as any, + { + url: "https://example.com/data", + onerror: () => { + throw new Error("user callback failed"); + }, + onload, + onloadend, + }, + true + ); + + await vi.waitFor(() => expect(onMessage).toBeTypeOf("function")); + onMessage({ + code: 0, + action: "onerror", + data: { + finalUrl: "https://example.com/data", + readyState: 4, + status: 500, + statusText: "", + responseHeaders: "", + useFetch: false, + eventType: "onerror", + ok: false, + contentType: "text/plain", + error: "network", + }, + }); + onMessage({ + code: 0, + action: "onload", + data: { + finalUrl: "https://example.com/data", + readyState: 4, + status: 500, + statusText: "", + responseHeaders: "", + useFetch: false, + eventType: "onload", + ok: false, + contentType: "text/plain", + }, + }); + onMessage({ + code: 0, + action: "onloadend", + data: { + finalUrl: "https://example.com/data", + readyState: 4, + status: 500, + statusText: "", + responseHeaders: "", + useFetch: false, + eventType: "onloadend", + ok: false, + contentType: "text/plain", + }, + }); + + await expect(request.retPromise).rejects.toBe("network"); + expect(connection.disconnect).toHaveBeenCalledWith(true); + expect(onload).not.toHaveBeenCalled(); + expect(onloadend).toHaveBeenCalledTimes(1); + }); + + it("synthesizes loadend when an error has no terminal broker event", async () => { + let onMessage!: (message: any) => void; + const connection = { + onMessage: vi.fn((callback: (message: any) => void) => { + onMessage = callback; + }), + disconnect: vi.fn(), + sendMessage: vi.fn(), + onDisconnect: vi.fn(), + }; + const onloadend = vi.fn(); + const api = { + isInvalidContext: () => false, + connect: vi.fn().mockResolvedValue(connection), + sendMessage: vi.fn(), + }; + const request = GM_xmlhttpRequest( + api as any, + { + url: "https://example.com/data", + onerror: vi.fn(), + onloadend, + }, + true + ); + + await vi.waitFor(() => expect(onMessage).toBeTypeOf("function")); + onMessage({ + code: 0, + action: "onerror", + data: { + finalUrl: "https://example.com/data", + readyState: 4, + status: 500, + statusText: "", + responseHeaders: "", + useFetch: false, + eventType: "onerror", + ok: false, + contentType: "text/plain", + error: "network", + }, + }); + + await expect(request.retPromise).rejects.toBe("network"); + expect(connection.disconnect).toHaveBeenCalledWith(true); + expect(onloadend).toHaveBeenCalledTimes(1); + }); + + it("aborts and releases the connection even without an onabort callback", async () => { + const connection = { + onMessage: vi.fn(), + disconnect: vi.fn(), + sendMessage: vi.fn(), + onDisconnect: vi.fn(), + }; + const onloadend = vi.fn(); + const api = { + isInvalidContext: () => false, + connect: vi.fn().mockResolvedValue(connection), + sendMessage: vi.fn(), + }; + const request = GM_xmlhttpRequest( + api as any, + { + url: "https://example.com/data", + onloadend, + }, + true + ); + + await vi.waitFor(() => expect(connection.onMessage).toHaveBeenCalled()); + request.abort(); + + await expect(request.retPromise).rejects.toBe("AbortError"); + expect(connection.disconnect).toHaveBeenCalledWith(true); + await vi.waitFor(() => expect(onloadend).toHaveBeenCalledTimes(1)); + }); + + it("honors abort requested before the native connection is ready", async () => { + const connection = { + onMessage: vi.fn(), + disconnect: vi.fn(), + sendMessage: vi.fn(), + onDisconnect: vi.fn(), + }; + const onloadend = vi.fn(); + const api = { + isInvalidContext: () => false, + connect: vi.fn().mockResolvedValue(connection), + sendMessage: vi.fn(), + }; + const request = GM_xmlhttpRequest( + api as any, + { + url: "https://example.com/data", + onloadend, + }, + true + ); + + request.abort(); + + await expect(request.retPromise).rejects.toBe("AbortError"); + expect(connection.disconnect).toHaveBeenCalledWith(true); + await vi.waitFor(() => expect(onloadend).toHaveBeenCalledTimes(1)); + }); + + it("settles the request when connection setup rejects", async () => { + const onerror = vi.fn(); + const onloadend = vi.fn(); + const api = { + isInvalidContext: () => false, + connect: vi.fn().mockRejectedValue(new Error("connection failed")), + sendMessage: vi.fn(), + }; + const request = GM_xmlhttpRequest( + api as any, + { + url: "https://example.com/data", + onerror, + onloadend, + }, + true + ); + + await expect(request.retPromise).rejects.toBe("connection failed"); + expect(onerror).toHaveBeenCalledTimes(1); + expect(onloadend).toHaveBeenCalledTimes(1); + }); + + it("settles the request when data encoding rejects before connection setup", async () => { + const onerror = vi.fn(); + const onloadend = vi.fn(); + const api = { + isInvalidContext: () => false, + connect: vi.fn(), + sendMessage: vi.fn(), + }; + const request = GM_xmlhttpRequest( + api as any, + { + url: "https://example.com/data", + data: Promise.reject(new Error("data failed")) as unknown as GMTypes.XHRDetails["data"], + onerror, + onloadend, + }, + true + ); + + await expect(request.retPromise).rejects.toBe("data failed"); + expect(api.connect).not.toHaveBeenCalled(); + expect(onerror).toHaveBeenCalledTimes(1); + expect(onloadend).toHaveBeenCalledTimes(1); + }); + + it("disconnects an established connection when listener setup throws", async () => { + const onerror = vi.fn(); + const onloadend = vi.fn(); + const connection = { + onMessage: vi.fn(() => { + throw new Error("listener setup failed"); + }), + disconnect: vi.fn(), + sendMessage: vi.fn(), + onDisconnect: vi.fn(), + }; + const api = { + isInvalidContext: () => false, + connect: vi.fn().mockResolvedValue(connection), + sendMessage: vi.fn(), + }; + const request = GM_xmlhttpRequest( + api as any, + { + url: "https://example.com/data", + onerror, + onloadend, + }, + true + ); + + await expect(request.retPromise).rejects.toBe("listener setup failed"); + expect(connection.disconnect).toHaveBeenCalledWith(true); + expect(onerror).toHaveBeenCalledTimes(1); + expect(onloadend).toHaveBeenCalledTimes(1); + }); + + it("does not execute accessor headers while preparing the request", async () => { + const getter = vi.fn(() => "forged"); + const headers = {} as Record; + Object.defineProperty(headers, "X-Hostile", { enumerable: true, configurable: true, get: getter }); + const connection = { + onMessage: vi.fn(), + disconnect: vi.fn(), + sendMessage: vi.fn(), + onDisconnect: vi.fn(), + }; + const api = { + isInvalidContext: () => false, + connect: vi.fn().mockResolvedValue(connection), + sendMessage: vi.fn(), + }; + const request = GM_xmlhttpRequest(api as any, { url: "https://example.com/data", headers }, false); + + await vi.waitFor(() => expect(api.connect).toHaveBeenCalled()); + expect(getter).not.toHaveBeenCalled(); + expect(Object.getOwnPropertyDescriptor(api.connect.mock.calls[0][1][0].headers, "X-Hostile")).toBeUndefined(); + request.abort(); + }); +}); diff --git a/src/app/service/content/gm_api/gm_xhr.ts b/src/app/service/content/gm_api/gm_xhr.ts index d3b147f88..42a2ce6e7 100644 --- a/src/app/service/content/gm_api/gm_xhr.ts +++ b/src/app/service/content/gm_api/gm_xhr.ts @@ -1,11 +1,11 @@ import { Native } from "../global"; -import type { CustomEventMessage } from "@Packages/message/custom_event_message"; import type GMApi from "./gm_api"; import { dataEncode } from "@App/pkg/utils/xhr/xhr_data"; import type { MessageConnect, TMessage } from "@Packages/message/types"; import { base64ToUint8, concatUint8 } from "@App/pkg/utils/datatype"; import { stackAsyncTask } from "@App/pkg/utils/async_queue"; import LoggerCore from "@App/app/logger/core"; +import Logger from "@App/app/logger/logger"; const ChunkResponseCode = { NONE: 0, @@ -112,10 +112,33 @@ export const convObjectToURL = async (object: string | URL | Blob | File | undef return url; }; -export const urlToDocumentInContentPage = async (a: GMApi, url: string, isContent: boolean) => { - // url (e.g. blob url) -> XMLHttpRequest (CONTENT) -> Document (CONTENT) - const nodeId = await a.sendMessage("CAT_fetchDocument", [`${url}`, isContent]); - return (a.message).getAndDelRelatedTarget(nodeId) as Document; +export type SerializedDocumentResponse = { + text: string; + contentType: string; +}; + +const readDataProperty = (value: object, key: string): unknown => { + try { + const descriptor = Native.objectGetOwnPropertyDescriptor(value, key); + return descriptor && "value" in descriptor ? descriptor.value : undefined; + } catch { + return undefined; + } +}; + +export const parseSerializedDocumentResponse = (value: unknown): Document | undefined => { + if (value === null || typeof value !== "object") return undefined; + const text = readDataProperty(value, "text"); + const contentType = readDataProperty(value, "contentType"); + if (typeof text !== "string" || typeof contentType !== "string") return undefined; + + const mime = getMimeType(contentType); + const parseType = docParseTypes.has(mime) ? (mime as DOMParserSupportedType) : "text/xml"; + try { + return new DOMParser().parseFromString(text, parseType); + } catch { + return undefined; + } }; const getMimeType = (contentType: string) => { @@ -126,9 +149,25 @@ const getMimeType = (contentType: string) => { return mime; }; -const docParseTypes = new Set(["application/xhtml+xml", "application/xml", "image/svg+xml", "text/html", "text/xml"]); - -const retStateFnMap = new WeakMap, RetStateFnRecord>(); +const docParseTypes = new Native.Set([ + "application/xhtml+xml", + "application/xml", + "image/svg+xml", + "text/html", + "text/xml", +]); + +const retStateFnMap = new Native.WeakMap(); + +const invokeXHRCallback = (name: string, callback: ((value: any) => void) | undefined, value: any) => { + if (!callback) return; + try { + callback(value); + } catch (error) { + // 用户回调异常只记录,不得拒绝内部消息队列或打断请求收尾。 + LoggerCore.logger().error("GM_xmlhttpRequest callback failed", { name, ...Logger.E(error) }); + } +}; interface RetStateFnRecord { getResponseText(): string | undefined; @@ -172,14 +211,15 @@ export function GM_xmlhttpRequest( isDownload: boolean = false ) { let reqDone = false; + let abortRequested = false; if (a.isInvalidContext()) { return { retPromise: requirePromise ? Promise.reject("GM_xmlhttpRequest: Invalid Context") : null, abort: () => {}, }; } - let retPromiseResolve: (value: unknown) => void | undefined; - let retPromiseReject: (reason?: any) => void | undefined; + let retPromiseResolve: ((value: unknown) => void) | undefined; + let retPromiseReject: ((reason?: any) => void) | undefined; const retPromise = requirePromise ? new Promise((resolve, reject) => { retPromiseResolve = resolve; @@ -189,11 +229,20 @@ export function GM_xmlhttpRequest( const urlPromiseLike = typeof details.url === "object" ? convObjectToURL(details.url) : details.url; const dataPromise = dataEncode(details.data); const headers = details.headers; + let requestHeaders: Record | undefined; + let requestCookie = details.cookie; if (headers) { - for (const key of Object.keys(headers)) { + requestHeaders = Native.objectCreate(null) as Record; + const keys = Native.reflectOwnKeys(headers); + for (let index = 0; index < keys.length; index += 1) { + const key = keys[index]; + if (typeof key !== "string") continue; + const descriptor = Native.objectGetOwnPropertyDescriptor(headers, key); + if (!descriptor || !descriptor.enumerable || !("value" in descriptor)) continue; if (key.toLowerCase() === "cookie") { - details.cookie = headers[key]; - delete headers[key]; + requestCookie = descriptor.value as string; + } else { + requestHeaders[key] = descriptor.value as string; } } } @@ -206,8 +255,8 @@ export function GM_xmlhttpRequest( method: details.method, timeout: details.timeout, url: "", - headers: details.headers, - cookie: details.cookie, + headers: requestHeaders, + cookie: requestCookie, responseType: details.responseType, overrideMimeType: details.overrideMimeType, anonymous: details.anonymous, @@ -260,19 +309,30 @@ export function GM_xmlhttpRequest( } } // 发送信息 - let connectMessage: Promise; - if (isDownload) { - // 如果是下载,带上 downloadMode 参数,呼叫 SW 的 GM_download - // 在 SW 中处理,实际使用 GM_xmlhttpRequest 进行下载 - const method = param.method === "POST" ? "POST" : "GET"; - const downloadParam: GMTypes.DownloadDetails = { ...param, method, downloadMode: "native", name: "" }; - connectMessage = a.connect("GM_download", [downloadParam]); - } else { - // 一般 GM_xmlhttpRequest,呼叫 SW 的 GM_xmlhttpRequest - connectMessage = a.connect("GM_xmlhttpRequest", [param]); + try { + let connectMessage: Promise; + if (isDownload) { + // 如果是下载,带上 downloadMode 参数,呼叫 SW 的 GM_download + // 在 SW 中处理,实际使用 GM_xmlhttpRequest 进行下载 + const method = param.method === "POST" ? "POST" : "GET"; + const downloadParam: GMTypes.DownloadDetails = { ...param, method, downloadMode: "native", name: "" }; + connectMessage = a.connect("GM_download", [downloadParam]); + } else { + // 一般 GM_xmlhttpRequest,呼叫 SW 的 GM_xmlhttpRequest + connectMessage = a.connect("GM_xmlhttpRequest", [param]); + } + param = null; // GC + connect = await connectMessage; + } catch (error) { + param = null; + const message = error instanceof Error ? error.message : `${error}`; + reqDone = true; + const response = { readyState: ReadyStateCode.DONE, error: message }; + invokeXHRCallback("onerror", details.onerror, response); + retPromiseReject?.(message); + invokeXHRCallback("onloadend", details.onloadend, response); + return; } - param = null; // GC - connect = await connectMessage; const resultTexts = [] as string[]; // 函数参考清掉后,变数会被GC const resultBuffers = [] as Uint8Array[]; // 函数参考清掉后,变数会被GC @@ -501,6 +561,7 @@ export function GM_xmlhttpRequest( }; let makeXHRCallbackParam: typeof makeXHRCallbackParam_ | null = makeXHRCallbackParam_; let loadendCalled = false; + let loadCalled = false; const doLoadEnd = (data: TXhrCallBackArg) => { if (!loadendCalled) { loadendCalled = true; @@ -509,24 +570,35 @@ export function GM_xmlhttpRequest( finalResultBuffers = null; finalResultText = null; const xhrResponse = makeXHRCallbackParam?.(data) ?? {}; - details.onloadend?.(xhrResponse); if (errorOccur === null) { retPromiseResolve?.(xhrResponse); } else { retPromiseReject?.(errorOccur); } refCleanup?.(); + invokeXHRCallback("onloadend", details.onloadend, xhrResponse); } }; + const scheduleSyntheticLoadEnd = () => { + // abort/error/timeout 可能没有 broker 的 onloadend,补发一次以释放连接和引用。 + Promise.resolve({ + error: "loadend", + responseHeaders: "", + readyState: 0, + status: 0, + statusText: "", + } as TXhrCallBackArg).then(doLoadEnd); + }; doAbort = (data: TXhrCallBackArg) => { if (!reqDone) { errorOccur = "AbortError"; - details.onabort?.(makeXHRCallbackParam?.(data) ?? {}); reqDone = true; + // 先标记完成再调用用户代码;回调抛错也不能留下未收尾的连接。 + invokeXHRCallback("onabort", details.onabort, makeXHRCallbackParam?.(data) ?? {}); // 不要进行 refCleanup !要等待最后的 onloadend // refCleanup?.(); // doAbort 不是由通讯管控 onloadend. 需要手动处理. 排程在下一个 microTask 避免影响 Abort 流程 - Promise.resolve({ ...data, type: "loadend" }).then(doLoadEnd); + scheduleSyntheticLoadEnd(); } doAbort = null; }; @@ -557,22 +629,16 @@ export function GM_xmlhttpRequest( }); if (!reqDone) { errorOccur = message; - details.onerror?.({ + reqDone = true; + invokeXHRCallback("onerror", details.onerror, { readyState: ReadyStateCode.DONE, error: message, }); - reqDone = true; // 不要进行 refCleanup !要等待最后的 onloadend // refCleanup?.(); // 此错误多为 API 非正常执行,估计不会有 loadend 触发。见 Aborted 处理 - Promise.resolve({ - error: "loadend", - responseHeaders: "", - readyState: 0, - status: 0, - statusText: "", - } as TXhrCallBackArg).then(doLoadEnd); + scheduleSyntheticLoadEnd(); } return; } @@ -646,14 +712,16 @@ export function GM_xmlhttpRequest( break; } case "onload": - details.onload?.(makeXHRCallbackParam?.(data) ?? {}); + if (loadCalled || reqDone) break; + loadCalled = true; + invokeXHRCallback("onload", details.onload, makeXHRCallbackParam?.(data) ?? {}); break; case "onloadend": { doLoadEnd(data); break; } case "onloadstart": - details.onloadstart?.(makeXHRCallbackParam?.(data) ?? {}); + invokeXHRCallback("onloadstart", details.onloadstart, makeXHRCallbackParam?.(data) ?? {}); break; case "onprogress": { if (details.onprogress) { @@ -665,7 +733,7 @@ export function GM_xmlhttpRequest( done: data.loaded, totalSize: data.total, }; - details.onprogress?.(res); + invokeXHRCallback("onprogress", details.onprogress, res); } break; } @@ -678,14 +746,15 @@ export function GM_xmlhttpRequest( // readable stream 的 controller 可以释放 controller = undefined; // GC用 } - details.onreadystatechange?.(makeXHRCallbackParam?.(data) ?? {}); + invokeXHRCallback("onreadystatechange", details.onreadystatechange, makeXHRCallbackParam?.(data) ?? {}); break; } case "ontimeout": if (!reqDone) { errorOccur = "TimeoutError"; - details.ontimeout?.(makeXHRCallbackParam?.(data) ?? {}); reqDone = true; + invokeXHRCallback("ontimeout", details.ontimeout, makeXHRCallbackParam?.(data) ?? {}); + scheduleSyntheticLoadEnd(); // 不要进行 refCleanup !要等待最后的 onloadend // refCleanup?.(); } @@ -694,10 +763,14 @@ export function GM_xmlhttpRequest( if (!reqDone) { data.error ||= "Unknown Error"; errorOccur = data.error; - details.onerror?.((makeXHRCallbackParam?.(data) ?? {}) as GMXHRResponseTypeWithError); reqDone = true; - // 不要进行 refCleanup !要等待最后的 onloadend - // refCleanup?.(); + invokeXHRCallback( + "onerror", + details.onerror, + (makeXHRCallbackParam?.(data) ?? {}) as GMXHRResponseTypeWithError + ); + // 错误消息可能没有对应的 onloadend,补发一次以收尾并释放连接。 + scheduleSyntheticLoadEnd(); } break; case "onabort": @@ -716,16 +789,37 @@ export function GM_xmlhttpRequest( }; connect?.onMessage((msgData) => onMessageHandler?.(msgData)); - })(); + if (abortRequested && !reqDone) { + doAbort?.({ + error: "aborted", + responseHeaders: "", + readyState: 0, + status: 0, + statusText: "", + } as TXhrCallBackArg); + } + })().catch((error) => { + const pendingConnection = connect; + connect = null; + pendingConnection?.disconnect(true); + if (reqDone) return; + reqDone = true; + const message = error instanceof Error ? error.message : `${error}`; + const response = { readyState: ReadyStateCode.DONE, error: message }; + invokeXHRCallback("onerror", details.onerror, response); + retPromiseReject?.(message); + invokeXHRCallback("onloadend", details.onloadend, response); + }); // 由于需要同步返回一个abort,但是一些操作是异步的,所以需要在这里处理 return { retPromise, abort: () => { + abortRequested = true; if (connect) { connect.disconnect(true); // 断开连结(容忍已断开) connect = null; } - if (doAbort && details.onabort && !reqDone) { + if (doAbort && !reqDone) { // https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/abort // When a request is aborted, its readyState is changed to XMLHttpRequest.UNSENT (0) and the request's status code is set to 0. doAbort?.({ diff --git a/src/app/service/content/gm_api/grant.ts b/src/app/service/content/gm_api/grant.ts index ab1e91c39..579e03112 100644 --- a/src/app/service/content/gm_api/grant.ts +++ b/src/app/service/content/gm_api/grant.ts @@ -1,9 +1,13 @@ +const nativeReflectApply = Reflect.apply; +const nativeStringStartsWith = String.prototype.startsWith; +const nativeStringSlice = String.prototype.slice; + export function getGrantCandidates(grant: string): string[] { - if (grant.startsWith("GM.")) { - return [grant, `GM_${grant.slice(3)}`]; + if (nativeReflectApply(nativeStringStartsWith, grant, ["GM."])) { + return [grant, `GM_${nativeReflectApply(nativeStringSlice, grant, [3])}`]; } - if (grant.startsWith("GM_")) { - return [grant, `GM.${grant.slice(3)}`]; + if (nativeReflectApply(nativeStringStartsWith, grant, ["GM_"])) { + return [grant, `GM.${nativeReflectApply(nativeStringSlice, grant, [3])}`]; } return [grant]; } diff --git a/src/app/service/content/gm_api/navigation_handle.test.ts b/src/app/service/content/gm_api/navigation_handle.test.ts index 29d7dfbdd..5058be5d6 100644 --- a/src/app/service/content/gm_api/navigation_handle.test.ts +++ b/src/app/service/content/gm_api/navigation_handle.test.ts @@ -94,6 +94,23 @@ describe("attachNavigateHandler", () => { expect(ev.url).toBe("https://example.com/new"); }); + it("dispatchEvent 的 bind 屬性被改寫時仍能派發事件", async () => { + const mock = createMockWin("https://example.com/"); + Object.defineProperty(mock.win.dispatchEvent, "bind", { + configurable: true, + value: () => { + throw new Error("poisoned bind"); + }, + }); + + attachNavigateHandler(mock.win); + mock.fireNavigate("https://example.com/new"); + + await vi.waitFor(() => { + expect(mock.dispatched).toHaveLength(1); + }); + }); + it("URL 未变化时不应派发事件", async () => { const mock = createMockWin("https://example.com/"); attachNavigateHandler(mock.win); diff --git a/src/app/service/content/gm_api/navigation_handle.ts b/src/app/service/content/gm_api/navigation_handle.ts index a536f6a70..d05beebe5 100644 --- a/src/app/service/content/gm_api/navigation_handle.ts +++ b/src/app/service/content/gm_api/navigation_handle.ts @@ -19,7 +19,7 @@ const getPropGetter = (obj: T, key: keyof T) => { // 避免直接 obj[key] 读取。或会被 hack for (let t = obj; t; t = Native.objectGetPrototypeOf(t)) { const pd = Native.objectGetOwnPropertyDescriptor(t, key); - if (pd) return pd.get?.bind(obj); + if (pd) return pd.get ? Native.bind(pd.get, obj) : undefined; } }; @@ -33,7 +33,7 @@ export const attachNavigateHandler = (win: Window & { navigation: EventTarget }) // 以 location.href 判断避免 replaceState/pushState 重复执行重复触发 const loc = win.location; const getUrl = getPropGetter(loc, "href"); - const dispatch = win.dispatchEvent.bind(win); + const dispatch = Native.bind(win.dispatchEvent, win); let lastUrl = getUrl?.(); let callSeq = 0; const handler = async (ev: Event): Promise => { diff --git a/src/app/service/content/gm_api/related_target_lifecycle.test.ts b/src/app/service/content/gm_api/related_target_lifecycle.test.ts index 722111552..b3368382a 100644 --- a/src/app/service/content/gm_api/related_target_lifecycle.test.ts +++ b/src/app/service/content/gm_api/related_target_lifecycle.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { ScriptEnvTag } from "@Packages/message/consts"; import { CustomEventMessage } from "@Packages/message/custom_event_message"; import { Server } from "@Packages/message/server"; @@ -50,19 +50,19 @@ describe("relatedTarget lifecycle across content runtime callers", () => { const parent = document.createElement("section"); try { - const style = api.GM_addStyle("body { color: red; }"); + const style = api.GM_addStyle(api, "body { color: red; }"); expect(style?.tagName).toBe("STYLE"); expect(style?.textContent).toBe("body { color: red; }"); expect(sender.relatedTarget).toHaveProperty("size", 0); expect(receiver.relatedTarget).toHaveProperty("size", 0); - const child = api.GM_addElement(parent, "span", { id: "child" }); + const child = api.GM_addElement(api, parent, "span", { id: "child" }); expect(child?.parentNode).toBe(parent); expect(child?.id).toBe("child"); expect(sender.relatedTarget).toHaveProperty("size", 0); expect(receiver.relatedTarget).toHaveProperty("size", 0); - const root = api.GM_addElement("div", { id: "root" }); + const root = api.GM_addElement(api, "div", { id: "root" }); expect(root?.tagName).toBe("DIV"); expect(root?.id).toBe("root"); expect(sender.relatedTarget).toHaveProperty("size", 0); @@ -72,4 +72,21 @@ describe("relatedTarget lifecycle across content runtime callers", () => { receiver.relatedTarget.clear(); } }); + + it("skips accessor attributes without executing their getter", () => { + const { api, sender, receiver } = createApiWithContentRuntime(); + const getter = vi.fn(() => "forged"); + const attrs = { id: "safe" } as Record; + Object.defineProperty(attrs, "secret", { enumerable: true, configurable: true, get: getter }); + + try { + const element = api.GM_addElement(api, "div", attrs); + expect(element?.id).toBe("safe"); + expect(element).not.toHaveProperty("secret"); + expect(getter).not.toHaveBeenCalled(); + } finally { + sender.relatedTarget.clear(); + receiver.relatedTarget.clear(); + } + }); }); diff --git a/src/app/service/content/listener_manager.ts b/src/app/service/content/listener_manager.ts index cc4de0f9e..ef9aa23ed 100644 --- a/src/app/service/content/listener_manager.ts +++ b/src/app/service/content/listener_manager.ts @@ -2,46 +2,44 @@ // 删除会较慢但执行会较快 export class ListenerManager void> { private counterId = 0; - private readonly listeners = new Map>(); + private readonly listeners: Array<{ key: string; id: number; handler: T }> = []; public add(key: string, handler: T): number { const id = ++this.counterId; - let listenrMap = this.listeners.get(key); - if (!listenrMap) { - this.listeners.set(key, (listenrMap = new Map())); - } - listenrMap.set(id, handler); + this.listeners[this.listeners.length] = { key, id, handler }; return id; } public execute(key: string, ...args: T extends (key: string, ...a: infer A) => any ? A : never): void { - const handlers = this.listeners.get(key); - if (handlers) { - for (const handler of handlers.values()) { - handler?.(key, ...args); + // handler 可能在执行期间移除自身;按当前下标复查 id,避免跳过紧邻监听器。 + for (let i = 0; i < this.listeners.length; ) { + const listener = this.listeners[i]; + if (listener?.key !== key) { + i += 1; + continue; } + const listenerId = listener.id; + listener.handler?.(key, ...args); + if (this.listeners[i]?.id === listenerId) i += 1; } } public remove(id: number | string): boolean { const idNum = +id || 0; if (idNum > 0) { - for (const [key, handlers] of this.listeners) { - if (handlers.delete(idNum)) { - if (handlers.size === 0) { - this.listeners.delete(key); - } - return true; + for (let i = 0; i < this.listeners.length; i += 1) { + if (this.listeners[i]?.id !== idNum) continue; + for (let j = i + 1; j < this.listeners.length; j += 1) { + this.listeners[j - 1] = this.listeners[j]; } + this.listeners.length -= 1; + return true; } } return false; } public clear(): void { - for (const [_key, handlers] of this.listeners) { - handlers.clear(); - } - this.listeners.clear(); + this.listeners.length = 0; } } diff --git a/src/app/service/content/main_world_page_load_gate.test.ts b/src/app/service/content/main_world_page_load_gate.test.ts new file mode 100644 index 000000000..53a948ae2 --- /dev/null +++ b/src/app/service/content/main_world_page_load_gate.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it, vi } from "vitest"; +import { createMainWorldPageLoadGate } from "./main_world_page_load_gate"; + +describe("createMainWorldPageLoadGate", () => { + it("does not deliver the page-visible payload while the native channel is opening", async () => { + let resolveNative!: (connected: boolean) => void; + const openNativeChannel = vi.fn( + () => + new Promise((resolve) => { + resolveNative = resolve; + }) + ); + const receivePageLoad = vi.fn(); + const gate = createMainWorldPageLoadGate(openNativeChannel, receivePageLoad); + + gate.onPageLoad({ source: "page" }); + gate.onBootstrap("bootstrap-token"); + + expect(openNativeChannel).toHaveBeenCalledWith("bootstrap-token"); + expect(receivePageLoad).not.toHaveBeenCalled(); + + resolveNative(true); + await Promise.resolve(); + expect(receivePageLoad).not.toHaveBeenCalled(); + + gate.onPageLoad({ source: "replay" }); + expect(receivePageLoad).not.toHaveBeenCalled(); + }); + + it("releases one queued payload only when native transport is unavailable", async () => { + const receivePageLoad = vi.fn(); + const requestFallbackPageLoad = vi.fn(); + const gate = createMainWorldPageLoadGate(async () => false, receivePageLoad, requestFallbackPageLoad); + const first = { source: "page" }; + const second = { source: "page-after-fallback" }; + + gate.onPageLoad(first); + gate.onBootstrap("bootstrap-token"); + await Promise.resolve(); + + expect(receivePageLoad).toHaveBeenCalledWith(first); + expect(requestFallbackPageLoad).toHaveBeenCalledOnce(); + + gate.onPageLoad(second); + expect(receivePageLoad).toHaveBeenLastCalledWith(second); + expect(receivePageLoad).toHaveBeenCalledTimes(2); + }); + + it("does not request a page-visible fallback after native transport succeeds", async () => { + const requestFallbackPageLoad = vi.fn(); + const gate = createMainWorldPageLoadGate(async () => true, vi.fn(), requestFallbackPageLoad); + + gate.onBootstrap("bootstrap-token"); + await Promise.resolve(); + + expect(requestFallbackPageLoad).not.toHaveBeenCalled(); + }); + + it("does not reopen or fall back after the native channel has been selected", async () => { + const receivePageLoad = vi.fn(); + const openNativeChannel = vi.fn(async () => true); + const gate = createMainWorldPageLoadGate(openNativeChannel, receivePageLoad); + + gate.onBootstrap("first-token"); + await Promise.resolve(); + gate.onBootstrap("second-token"); + gate.onPageLoad({ source: "replay" }); + + expect(openNativeChannel).toHaveBeenCalledOnce(); + expect(receivePageLoad).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/service/content/main_world_page_load_gate.ts b/src/app/service/content/main_world_page_load_gate.ts new file mode 100644 index 000000000..35652ab7e --- /dev/null +++ b/src/app/service/content/main_world_page_load_gate.ts @@ -0,0 +1,44 @@ +type MainWorldPageLoadGateState = "waiting" | "opening" | "native" | "fallback"; + +export type MainWorldPageLoadGate = { + onBootstrap: (bootstrapToken: string) => void; + onPageLoad: (data: unknown) => void; +}; + +export const createMainWorldPageLoadGate = ( + openNativeChannel: (bootstrapToken: string) => Promise, + receivePageLoad: (data: unknown) => void, + requestFallbackPageLoad: () => void = () => undefined +): MainWorldPageLoadGate => { + let state: MainWorldPageLoadGateState = "waiting"; + let pendingPageLoad: unknown; + let hasPendingPageLoad = false; + + const finishOpening = (connected: boolean): void => { + if (state !== "opening") return; + state = connected ? "native" : "fallback"; + if (state === "fallback") requestFallbackPageLoad(); + if (state === "fallback" && hasPendingPageLoad) { + receivePageLoad(pendingPageLoad); + } + pendingPageLoad = undefined; + hasPendingPageLoad = false; + }; + + return { + onBootstrap(bootstrapToken) { + if (state !== "waiting") return; + state = "opening"; + void openNativeChannel(bootstrapToken).then(finishOpening, () => finishOpening(false)); + }, + onPageLoad(data) { + if (state === "fallback") { + receivePageLoad(data); + return; + } + if (state === "native") return; + pendingPageLoad = data; + hasPendingPageLoad = true; + }, + }; +}; diff --git a/src/app/service/content/page_rpc.test.ts b/src/app/service/content/page_rpc.test.ts new file mode 100644 index 000000000..40cb5c58e --- /dev/null +++ b/src/app/service/content/page_rpc.test.ts @@ -0,0 +1,383 @@ +import { describe, expect, it, vi } from "vitest"; +import { Blob as NodeBlob } from "node:buffer"; +import { + getPageRpcAllowedAPIs, + setPageRpcExtensionOrigin, + isExtensionBlobUrl, + PageRpcError, + PageRpcRegistry, + validatePageGMRequest, +} from "./page_rpc"; + +describe("page GM RPC", () => { + it("expands only the helper operations reachable from an explicit public grant", () => { + const allowed = getPageRpcAllowedAPIs(["CAT.agent.opfs", "GM_xmlhttpRequest"]); + + expect(allowed).toEqual( + expect.arrayContaining([ + "CAT.agent.opfs", + "CAT_agentOPFS", + "CAT_fetchBlob", + "GM_xmlhttpRequest", + "GM.xmlhttpRequest", + ]) + ); + expect(allowed).not.toContain("CAT_fetchDocument"); + expect(allowed).not.toContain("CAT_createBlobUrl"); + expect(allowed).not.toContain("CAT_agentSkills"); + }); + + it("includes APIs exposed through the same dependency graph as the script context", () => { + const allowed = getPageRpcAllowedAPIs(["GM.openInTab"]); + + expect(allowed).toEqual(expect.arrayContaining(["GM.openInTab", "GM_openInTab", "GM_closeInTab"])); + }); + + it("includes storage APIs used by delete wrappers", () => { + expect(getPageRpcAllowedAPIs(["GM_deleteValue"])).toEqual( + expect.arrayContaining(["GM_deleteValue", "GM_setValue"]) + ); + expect(getPageRpcAllowedAPIs(["GM.deleteValues"])).toEqual( + expect.arrayContaining(["GM.deleteValues", "GM_setValues"]) + ); + }); + + it("includes the nested cookie methods exposed by both cookie grant spellings", () => { + expect(getPageRpcAllowedAPIs(["GM.cookie"])).toEqual( + expect.arrayContaining(["GM.cookie.set", "GM.cookie.list", "GM.cookie.delete"]) + ); + expect(getPageRpcAllowedAPIs(["GM_cookie"])).toEqual( + expect.arrayContaining(["GM_cookie.set", "GM_cookie.list", "GM_cookie.delete"]) + ); + }); + + it("does not create a GM capability set for a none grant", () => { + expect(getPageRpcAllowedAPIs(["none", "GM_getValue", "CAT.agent.dom"])).toEqual([]); + }); + + it("honors a none grant when Array.prototype.some is hooked", () => { + const originalSome = Array.prototype.some; + Array.prototype.some = (() => false) as typeof Array.prototype.some; + let allowed: string[]; + try { + allowed = getPageRpcAllowedAPIs(["none", "GM_getValue"]); + } finally { + Array.prototype.some = originalSome; + } + + expect(allowed!).toEqual([]); + }); + + it("does not let a hooked String.prototype.slice enlarge grant aliases", () => { + const originalSlice = String.prototype.slice; + String.prototype.slice = (() => "xmlhttpRequest") as typeof String.prototype.slice; + let allowed: string[]; + try { + allowed = getPageRpcAllowedAPIs(["GM.getValue"]); + } finally { + String.prototype.slice = originalSlice; + } + + expect(allowed!).toContain("GM_getValue"); + expect(allowed!).not.toContain("GM_xmlhttpRequest"); + }); + + it("ignores inherited capability-map properties for unknown grant names", () => { + expect(getPageRpcAllowedAPIs(["constructor", "toString"])).toEqual(["constructor", "toString"]); + }); + + it("does not let a hooked Array.prototype.push enlarge the capability result", () => { + const originalPush = Array.prototype.push; + Array.prototype.push = function (...items: unknown[]): number { + return originalPush.call(this, ...items, "GM_xmlhttpRequest"); + }; + let allowed: string[]; + try { + allowed = getPageRpcAllowedAPIs(["GM_getValue"]); + } finally { + Array.prototype.push = originalPush; + } + + expect(allowed!).not.toContain("GM_xmlhttpRequest"); + }); + + it("allows the internal request name used by the GM.xmlHttpRequest wrapper", () => { + const allowed = getPageRpcAllowedAPIs(["GM.xmlHttpRequest"]); + + expect(allowed).toContain("GM_xmlhttpRequest"); + expect(allowed).not.toContain("CAT_fetchBlob"); + expect(allowed).not.toContain("CAT_fetchDocument"); + expect(allowed).not.toContain("CAT_createBlobUrl"); + }); + + it("rejects direct internal fetch helpers from a GM XHR binding", () => { + const registry = new PageRpcRegistry(); + const handle = registry.register("script-a", "it", getPageRpcAllowedAPIs(["GM_xmlhttpRequest"])); + + expect(() => + validatePageGMRequest( + { version: 1, requestId: "fetch", handle, api: "CAT_fetchBlob", params: ["https://example.com/file"] }, + registry + ) + ).toThrow("API is not granted"); + expect(() => + validatePageGMRequest( + { + version: 1, + requestId: "document", + handle, + api: "CAT_fetchDocument", + params: ["https://example.com/file", false], + }, + registry + ) + ).toThrow("API is not granted"); + }); + + it("accepts a request for the active execution binding and clones parameters", () => { + const registry = new PageRpcRegistry(); + const handle = registry.register("script-a", "it", ["GM_getValue"], undefined, "canonical-run"); + const params = { nested: { value: 1 } }; + + const request = validatePageGMRequest( + { + version: 1, + requestId: "request-a", + handle, + api: "GM_getValue", + params: [params], + }, + registry + ); + + expect(request).toEqual({ + version: 1, + requestId: "request-a", + handle, + api: "GM_getValue", + params: [params], + uuid: "script-a", + envTag: "it", + runFlag: "canonical-run", + }); + expect(request.params[0]).not.toBe(params); + expect(() => + validatePageGMRequest({ version: 1, requestId: "request-a", handle, api: "GM_getValue", params: [] }, registry) + ).toThrow("already used"); + }); + + it("rejects an unknown or stale execution binding and supplies canonical identity", () => { + const registry = new PageRpcRegistry(); + const handle = registry.register("script-a", "it", ["GM_getValue"]); + + expect(() => + validatePageGMRequest( + { + version: 1, + requestId: "a", + handle: "missing", + api: "GM_getValue", + params: [], + }, + registry + ) + ).toThrow(PageRpcError); + + registry.revoke(handle); + expect(() => + validatePageGMRequest({ version: 1, requestId: "b", handle, api: "GM_getValue", params: [] }, registry) + ).toThrow(PageRpcError); + + const activeHandle = registry.register("script-a", "it", ["GM_getValue"]); + expect( + validatePageGMRequest( + { version: 1, requestId: "c", handle: activeHandle, api: "GM_getValue", params: [] }, + registry + ) + ).toMatchObject({ + uuid: "script-a", + envTag: "it", + }); + expect(() => + validatePageGMRequest( + { version: 1, requestId: "d", handle: activeHandle, uuid: "script-b", api: "GM_getValue", params: [] }, + registry + ) + ).toThrow(PageRpcError); + }); + + it("rejects APIs outside the binding and packets with accessors or unsupported values", () => { + const registry = new PageRpcRegistry(); + const handle = registry.register("script-a", "it", ["GM_getValue"]); + const accessorRequest = { + version: 1, + requestId: "a", + handle, + api: "GM_getValue", + params: [], + }; + Object.defineProperty(accessorRequest, "api", { get: () => "GM_getValue" }); + + expect(() => validatePageGMRequest(accessorRequest, registry)).toThrow(PageRpcError); + expect(() => + validatePageGMRequest({ version: 1, requestId: "b", handle, api: "GM_setValue", params: [] }, registry) + ).toThrow(PageRpcError); + expect(() => + validatePageGMRequest( + { + version: 1, + requestId: "c", + handle, + api: "GM_getValue", + params: [() => undefined], + }, + registry + ) + ).toThrow(PageRpcError); + }); + + it("rejects accessors nested in collection RPC parameters", () => { + const registry = new PageRpcRegistry(); + const handle = registry.register("script-a", "it", ["GM_getValue"]); + const getter = vi.fn(() => "secret"); + const nested = {} as Record; + Object.defineProperty(nested, "value", { configurable: true, enumerable: true, get: getter }); + + expect(() => + validatePageGMRequest( + { version: 1, requestId: "collection", handle, api: "GM_getValue", params: [new Map([["nested", nested]])] }, + registry + ) + ).toThrow(PageRpcError); + expect(getter).not.toHaveBeenCalled(); + }); + + it("rejects accessors nested in set RPC parameters", () => { + const registry = new PageRpcRegistry(); + const handle = registry.register("script-a", "it", ["GM_getValue"]); + const getter = vi.fn(() => "secret"); + const nested = {} as Record; + Object.defineProperty(nested, "value", { configurable: true, enumerable: true, get: getter }); + + expect(() => + validatePageGMRequest( + { version: 1, requestId: "set", handle, api: "GM_getValue", params: [new Set([nested])] }, + registry + ) + ).toThrow(PageRpcError); + expect(getter).not.toHaveBeenCalled(); + }); + + it("does not execute a Symbol.toStringTag accessor while validating RPC values", () => { + const registry = new PageRpcRegistry(); + const handle = registry.register("script-a", "it", ["GM_getValue"]); + const getter = vi.fn(() => "Blob"); + const nested = Object.create(null) as Record; + Object.defineProperty(nested, Symbol.toStringTag, { configurable: true, get: getter }); + + expect(() => + validatePageGMRequest({ version: 1, requestId: "tag", handle, api: "GM_getValue", params: [nested] }, registry) + ).toThrow(PageRpcError); + expect(getter).not.toHaveBeenCalled(); + }); + + it("keeps validation on captured intrinsics after page prototype hooks", () => { + const registry = new PageRpcRegistry(); + const handle = registry.register("script-a", "it", ["GM_getValue"]); + const ownKeysSpy = vi.spyOn(Reflect, "ownKeys").mockImplementation(() => { + throw new Error("page hook"); + }); + const descriptorSpy = vi.spyOn(Object, "getOwnPropertyDescriptor").mockImplementation(() => { + throw new Error("page hook"); + }); + + let result: ReturnType | undefined; + try { + result = validatePageGMRequest( + { version: 1, requestId: "hooked", handle, api: "GM_getValue", params: [] }, + registry + ); + } finally { + ownKeysSpy.mockRestore(); + descriptorSpy.mockRestore(); + } + expect(result).toMatchObject({ uuid: "script-a", envTag: "it" }); + }); + + it("rejects malformed parameters for privileged helper operations", () => { + const registry = new PageRpcRegistry(); + const handle = registry.register("script-a", "it", ["CAT_fetchBlob"]); + + expect(() => + validatePageGMRequest({ version: 1, requestId: "a", handle, api: "CAT_fetchBlob", params: [42] }, registry) + ).toThrow("CAT_fetchBlob expects an extension blob URL"); + + expect(isExtensionBlobUrl("https://example.com/file")).toBe(false); + const extensionBlobUrl = `blob:${chrome.runtime.getURL("/").replace(/\/$/, "")}/internal`; + expect(isExtensionBlobUrl(extensionBlobUrl)).toBe(true); + + expect( + validatePageGMRequest( + { version: 1, requestId: "b", handle, api: "CAT_fetchBlob", params: [extensionBlobUrl] }, + registry + ).params + ).toEqual([extensionBlobUrl]); + expect(isExtensionBlobUrl("blob:https://example.com/internal")).toBe(false); + expect(isExtensionBlobUrl("blob:chrome-extension://other/internal")).toBe(false); + }); + + it("requires a Blob for CAT_createBlobUrl after parameter cloning", () => { + const registry = new PageRpcRegistry(); + const handle = registry.register("script-a", "it", ["CAT_createBlobUrl"]); + + expect(() => + validatePageGMRequest( + { version: 1, requestId: "object", handle, api: "CAT_createBlobUrl", params: [{}] }, + registry + ) + ).toThrow("CAT_createBlobUrl expects one Blob value"); + + const blob = new NodeBlob(["payload"], { type: "text/plain" }); + expect(Object.prototype.toString.call(blob)).toBe("[object Blob]"); + expect(Object.prototype.toString.call(structuredClone(blob))).toBe("[object Blob]"); + const request = validatePageGMRequest( + { version: 1, requestId: "blob", handle, api: "CAT_createBlobUrl", params: [blob] }, + registry + ); + expect(Object.prototype.toString.call(request.params[0])).toBe("[object Blob]"); + expect(request.params[0]).not.toBe(blob); + }); + + it("validates extension blobs in USER_SCRIPT when runtime.getURL is unavailable", () => { + const runtime = chrome.runtime as unknown as { getURL?: typeof chrome.runtime.getURL }; + const getURL = runtime.getURL; + const extensionBlobUrl = `blob:chrome-extension://${chrome.runtime.id}/internal`; + try { + runtime.getURL = undefined; + setPageRpcExtensionOrigin({ protocol: "chrome-extension:", hostname: chrome.runtime.id, port: "" }); + expect(isExtensionBlobUrl(extensionBlobUrl)).toBe(true); + expect(isExtensionBlobUrl("blob:https://example.com/internal")).toBe(false); + } finally { + runtime.getURL = getURL; + setPageRpcExtensionOrigin(undefined); + } + }); + + it("accepts a unique request when replay state is full while still rejecting replay", () => { + const registry = new PageRpcRegistry(); + const handle = registry.register("script-a", "it", ["GM_getValue"]); + const binding = registry.resolve(handle, "GM_getValue"); + + // 请求 ID 必须严格只消费一次,与集合已保存的条目数量无关。 + // 直接模拟满集合,避免 CI 为构造状态发送数千个请求。 + Object.defineProperty(binding.requestIds, "size", { configurable: true, value: 4096 }); + binding.requestIds.add("request-0"); + + expect( + validatePageGMRequest({ version: 1, requestId: "request-4097", handle, api: "GM_getValue", params: [] }, registry) + ).toMatchObject({ requestId: "request-4097" }); + expect(() => + validatePageGMRequest({ version: 1, requestId: "request-0", handle, api: "GM_getValue", params: [] }, registry) + ).toThrow("already used"); + }); +}); diff --git a/src/app/service/content/page_rpc.ts b/src/app/service/content/page_rpc.ts new file mode 100644 index 000000000..f17eec44b --- /dev/null +++ b/src/app/service/content/page_rpc.ts @@ -0,0 +1,439 @@ +import { uuidv4 } from "@App/pkg/utils/uuid"; +import type { ScriptEnvTag } from "@Packages/message/consts"; +import { getGrantCandidates } from "./gm_api/grant"; +import { Native, nativeReflectApply } from "./global"; + +export const PAGE_RPC_VERSION = 1 as const; +const MAX_REQUEST_ID_LENGTH = 256; +const nativeStructuredClone = typeof structuredClone === "function" ? structuredClone : undefined; +const nativeObjectToString = Object.prototype.toString; +const nativeMapForEach = Map.prototype.forEach; +const nativeSetForEach = Set.prototype.forEach; +const EXTENSION_PROTOCOLS = new Native.Set(["chrome-extension:", "moz-extension:"]); +const nativeReflectOwnKeys = Native.reflectOwnKeys; +const nativeObjectGetOwnPropertyDescriptor = Native.objectGetOwnPropertyDescriptor; +const nativeArrayIsArray = Array.isArray; +const nativeURL = URL; +const nativeBlob = typeof Blob === "function" ? Blob : undefined; +const nativeStringSlice = String.prototype.slice; + +export type ExtensionOrigin = Pick; + +export const getExtensionOrigin = (): ExtensionOrigin | undefined => { + if (typeof chrome === "undefined" || typeof chrome.runtime?.getURL !== "function") return undefined; + try { + const url = new nativeURL(chrome.runtime.getURL("/")); + if (!EXTENSION_PROTOCOLS.has(url.protocol) || !url.hostname) return undefined; + return { protocol: url.protocol, hostname: url.hostname, port: url.port }; + } catch { + // Ignore malformed runtime metadata and reject the URL below. + } + return undefined; +}; + +// USER_SCRIPT 的 blob URL 必须回指当前扩展 origin,origin 由隔离 context 提供并缓存。 +let configuredExtensionOrigin: ExtensionOrigin | undefined; + +export const setPageRpcExtensionOrigin = (value: unknown): void => { + if (value === null || typeof value !== "object") { + configuredExtensionOrigin = undefined; + return; + } + try { + const read = (key: keyof ExtensionOrigin): unknown => { + const descriptor = nativeObjectGetOwnPropertyDescriptor(value, key); + return descriptor && "value" in descriptor ? descriptor.value : undefined; + }; + const protocol = read("protocol"); + const hostname = read("hostname"); + const port = read("port"); + if ( + (protocol !== "chrome-extension:" && protocol !== "moz-extension:") || + typeof hostname !== "string" || + hostname.length === 0 || + typeof port !== "string" + ) { + configuredExtensionOrigin = undefined; + return; + } + configuredExtensionOrigin = { protocol, hostname, port }; + } catch { + configuredExtensionOrigin = undefined; + } +}; + +export const isExtensionBlobUrl = (value: unknown): value is string => { + if (typeof value !== "string") return false; + const extensionOrigin = configuredExtensionOrigin || getExtensionOrigin(); + if (!extensionOrigin) return false; + try { + const url = new nativeURL(value); + if (url.protocol !== "blob:") return false; + const creatorOrigin = new nativeURL(nativeReflectApply(nativeStringSlice, value, ["blob:".length])); + return ( + creatorOrigin.protocol === extensionOrigin.protocol && + creatorOrigin.hostname === extensionOrigin.hostname && + creatorOrigin.port === extensionOrigin.port + ); + } catch { + return false; + } +}; + +export type PageExecutionBinding = { + readonly handle: string; + readonly uuid: string; + readonly envTag: ScriptEnvTag; + readonly allowedAPIs: ReadonlySet; + readonly runFlag: string; + active: boolean; + requestIds: Set; +}; + +export type PageGMRequest = { + readonly version: typeof PAGE_RPC_VERSION; + readonly requestId: string; + readonly handle: string; + readonly api: string; + readonly params: readonly unknown[]; + /** Canonical identity filled by the isolated broker after handle resolution. */ + readonly uuid: string; + readonly envTag: ScriptEnvTag; + readonly runFlag: string; +}; + +/** MAIN world 脚本可提交的不可信数据包。 */ +export type PageGMRequestPacket = { + readonly version: typeof PAGE_RPC_VERSION; + readonly requestId: string; + readonly handle: string; + readonly api: string; + readonly params: readonly unknown[]; +}; + +const INTERNAL_APIS_BY_GRANT: Readonly> = { + "CAT.agent.conversation": ["CAT_agentConversation", "CAT_agentConversationChat", "CAT_agentAttachToConversation"], + "CAT.agent.dom": ["CAT_agentDom"], + "CAT.agent.model": ["CAT_agentModel"], + "CAT.agent.opfs": ["CAT_agentOPFS", "CAT_fetchBlob"], + "CAT.agent.skills": ["CAT_agentSkills"], + "CAT.agent.task": ["CAT_agentTask"], + CAT_fileStorage: ["CAT_fetchBlob", "CAT_createBlobUrl"], + "GM.xmlHttpRequest": ["GM_xmlhttpRequest"], +}; + +// ScriptingRuntime 不加载 GM 实现模块,因此在此镜像一份精简依赖图。 +const API_DEPENDENCIES: Readonly> = { + "GM.getValues": ["GM_getValues"], + "GM.cookie": ["GM.cookie.set", "GM.cookie.list", "GM.cookie.delete"], + GM_cookie: ["GM_cookie.set", "GM_cookie.list", "GM_cookie.delete"], + "GM.setValue": ["GM_setValue"], + "GM.setValues": ["GM_setValues"], + "GM.listValues": ["GM_listValues"], + "GM.download": ["GM_download"], + "GM.notification": ["GM_notification"], + "GM.addValueChangeListener": ["GM_addValueChangeListener"], + "GM.removeValueChangeListener": ["GM_removeValueChangeListener"], + "GM.log": ["GM_log"], + "GM.deleteValue": ["GM_setValue"], + GM_deleteValue: ["GM_setValue"], + "GM.deleteValues": ["GM_setValues"], + GM_deleteValues: ["GM_setValues"], + "GM.registerMenuCommand": ["GM_registerMenuCommand"], + CAT_registerMenuInput: ["GM_registerMenuCommand"], + "GM.addStyle": ["GM_addStyle"], + "GM.addElement": ["GM_addElement"], + "GM.unregisterMenuCommand": ["GM_unregisterMenuCommand"], + CAT_unregisterMenuInput: ["GM_unregisterMenuCommand"], + CAT_fileStorage: ["CAT_fetchBlob"], + "GM.openInTab": ["GM_openInTab", "GM_closeInTab"], + "GM.getTab": ["GM_getTab"], + "GM.saveTab": ["GM_saveTab"], + "GM.getTabs": ["GM_getTabs"], + "GM.setClipboard": ["GM_setClipboard"], + "GM.getResourceText": ["GM_getResourceText"], + "GM.getResourceURL": ["GM_getResourceURL"], + "GM.getResourceUrl": ["GM_getResourceURL"], +}; + +export const getPageRpcAllowedAPIs = (grants: readonly string[]): string[] => { + for (let index = 0; index < grants.length; index += 1) { + if (grants[index] === "none") return []; + } + const allowed = new Native.Set(); + const visited = new Native.Set(); + const visitGrant = (grant: string): void => { + const candidates = getGrantCandidates(grant); + for (let index = 0; index < candidates.length; index += 1) { + const candidate = candidates[index]; + if (visited.has(candidate)) continue; + visited.add(candidate); + allowed.add(candidate); + if (Native.objectHasOwn(INTERNAL_APIS_BY_GRANT, candidate)) { + const internalAPIs = INTERNAL_APIS_BY_GRANT[candidate]; + for (let index = 0; index < internalAPIs.length; index += 1) allowed.add(internalAPIs[index]); + } + if (Native.objectHasOwn(API_DEPENDENCIES, candidate)) { + const dependencies = API_DEPENDENCIES[candidate]; + for (let index = 0; index < dependencies.length; index += 1) visitGrant(dependencies[index]); + } + } + }; + for (let index = 0; index < grants.length; index += 1) visitGrant(grants[index]); + const result: string[] = []; + allowed.forEach((value) => { + result[result.length] = value; + }); + return result; +}; + +export class PageRpcError extends Error { + constructor(message: string) { + super(message); + this.name = "PageRpcError"; + } +} + +const ownData = (value: object, key: PropertyKey): unknown => { + const descriptor = nativeObjectGetOwnPropertyDescriptor(value, key); + if (!descriptor || !("value" in descriptor)) { + throw new PageRpcError(`page RPC field ${String(key)} must be a data property`); + } + return descriptor.value; +}; + +const isBlobLike = (value: object): boolean => { + let current: object | null = value; + while (current !== null) { + let descriptor: PropertyDescriptor | undefined; + try { + descriptor = nativeObjectGetOwnPropertyDescriptor(current, Symbol.toStringTag); + } catch { + throw new PageRpcError("page RPC value cannot be inspected"); + } + if (descriptor) { + if (!("value" in descriptor)) throw new PageRpcError("page RPC values cannot contain accessor properties"); + return descriptor.value === "Blob"; + } + try { + current = Native.objectGetPrototypeOf(current); + } catch { + throw new PageRpcError("page RPC value cannot be inspected"); + } + } + return false; +}; + +const assertDataOnly = (value: unknown, seen: Set): void => { + // 先检查自有数据描述符,再做 structuredClone;这样页面 getter/Proxy 不会在 broker 中执行。 + if (value === null || typeof value !== "object") return; + if (seen.has(value)) return; + seen.add(value); + + // Blob 的内部槽由浏览器管理;只检查可由页面添加的字符串属性,忽略其内部 symbol 属性。 + if (nativeBlob && (value instanceof nativeBlob || isBlobLike(value))) { + let keys: (string | symbol)[]; + try { + keys = nativeReflectOwnKeys(value); + } catch { + throw new PageRpcError("page RPC value cannot be inspected"); + } + for (let index = 0; index < keys.length; index += 1) { + const key = keys[index]; + if (typeof key === "string") assertDataOnly(ownData(value, key), seen); + } + return; + } + + // Map/Set 条目不在自有属性中,必须先检查,避免 structuredClone 遍历时触发嵌套访问器。 + try { + nativeReflectApply(nativeMapForEach, value as Map, [ + (key: unknown, entry: unknown) => { + assertDataOnly(key, seen); + assertDataOnly(entry, seen); + }, + ]); + return; + } catch (error) { + if (error instanceof PageRpcError) throw error; + // 不是 Map,继续检查普通自有属性。 + } + try { + nativeReflectApply(nativeSetForEach, value as Set, [(entry: unknown) => assertDataOnly(entry, seen)]); + return; + } catch (error) { + if (error instanceof PageRpcError) throw error; + // 不是 Set,继续检查普通自有属性。 + } + + let keys: (string | symbol)[]; + try { + keys = nativeReflectOwnKeys(value); + } catch { + throw new PageRpcError("page RPC value cannot be inspected"); + } + for (let index = 0; index < keys.length; index += 1) { + const key = keys[index]; + if (typeof key === "symbol") throw new PageRpcError("page RPC values cannot contain symbol properties"); + const child = ownData(value, key); + assertDataOnly(child, seen); + } +}; + +const cloneParams = (params: unknown): readonly unknown[] => { + // 复制发生在交给 service worker 之前,后续 broker 只处理隔离后的普通值。 + if (!nativeArrayIsArray(params)) throw new PageRpcError("page RPC params must be an array"); + assertDataOnly(params, new Native.Set()); + if (!nativeStructuredClone) throw new PageRpcError("structured clone is unavailable"); + try { + return nativeStructuredClone(params) as readonly unknown[]; + } catch { + throw new PageRpcError("page RPC params are not cloneable"); + } +}; + +const validateOperationParams = (api: string, params: readonly unknown[]): void => { + switch (api) { + case "CAT_fetchBlob": + if (params.length !== 1 || !isExtensionBlobUrl(params[0])) { + throw new PageRpcError("CAT_fetchBlob expects an extension blob URL"); + } + return; + case "CAT_createBlobUrl": + if ( + params.length !== 1 || + !nativeBlob || + (!(params[0] instanceof nativeBlob) && nativeObjectToString.call(params[0]) !== "[object Blob]") + ) { + throw new PageRpcError("CAT_createBlobUrl expects one Blob value"); + } + return; + case "CAT_fetchDocument": + if (params.length !== 2 || typeof params[0] !== "string" || typeof params[1] !== "boolean") { + throw new PageRpcError("CAT_fetchDocument expects a URL and content flag"); + } + return; + case "CAT_agentOPFS": + if ( + params.length !== 1 || + params[0] === null || + typeof params[0] !== "object" || + nativeArrayIsArray(params[0]) || + typeof (params[0] as { action?: unknown }).action !== "string" + ) { + throw new PageRpcError("CAT_agentOPFS expects an operation object"); + } + return; + default: + return; + } +}; + +export class PageRpcRegistry { + private readonly bindings = new Native.Map(); + + register( + uuid: string, + envTag: ScriptEnvTag, + allowedAPIs: readonly string[], + handle = uuidv4(), + runFlag = uuidv4() + ): string { + if (!uuid || !handle || this.bindings.has(handle)) { + throw new PageRpcError("invalid page execution binding"); + } + this.bindings.set(handle, { + handle, + uuid, + envTag, + allowedAPIs: new Native.Set(allowedAPIs), + runFlag, + active: true, + requestIds: new Native.Set(), + }); + return handle; + } + + revoke(handle: string): void { + this.bindings.delete(handle); + } + + revokeAll(): void { + this.bindings.clear(); + } + + resolve(handle: string, api: string): PageExecutionBinding { + const binding = this.bindings.get(handle); + if (!binding?.active) throw new PageRpcError("page execution binding is inactive"); + if (!binding.allowedAPIs.has(api)) throw new PageRpcError("API is not granted to this execution"); + return binding; + } + + consumeRequestId(binding: PageExecutionBinding, requestId: string): void { + // requestId 在每个绑定内只接受一次;绑定销毁时一并释放,避免重放而不截断长时间运行的脚本。 + if (binding.requestIds.has(requestId)) throw new PageRpcError("page RPC requestId was already used"); + binding.requestIds.add(requestId); + } +} + +const REQUEST_KEYS = ["version", "requestId", "handle", "api", "params"] as const; + +export const validatePageGMRequest = (value: unknown, registry: PageRpcRegistry): PageGMRequest => { + if (value === null || typeof value !== "object") throw new PageRpcError("page RPC request must be an object"); + + let keys: (string | symbol)[]; + try { + keys = nativeReflectOwnKeys(value); + } catch { + throw new PageRpcError("page RPC request cannot be inspected"); + } + if (keys.length !== REQUEST_KEYS.length) { + throw new PageRpcError("page RPC request has unexpected fields"); + } + for (let index = 0; index < keys.length; index += 1) { + const key = keys[index]; + let knownKey = false; + if (typeof key === "string") { + for (let expectedIndex = 0; expectedIndex < REQUEST_KEYS.length; expectedIndex += 1) { + const expected = REQUEST_KEYS[expectedIndex]; + if (expected === key) { + knownKey = true; + break; + } + } + } + if (!knownKey) { + throw new PageRpcError("page RPC request has unexpected fields"); + } + } + + const version = ownData(value, "version"); + const requestId = ownData(value, "requestId"); + const handle = ownData(value, "handle"); + const api = ownData(value, "api"); + const params = ownData(value, "params"); + + if (version !== PAGE_RPC_VERSION) throw new PageRpcError("unsupported page RPC version"); + if (typeof requestId !== "string" || !requestId || requestId.length > MAX_REQUEST_ID_LENGTH) { + throw new PageRpcError("page RPC requestId is invalid"); + } + if (typeof handle !== "string" || typeof api !== "string") { + throw new PageRpcError("page RPC identity fields are invalid"); + } + + const binding = registry.resolve(handle, api); + // resolve 同时执行句柄、授权和活跃状态检查;不要把页面传来的 api 直接转发给后端。 + const clonedParams = cloneParams(params); + validateOperationParams(api, clonedParams); + registry.consumeRequestId(binding, requestId); + return { + version: PAGE_RPC_VERSION, + requestId, + handle, + api, + params: clonedParams, + uuid: binding.uuid, + envTag: binding.envTag, + runFlag: binding.runFlag, + }; +}; diff --git a/src/app/service/content/script_executor.test.ts b/src/app/service/content/script_executor.test.ts index a4fe6c02a..65ecfc42a 100644 --- a/src/app/service/content/script_executor.test.ts +++ b/src/app/service/content/script_executor.test.ts @@ -2,10 +2,30 @@ import { describe, expect, it, beforeEach, afterEach, vi } from "vitest"; import type { Message } from "@Packages/message/types"; import type { ScriptLoadInfo } from "../service_worker/types"; import type { TScriptInfo } from "@App/app/repo/scripts"; +import type { GMInfoEnv } from "./types"; import { initEnvInfo, ScriptExecutor } from "./script_executor"; +import { + compilePreInjectScript, + preInjectScriptDocumentIdKey, + preInjectScriptDocumentUrlKey, + preInjectScriptInfoKey, +} from "./utils"; +import { DefinedFlags } from "../service_worker/runtime.consts"; +import { pageDispatchEvent } from "@Packages/message/common"; const styleUrl = "https://example.com/style.css"; const secondStyleUrl = "https://example.com/second-style.css"; +const fnStrIntegrity = process.env.SC_RANDOM_FNKEY!; + +beforeEach(() => { + if (!Object.prototype.hasOwnProperty.call(window, preInjectScriptDocumentIdKey)) { + Object.defineProperty(window, preInjectScriptDocumentIdKey, { + configurable: false, + writable: false, + value: "script-executor-test-document", + }); + } +}); function makeScript(overrides: Partial> = {}): ScriptLoadInfo { return { @@ -31,6 +51,322 @@ function makeScript(overrides: Partial { + it("uses the configured transport prefix for USER_SCRIPT GM calls", () => { + const sendMessage = vi.fn().mockResolvedValue(undefined); + const script = makeScript({ metadata: { grant: ["GM_log"] } }); + const executor = new ScriptExecutor({ sendMessage } as unknown as Message, {} as Message, "serviceWorker"); + + executor.execScriptEntry({ + scriptLoadInfo: script, + scriptFlag: script.flag, + envInfo: initEnvInfo, + scriptFunc: (_token: string, context: any) => context.GM_log("transport prefix"), + }); + + expect(sendMessage).toHaveBeenCalledWith({ + action: "serviceWorker/runtime/gmApi", + data: expect.objectContaining({ api: "GM_log" }), + }); + }); + + it("does not resolve page-patchable Map methods for execution bookkeeping", () => { + const originalSet = Map.prototype.set; + const originalGet = Map.prototype.get; + const originalValues = Map.prototype.values; + const receivers: Map[] = []; + Map.prototype.set = function (key, value) { + receivers.push(this); + return originalSet.call(this, key, value); + }; + Map.prototype.get = function (key) { + receivers.push(this); + return originalGet.call(this, key); + }; + Map.prototype.values = function () { + receivers.push(this); + return originalValues.call(this); + }; + try { + const executor = new ScriptExecutor({} as Message, {} as Message); + executor.execScriptEntry({ + scriptLoadInfo: makeScript(), + scriptFlag: "executor-test-flag", + envInfo: initEnvInfo, + scriptFunc: () => undefined, + }); + expect(receivers).toHaveLength(0); + } finally { + Map.prototype.set = originalSet; + Map.prototype.get = originalGet; + Map.prototype.values = originalValues; + } + }); + + it("attaches the page execution binding when an early-start script is reconciled", () => { + const initial = makeScript({ metadata: { "early-start": [""], "run-at": ["document-start"] } }); + const executor = new ScriptExecutor({} as Message, {} as Message); + + executor.execScriptEntry({ + scriptLoadInfo: initial, + scriptFlag: initial.flag, + envInfo: initEnvInfo, + scriptFunc: () => undefined, + }); + + const exec = ( + executor as unknown as { + execScripts: Map< + string, + { + scriptRes: TScriptInfo; + updateEarlyScriptGMInfo: (envInfo: GMInfoEnv, scriptInfo?: TScriptInfo) => void; + } + >; + } + ).execScripts.get(initial.uuid)!; + expect(exec.scriptRes.executionHandle).toBeUndefined(); + + exec.updateEarlyScriptGMInfo(initEnvInfo, { + ...initial, + value: { secret: "authoritative-value" }, + config: { + private: { secret: { title: "Private", description: "", index: 0, default: "authoritative" } }, + }, + executionHandle: "page-binding", + executionEnvTag: "it", + }); + + expect(exec.scriptRes.executionHandle).toBe("page-binding"); + expect(exec.scriptRes.executionEnvTag).toBe("it"); + expect(exec.scriptRes.value).toEqual({ secret: "authoritative-value" }); + expect(exec.scriptRes.config).toEqual({ + private: { secret: { title: "Private", description: "", index: 0, default: "authoritative" } }, + }); + }); + + it("ignores a counterfeit mount and keeps listening for the genuine wrapper", () => { + const script = makeScript({ flag: "executor-counterfeit-flag" }); + const executor = new ScriptExecutor({} as Message, {} as Message); + const attackerTarget = vi.fn(); + const attacker = new Proxy(attackerTarget, { + getOwnPropertyDescriptor(target, property) { + if (property === fnStrIntegrity) { + return { configurable: true, enumerable: false, value: true, writable: true }; + } + return Object.getOwnPropertyDescriptor(target, property); + }, + }); + const genuine = vi.fn(); + const pageWindow = window as unknown as Record; + Object.defineProperty(genuine, fnStrIntegrity, { value: true }); + + try { + executor.startScripts([script], initEnvInfo); + pageWindow[script.flag] = attacker; + + expect(attackerTarget).not.toHaveBeenCalled(); + + pageWindow[script.flag] = genuine; + + expect(genuine).toHaveBeenCalledWith(fnStrIntegrity, expect.anything(), undefined, script.name); + } finally { + delete pageWindow[script.flag]; + } + }); + + it("rejects a counterfeit early-start wrapper before execution", () => { + const script = makeScript({ flag: "executor-counterfeit-early-flag" }); + const executor = new ScriptExecutor({} as Message, {} as Message); + const attacker = vi.fn(); + const genuine = vi.fn(); + const pageWindow = window as unknown as Record; + Object.defineProperty(genuine, fnStrIntegrity, { value: true }); + + try { + pageWindow[script.flag] = attacker; + executor.execEarlyScript(script.flag, initEnvInfo); + expect(attacker).not.toHaveBeenCalled(); + + pageWindow[script.flag] = genuine; + Object.defineProperty(genuine, preInjectScriptInfoKey, { value: JSON.stringify(script) }); + Object.defineProperty(genuine, preInjectScriptDocumentUrlKey, { value: window.location.href }); + Object.defineProperty(genuine, preInjectScriptDocumentIdKey, { value: "script-executor-test-document" }); + executor.execEarlyScript(script.flag, initEnvInfo); + expect(genuine).toHaveBeenCalledWith(fnStrIntegrity, expect.anything(), undefined, script.name); + } finally { + delete pageWindow[script.flag]; + } + }); + + it("rejects early metadata that retargets the flag or carries a page binding", () => { + const script = makeScript({ flag: "#-executor-test-uuid" }); + const executor = new ScriptExecutor({} as Message, {} as Message); + const wrongUuid = vi.fn(); + const bound = vi.fn(); + const pageWindow = window as unknown as Record; + Object.defineProperty(wrongUuid, fnStrIntegrity, { value: true }); + Object.defineProperty(wrongUuid, preInjectScriptInfoKey, { + value: JSON.stringify({ ...script, uuid: "other-script" }), + }); + Object.defineProperty(bound, fnStrIntegrity, { value: true }); + Object.defineProperty(bound, preInjectScriptInfoKey, { + value: JSON.stringify({ ...script, executionHandle: "other-binding" }), + }); + + try { + pageWindow[script.flag] = wrongUuid; + executor.execEarlyScript(script.flag, initEnvInfo); + expect(wrongUuid).not.toHaveBeenCalled(); + + pageWindow[script.flag] = bound; + executor.execEarlyScript(script.flag, initEnvInfo); + expect(bound).not.toHaveBeenCalled(); + } finally { + delete pageWindow[script.flag]; + } + }); + + it("rejects same-UUID early metadata mutations", () => { + const script = makeScript({ + uuid: "executor-early-authenticated-uuid", + flag: "#-executor-early-authenticated-uuid", + metadata: { grant: ["GM_getValue", "GM_getResourceText"], resource: ["canonical https://example.com/canonical"] }, + resource: { + canonical: { + url: "https://example.com/canonical", + content: "canonical", + base64: "", + hash: { md5: "", sha1: "", sha256: "", sha384: "", sha512: "" }, + type: "resource", + link: {}, + contentType: "text/plain", + createtime: Date.now(), + }, + }, + }); + const executor = new ScriptExecutor({} as Message, {} as Message); + const pageWindow = window as unknown as Record; + const performance = { dispatchEvent: vi.fn(() => false), addEventListener: vi.fn() }; + const generated = new Function("window", "performance", "CustomEvent", compilePreInjectScript(script, "")); + + try { + generated(pageWindow, performance, CustomEvent); + const forged = { + ...script, + metadata: { grant: ["GM_setValue"] }, + resource: { forged: { content: "forged", contentType: "text/plain" } }, + } as TScriptInfo; + + executor.checkEarlyStartScript("it", initEnvInfo); + const hostileDetail = {}; + const flagGetter = vi.fn(() => script.flag); + Object.defineProperty(hostileDetail, "scriptFlag", { get: flagGetter }); + pageDispatchEvent( + new CustomEvent(`evt${process.env.SC_RANDOM_KEY}.it${DefinedFlags.scriptLoadComplete}`, { + detail: hostileDetail, + cancelable: true, + }) + ); + expect(flagGetter).not.toHaveBeenCalled(); + + pageDispatchEvent( + new CustomEvent(`evt${process.env.SC_RANDOM_KEY}.it${DefinedFlags.scriptLoadComplete}`, { + detail: { scriptFlag: script.flag, scriptInfo: forged }, + cancelable: true, + }) + ); + + const exec = ( + executor as unknown as { + execScripts: Map; + } + ).execScripts.get(script.uuid); + expect(exec?.scriptRes.metadata).toEqual(script.metadata); + expect(exec?.scriptRes.resource).toEqual({ + canonical: { base64: "", content: "canonical", contentType: "text/plain" }, + }); + } finally { + delete pageWindow[script.flag]; + } + }); + + it("accepts an early-start wrapper after a same-document URL change", () => { + const script = makeScript({ uuid: "executor-early-document-uuid", flag: "#-executor-early-document-uuid" }); + const executor = new ScriptExecutor({} as Message, {} as Message); + const genuine = vi.fn(); + const pageWindow = window as unknown as Record; + const initialUrl = window.location.href; + Object.defineProperty(genuine, fnStrIntegrity, { value: true }); + Object.defineProperty(genuine, preInjectScriptInfoKey, { value: JSON.stringify(script) }); + Object.defineProperty(genuine, preInjectScriptDocumentUrlKey, { value: initialUrl }); + Object.defineProperty(genuine, preInjectScriptDocumentIdKey, { value: "script-executor-test-document" }); + + try { + window.history.pushState({}, "", `${initialUrl}#same-document-change`); + pageWindow[script.flag] = genuine; + executor.execEarlyScript(script.flag, initEnvInfo); + expect(genuine).toHaveBeenCalledWith(fnStrIntegrity, expect.anything(), undefined, script.name); + } finally { + window.history.replaceState({}, "", initialUrl); + delete pageWindow[script.flag]; + } + }); + + it("accepts the immutable early manifest through the wrapper name fallback", () => { + const script = makeScript({ uuid: "executor-early-name-uuid", flag: "#-executor-early-name-uuid" }); + const executor = new ScriptExecutor({} as Message, {} as Message); + const genuine = vi.fn(); + const pageWindow = window as unknown as Record; + Object.defineProperty(genuine, fnStrIntegrity, { value: true }); + Object.defineProperty(genuine, preInjectScriptDocumentUrlKey, { value: window.location.href }); + Object.defineProperty(genuine, preInjectScriptDocumentIdKey, { value: "script-executor-test-document" }); + Object.defineProperty(genuine, "name", { configurable: false, value: JSON.stringify(script) }); + + try { + pageWindow[script.flag] = genuine; + executor.execEarlyScript(script.flag, initEnvInfo); + expect(genuine).toHaveBeenCalledWith(fnStrIntegrity, expect.anything(), undefined, script.name); + } finally { + delete pageWindow[script.flag]; + } + }); + + it("continues loading later scripts after reconciling an early-start entry", () => { + const early = makeScript({ + uuid: "early-script", + flag: "executor-early-batch", + metadata: { "early-start": [""], "run-at": ["document-start"] }, + }); + const later = makeScript({ uuid: "later-script", flag: "executor-later-batch" }); + const executor = new ScriptExecutor({} as Message, {} as Message); + executor.execScriptEntry({ + scriptLoadInfo: early, + scriptFlag: early.flag, + envInfo: initEnvInfo, + scriptFunc: () => undefined, + }); + + const internal = executor as unknown as { + earlyScriptFlags: Set; + execScripts: Map void }>; + }; + internal.earlyScriptFlags.add(early.flag); + const updateEarlyScriptGMInfo = vi.spyOn(internal.execScripts.get(early.uuid)!, "updateEarlyScriptGMInfo"); + const genuine = vi.fn(); + Object.defineProperty(genuine, fnStrIntegrity, { value: true }); + const pageWindow = window as unknown as Record; + + try { + executor.startScripts([early, later], initEnvInfo); + pageWindow[later.flag] = genuine; + + expect(updateEarlyScriptGMInfo).toHaveBeenCalledWith(initEnvInfo, early); + expect(genuine).toHaveBeenCalledWith(fnStrIntegrity, expect.anything(), undefined, later.name); + } finally { + delete pageWindow[later.flag]; + } + }); + describe("resource execution", () => { let adoptedSheets: CSSStyleSheet[]; diff --git a/src/app/service/content/script_executor.ts b/src/app/service/content/script_executor.ts index 7bbcd9bb3..377e22a67 100644 --- a/src/app/service/content/script_executor.ts +++ b/src/app/service/content/script_executor.ts @@ -3,13 +3,23 @@ import { getStorageName } from "@App/pkg/utils/utils"; import type { EmitEventRequest } from "../service_worker/types"; import ExecScript from "./exec_script"; import type { GMInfoEnv, ScriptFunc, ValueUpdateDataEncoded } from "./types"; -import { addStyleSheet, definePropertyListener, waitBody } from "./utils"; -import type { ScriptLoadInfo, TScriptInfo } from "@App/app/repo/scripts"; +import { + addStyleSheet, + definePropertyListener, + preInjectScriptDocumentIdKey, + preInjectScriptDocumentUrlKey, + preInjectScriptInfoKey, + waitBody, +} from "./utils"; +import type { TScriptInfo } from "@App/app/repo/scripts"; import { DefinedFlags } from "../service_worker/runtime.consts"; import { pageAddEventListener, pageDispatchEvent } from "@Packages/message/common"; import { isUrlExcluded } from "@App/pkg/utils/match"; import type { ScriptEnvTag } from "@Packages/message/consts"; -import { localizeObject } from "./global"; +import { localizeObject, Native } from "./global"; + +// 与编译器相同的构建级标记,用来拒绝页面伪造的脚本挂载函数。 +const fnStrIntegrity = process.env.SC_RANDOM_FNKEY!; export type ExecScriptEntry = { scriptLoadInfo: TScriptInfo; @@ -30,33 +40,32 @@ export const initEnvInfo: GMInfoEnv = { // 脚本执行器 export class ScriptExecutor { - earlyScriptFlag: Set = new Set(); - execScriptMap: Map = new Map(); + private readonly earlyScriptFlags = new Native.Set(); + private readonly execScripts = new Native.Map(); constructor( private msg: Message, - private contentMsg: Message // 用于 content <-> content/inject 通讯 + private contentMsg: Message, // 用于 content <-> content/inject 通讯 + private readonly envPrefix = "scripting" ) {} emitEvent(data: EmitEventRequest) { // 转发给脚本 - const exec = this.execScriptMap.get(data.uuid); - if (exec) { - exec.emitEvent(data.event, data.eventId, data.data); - } + this.execScripts.get(data.uuid)?.emitEvent(data.event, data.eventId, data.data); } valueUpdate(data: ValueUpdateDataEncoded) { // runtime/valueUpdate const { uuid, storageName } = data; - for (const val of this.execScriptMap.values()) { - if (val.scriptRes.uuid === uuid || getStorageName(val.scriptRes) === storageName) { - val.valueUpdate(data); + this.execScripts.forEach((exec) => { + if (exec.scriptRes.uuid === uuid || getStorageName(exec.scriptRes) === storageName) { + exec.valueUpdate(data); } - } + }); } startScripts(scripts: TScriptInfo[], envInfo: GMInfoEnv) { + const pageWindow = window as unknown as Record; const loadExec = (script: TScriptInfo, scriptFunc: any) => { this.execScriptEntry({ scriptLoadInfo: script, @@ -66,22 +75,39 @@ export class ScriptExecutor { }); }; // 监听脚本加载 - scripts.forEach((script) => { + for (let scriptIndex = 0; scriptIndex < scripts.length; scriptIndex += 1) { + const script = scripts[scriptIndex]; const flag = script.flag; // 如果是EarlyScriptFlag,处理沙盒环境 - if (this.earlyScriptFlag.has(flag)) { - for (const val of this.execScriptMap.values()) { - if (val.scriptRes.flag === flag) { + if (this.earlyScriptFlags.has(flag)) { + let updated = false; + this.execScripts.forEach((exec) => { + if (!updated && exec.scriptRes.flag === flag) { // 处理早期脚本的沙盒环境 - val.updateEarlyScriptGMInfo(envInfo); - return; + exec.updateEarlyScriptGMInfo(envInfo, script); + updated = true; } - } + }); + if (updated) continue; } - definePropertyListener(window, flag, (val: ScriptFunc) => { - loadExec(script, val); - }); - }); + const listenForScript = () => { + definePropertyListener(window, flag, (val: ScriptFunc) => { + // 只有扩展生成且不可改写的完整性标记才算有效挂载,页面自建同名函数必须忽略。 + const descriptor = + typeof val === "function" ? Native.objectGetOwnPropertyDescriptor(val, fnStrIntegrity) : undefined; + if (descriptor?.value !== true || descriptor.configurable || descriptor.writable) { + const mountDescriptor = Native.objectGetOwnPropertyDescriptor(pageWindow, flag); + if (mountDescriptor?.configurable) { + delete pageWindow[flag]; + listenForScript(); + } + return; + } + loadExec(script, val); + }); + }; + listenForScript(); + } } checkEarlyStartScript(scriptEnvTag: ScriptEnvTag, envInfo: GMInfoEnv) { @@ -91,33 +117,18 @@ export class ScriptExecutor { // 监听 脚本加载 // 适用于此「通知环境加载完成」代码执行后的脚本加载 const scriptLoadCompleteHandler: EventListener = (ev: Event) => { - const detail = (ev as CustomEvent).detail as { - scriptFlag: string; - scriptInfo: ScriptLoadInfo; - }; - const scriptFlag = detail?.scriptFlag; - if (typeof scriptFlag === "string") { - ev.preventDefault(); // dispatchEvent 会回传 false -> 分离环境也能得知环境加载代码已执行 - // 检查是否有 urlPattern,有则执行匹配再决定是否略过注入 - if (detail.scriptInfo.scriptUrlPatterns) { - // 以 REGEX 情况为例 - // "@include /REGEX/" 的情况下,MV3 UserScripts API 基础匹配范围扩大,会比实际需要的广阔,然后在 earlyScript 把不符合 REGEX 的除去 - // (All @include = false -> 除去) - // 注:如果 @include 混合了 regex 跟 一般的,即使 regex 的 @include 不匹对当前网址,但匹对了一般 @include 也视为有效 - // 相反如果 @include 混合了 regex 跟 一般的,regex 的 @include 匹对了即可 - // "@exclude /REGEX/" 的情况下,MV3 UserScripts API 基础匹配范围不会扩大,然后在 earlyScript 把符合 REGEX 的匹配除去 - // (Any @exclude = true -> 除去) - // 注:如果一早已被除排,根本不会被 MV3 UserScripts API 注入。所以只考虑排除「多余的匹配」。(略过注入) - try { - if (isUrlExcluded(window.location.href, detail.scriptInfo.scriptUrlPatterns)) { - // 「多余的匹配」-> 略过注入 - return; - } - } catch (e) { - console.warn("Unexpected match error", e); - } - } - this.execEarlyScript(scriptFlag, detail.scriptInfo, envInfo); + let scriptFlag: unknown; + try { + const detail = (ev as CustomEvent).detail; + if (!detail || typeof detail !== "object") return; + const flagDescriptor = Native.objectGetOwnPropertyDescriptor(detail, "scriptFlag"); + if (!flagDescriptor || !("value" in flagDescriptor)) return; + scriptFlag = flagDescriptor.value; + } catch { + return; + } + if (typeof scriptFlag === "string" && !this.earlyScriptFlags.has(scriptFlag)) { + if (this.execEarlyScript(scriptFlag, envInfo)) ev.preventDefault(); // dispatchEvent 会回传 false -> 分离环境也能得知环境加载代码已执行 } }; pageAddEventListener(scriptLoadCompleteEvtName, scriptLoadCompleteHandler); @@ -127,15 +138,87 @@ export class ScriptExecutor { pageDispatchEvent(ev); } - execEarlyScript(flag: string, scriptInfo: TScriptInfo, envInfo: GMInfoEnv) { - const scriptFunc = (window as any)[flag] as ScriptFunc; + execEarlyScript(flag: string, envInfo: GMInfoEnv) { + const scriptFunc = (window as unknown as Record)[flag] as ScriptFunc; + const descriptor = + typeof scriptFunc === "function" ? Native.objectGetOwnPropertyDescriptor(scriptFunc, fnStrIntegrity) : undefined; + if (descriptor?.value !== true || descriptor.configurable || descriptor.writable) return; + // 事件在页面可见,只用预注入函数上的不可改写清单作为脚本资料来源。 + const scriptInfoDescriptor = + typeof scriptFunc === "function" + ? Native.objectGetOwnPropertyDescriptor(scriptFunc, preInjectScriptInfoKey) + : undefined; + if (scriptInfoDescriptor?.configurable || scriptInfoDescriptor?.writable) return; + // The wrapper is installed on this document's window. Same-document history changes must not invalidate it; + // a full navigation creates a new window and cannot retain the old function. + const documentUrlDescriptor = + typeof scriptFunc === "function" + ? Native.objectGetOwnPropertyDescriptor(scriptFunc, preInjectScriptDocumentUrlKey) + : undefined; + if ( + !documentUrlDescriptor || + documentUrlDescriptor.configurable || + documentUrlDescriptor.writable || + typeof documentUrlDescriptor.value !== "string" + ) { + return; + } + const documentIdDescriptor = + typeof scriptFunc === "function" + ? Native.objectGetOwnPropertyDescriptor(scriptFunc, preInjectScriptDocumentIdKey) + : undefined; + const currentDocumentIdDescriptor = Native.objectGetOwnPropertyDescriptor(window, preInjectScriptDocumentIdKey); + if ( + !documentIdDescriptor || + documentIdDescriptor.configurable || + documentIdDescriptor.writable || + typeof documentIdDescriptor.value !== "string" || + !currentDocumentIdDescriptor || + currentDocumentIdDescriptor.configurable || + currentDocumentIdDescriptor.writable || + currentDocumentIdDescriptor.value !== documentIdDescriptor.value + ) { + return; + } + const scriptInfoJSON = + typeof scriptInfoDescriptor?.value === "string" + ? scriptInfoDescriptor.value + : typeof scriptFunc.name === "string" + ? scriptFunc.name + : undefined; + if (typeof scriptInfoJSON !== "string") return; + let scriptInfo: TScriptInfo | undefined; + try { + scriptInfo = Native.jsonParse(scriptInfoJSON) as TScriptInfo | undefined; + } catch { + return; + } + if (!scriptInfo || scriptInfo.flag !== flag) return; + const expectedUuid = flag.startsWith("#-") ? flag.slice(2) : undefined; + if (expectedUuid && scriptInfo.uuid !== expectedUuid) return; + if ( + scriptInfo.executionHandle !== undefined || + scriptInfo.executionEnvTag !== undefined || + scriptInfo.executionRunFlag !== undefined + ) { + return; + } + // MV3 对正则匹配会放宽注入范围,必须用编译器绑定的模式在当前页面再确认一次。 + if (scriptInfo.scriptUrlPatterns) { + try { + if (isUrlExcluded(window.location.href, scriptInfo.scriptUrlPatterns)) return; + } catch (e) { + console.warn("Unexpected match error", e); + } + } this.execScriptEntry({ scriptLoadInfo: scriptInfo, scriptFunc: scriptFunc, scriptFlag: flag, envInfo: envInfo, }); - this.earlyScriptFlag.add(flag); + this.earlyScriptFlags.add(flag); + return true; } execScriptEntry(scriptEntry: ExecScriptEntry) { @@ -144,18 +227,20 @@ export class ScriptExecutor { const scriptLoadInfo = localizeObject(scriptEntry.scriptLoadInfo); const execScript = new ExecScript(scriptLoadInfo, { - envPrefix: "scripting", + envPrefix: this.envPrefix, message: this.msg, contentMsg: this.contentMsg, code: scriptFunc, envInfo, }); - this.execScriptMap.set(scriptLoadInfo.uuid, execScript); + this.execScripts.set(scriptLoadInfo.uuid, execScript); const metadata = scriptLoadInfo.metadata || {}; const resource = scriptLoadInfo.requireCssResource ?? scriptLoadInfo.resource; // 注入css if (metadata["require-css"] && resource) { - for (const val of metadata["require-css"]) { + const requireCss = metadata["require-css"]; + for (let i = 0; i < requireCss.length; i += 1) { + const val = requireCss[i]; const res = resource[val]; if (res) { addStyleSheet(res.content); diff --git a/src/app/service/content/script_runtime.test.ts b/src/app/service/content/script_runtime.test.ts new file mode 100644 index 000000000..3945f3772 --- /dev/null +++ b/src/app/service/content/script_runtime.test.ts @@ -0,0 +1,316 @@ +import { describe, expect, it, vi } from "vitest"; +import type { Message } from "@Packages/message/types"; +import type { Server } from "@Packages/message/server"; +import type { CustomEventMessage } from "@Packages/message/custom_event_message"; +import { ScriptRuntime } from "./script_runtime"; +import type { ScriptExecutor } from "./script_executor"; + +describe("ScriptRuntime DOM bridge", () => { + it("rejects accessor attributes without executing their getters", () => { + let handler: ((data: any) => unknown) | undefined; + const server = { + on: vi.fn((_name: string, callback: (data: any) => unknown) => { + handler = callback; + }), + } as unknown as Server; + const runtime = new ScriptRuntime("ct", server, {} as Message, {} as any, undefined); + runtime.contentInit(server, {} as CustomEventMessage); + + const getter = vi.fn(() => "secret"); + const attrs = {} as Record; + Object.defineProperty(attrs, "id", { configurable: true, enumerable: true, get: getter }); + + expect(handler?.({ params: [null, "div", attrs] })).toBeUndefined(); + expect(getter).not.toHaveBeenCalled(); + }); + + it("creates an element only from the cloned flat attribute payload", () => { + let handler: ((data: any) => unknown) | undefined; + const domMessage = { + getAndDelRelatedTarget: vi.fn(), + sendRelatedTarget: vi.fn(() => 1), + } as unknown as CustomEventMessage; + const server = { + on: vi.fn((_name: string, callback: (data: any) => unknown) => { + handler = callback; + }), + } as unknown as Server; + const runtime = new ScriptRuntime("ct", server, {} as Message, {} as any, undefined); + runtime.contentInit(server, domMessage); + + const result = handler?.({ params: [null, "div", { id: "safe", textContent: "hello" }] }); + + expect(result).toBe(1); + expect(domMessage.sendRelatedTarget).toHaveBeenCalledWith(expect.any(HTMLDivElement)); + const element = (domMessage.sendRelatedTarget as any).mock.calls[0][0] as HTMLDivElement; + expect(element.id).toBe("safe"); + expect(element.textContent).toBe("hello"); + }); +}); + +describe("ScriptRuntime inject page bootstrap", () => { + const makeServer = () => { + const handlers = new Map unknown>(); + const server = { + on: vi.fn((name: string, callback: (data: unknown) => unknown) => { + handlers.set(name, callback); + }), + } as unknown as Server; + return { handlers, server }; + }; + + const makeExecutor = () => ({ + checkEarlyStartScript: vi.fn(), + startScripts: vi.fn(), + emitEvent: vi.fn(), + valueUpdate: vi.fn(), + }); + + const makePageLoad = () => ({ + scripts: [ + { + uuid: "inject-script", + name: "Inject script", + flag: "inject-script-flag", + code: "", + metadata: { grant: [] }, + resource: {}, + value: {}, + executionHandle: "page-binding", + executionEnvTag: "it", + executionRunFlag: "page-run", + }, + ], + envInfo: { userAgentData: {}, sandboxMode: "raw", isIncognito: false }, + }); + + it("rejects pageLoad payloads with accessors before starting scripts", () => { + const { handlers, server } = makeServer(); + const executor = makeExecutor(); + const runtime = new ScriptRuntime("it", server, {} as Message, executor as unknown as ScriptExecutor, undefined); + runtime.init(); + + const pageLoad = makePageLoad(); + const scripts = pageLoad.scripts; + const getter = vi.fn(() => scripts); + Object.defineProperty(pageLoad, "scripts", { configurable: true, enumerable: true, get: getter }); + + handlers.get("pageLoad")?.(pageLoad); + + expect(getter).not.toHaveBeenCalled(); + expect(executor.startScripts).not.toHaveBeenCalled(); + }); + + it("rejects pageLoad payloads whose own-key enumeration throws", () => { + const { handlers, server } = makeServer(); + const executor = makeExecutor(); + const runtime = new ScriptRuntime("it", server, {} as Message, executor as unknown as ScriptExecutor, undefined); + runtime.init(); + + const pageLoad = new Proxy(makePageLoad(), { + ownKeys() { + throw new Error("hostile enumeration"); + }, + }); + + handlers.get("pageLoad")?.(pageLoad); + + expect(executor.startScripts).not.toHaveBeenCalled(); + }); + + it("rejects callback DTO accessors before entering the script context", () => { + const { handlers, server } = makeServer(); + const executor = makeExecutor(); + const runtime = new ScriptRuntime("it", server, {} as Message, executor as unknown as ScriptExecutor, undefined); + runtime.init(); + + const eventData = { uuid: "script", event: "menuClick", eventId: "1", data: { value: 1 } }; + const getter = vi.fn(() => eventData.data); + Object.defineProperty(eventData, "data", { configurable: true, enumerable: true, get: getter }); + + handlers.get("runtime/emitEvent")?.(eventData); + + expect(getter).not.toHaveBeenCalled(); + expect(executor.emitEvent).not.toHaveBeenCalled(); + }); + + it("rejects accessors nested in collection callback payloads", () => { + const { handlers, server } = makeServer(); + const executor = makeExecutor(); + const runtime = new ScriptRuntime("it", server, {} as Message, executor as unknown as ScriptExecutor, undefined); + runtime.init(); + + const getter = vi.fn(() => "secret"); + const nested = {} as Record; + Object.defineProperty(nested, "value", { configurable: true, enumerable: true, get: getter }); + const eventData = { + uuid: "script", + event: "menuClick", + eventId: "1", + data: new Map([["nested", nested]]), + }; + + handlers.get("runtime/emitEvent")?.(eventData); + + expect(getter).not.toHaveBeenCalled(); + expect(executor.emitEvent).not.toHaveBeenCalled(); + }); + + it("rejects accessors nested in set callback payloads", () => { + const { handlers, server } = makeServer(); + const executor = makeExecutor(); + const runtime = new ScriptRuntime("it", server, {} as Message, executor as unknown as ScriptExecutor, undefined); + runtime.init(); + + const getter = vi.fn(() => "secret"); + const nested = {} as Record; + Object.defineProperty(nested, "value", { configurable: true, enumerable: true, get: getter }); + const eventData = { + uuid: "script", + event: "menuClick", + eventId: "1", + data: new Set([nested]), + }; + + handlers.get("runtime/emitEvent")?.(eventData); + + expect(getter).not.toHaveBeenCalled(); + expect(executor.emitEvent).not.toHaveBeenCalled(); + }); + + it("clones valid callback and value-update DTOs before dispatch", () => { + const { handlers, server } = makeServer(); + const executor = makeExecutor(); + const runtime = new ScriptRuntime("it", server, {} as Message, executor as unknown as ScriptExecutor, undefined); + runtime.init(); + + const eventData = { uuid: "script", event: "menuClick", eventId: "1", data: { value: 1 } }; + const valueData = { + uuid: "script", + storageName: "script", + entries: [["key", [0, { value: 1 }], [2]]], + sender: { runFlag: "run", tabId: 3 }, + valueUpdated: true, + }; + + handlers.get("runtime/emitEvent")?.(eventData); + handlers.get("runtime/valueUpdate")?.(valueData); + + expect(executor.emitEvent).toHaveBeenCalledOnce(); + expect(executor.valueUpdate).toHaveBeenCalledOnce(); + expect(executor.emitEvent.mock.calls[0][0]).not.toBe(eventData); + expect(executor.valueUpdate.mock.calls[0][0]).not.toBe(valueData); + expect(executor.emitEvent.mock.calls[0][0]).toEqual(eventData); + expect(executor.valueUpdate.mock.calls[0][0]).toEqual(valueData); + }); + + it("rejects inject scripts without the current execution binding", () => { + const { handlers, server } = makeServer(); + const executor = makeExecutor(); + const runtime = new ScriptRuntime("it", server, {} as Message, executor as unknown as ScriptExecutor, undefined); + runtime.init(); + + const pageLoad = makePageLoad(); + Object.defineProperty(pageLoad.scripts[0], "executionHandle", { configurable: true, value: undefined }); + + handlers.get("pageLoad")?.(pageLoad); + + expect(executor.startScripts).not.toHaveBeenCalled(); + }); + + it("starts scripts only after validating and cloning the execution binding", () => { + const { handlers, server } = makeServer(); + const executor = makeExecutor(); + const runtime = new ScriptRuntime("it", server, {} as Message, executor as unknown as ScriptExecutor, undefined); + runtime.init(); + + const pageLoad = makePageLoad(); + handlers.get("pageLoad")?.(pageLoad); + + expect(executor.startScripts).toHaveBeenCalledOnce(); + const [scripts, envInfo] = executor.startScripts.mock.calls[0]; + expect(scripts).not.toBe(pageLoad.scripts); + expect(scripts[0]).toMatchObject({ + executionHandle: "page-binding", + executionEnvTag: "it", + executionRunFlag: "page-run", + }); + expect(envInfo).toEqual(pageLoad.envInfo); + }); + + it("does not execute the same native bootstrap twice after a USER_SCRIPT reconnect", () => { + const { handlers, server } = makeServer(); + const executor = makeExecutor(); + const runtime = new ScriptRuntime("it", server, {} as Message, executor as unknown as ScriptExecutor, undefined); + runtime.init(); + + const first = makePageLoad(); + const replay = makePageLoad(); + handlers.get("pageLoad")?.(first); + handlers.get("pageLoad")?.(replay); + + expect(executor.startScripts).toHaveBeenCalledOnce(); + }); + + it("keeps the content pageLoad path on the native payload", () => { + const { handlers, server } = makeServer(); + const executor = makeExecutor(); + const runtime = new ScriptRuntime("ct", server, {} as Message, executor as unknown as ScriptExecutor, undefined); + runtime.init(); + + const pageLoad = { scripts: [], envInfo: { userAgentData: {}, sandboxMode: "raw", isIncognito: false } }; + handlers.get("pageLoad")?.(pageLoad); + + expect(executor.startScripts).toHaveBeenCalledWith(pageLoad.scripts, pageLoad.envInfo); + }); + + it("rejects content pageLoad accessors before starting scripts", () => { + const { handlers, server } = makeServer(); + const executor = makeExecutor(); + const runtime = new ScriptRuntime("ct", server, {} as Message, executor as unknown as ScriptExecutor, undefined); + runtime.init(); + + const pageLoad = { + scripts: [ + { + uuid: "content-script", + name: "Content script", + flag: "content-script-flag", + code: "", + metadata: { grant: [] }, + resource: {}, + value: {}, + executionHandle: "content-binding", + executionEnvTag: "ct", + executionRunFlag: "content-run", + }, + ], + envInfo: { userAgentData: {}, sandboxMode: "raw", isIncognito: false }, + }; + const scripts = pageLoad.scripts; + const getter = vi.fn(() => scripts); + Object.defineProperty(pageLoad, "scripts", { configurable: true, enumerable: true, get: getter }); + + handlers.get("pageLoad")?.(pageLoad); + + expect(getter).not.toHaveBeenCalled(); + expect(executor.startScripts).not.toHaveBeenCalled(); + }); + + it("rejects content callback DTO accessors before dispatch", () => { + const { handlers, server } = makeServer(); + const executor = makeExecutor(); + const runtime = new ScriptRuntime("ct", server, {} as Message, executor as unknown as ScriptExecutor, undefined); + runtime.init(); + + const eventData = { uuid: "script", event: "menuClick", eventId: "1", data: { value: 1 } }; + const eventPayload = eventData.data; + const getter = vi.fn(() => eventPayload); + Object.defineProperty(eventData, "data", { configurable: true, enumerable: true, get: getter }); + + handlers.get("runtime/emitEvent")?.(eventData); + + expect(getter).not.toHaveBeenCalled(); + expect(executor.emitEvent).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/service/content/script_runtime.ts b/src/app/service/content/script_runtime.ts index 817dd2a23..72faa7d27 100644 --- a/src/app/service/content/script_runtime.ts +++ b/src/app/service/content/script_runtime.ts @@ -8,8 +8,212 @@ import type { ScriptEnvTag } from "@Packages/message/consts"; import { onInjectPageLoaded } from "./external"; import type { CustomEventMessage } from "@Packages/message/custom_event_message"; import { type TExtensionEnv } from "../extension/extension_env"; +import { RuntimeClient } from "../service_worker/client"; +import { customClone, Native } from "./global"; +import { setPageRpcExtensionOrigin, type ExtensionOrigin } from "./page_rpc"; + +const MAX_EXECUTION_TOKEN_LENGTH = 256; + +// Inject pageLoad crosses the page-visible bridge, so only a cloned DTO with a current broker binding may reach the executor. +const isRecord = (value: unknown): value is Record => { + if (value === null || typeof value !== "object" || Native.arrayIsArray(value)) return false; + const prototype = Native.objectGetPrototypeOf(value); + return prototype === null || Native.objectGetPrototypeOf(prototype) === null; +}; + +const isStringArray = (value: unknown): value is string[] => { + if (!Native.arrayIsArray(value)) return false; + for (let index = 0; index < value.length; index += 1) { + if (typeof value[index] !== "string") return false; + } + return true; +}; + +const isExecutionToken = (value: unknown): value is string => + typeof value === "string" && value.length > 0 && value.length <= MAX_EXECUTION_TOKEN_LENGTH; + +const isPageResourceMap = (value: unknown): boolean => { + if (!isRecord(value)) return false; + const keys = Native.objectKeys(value); + for (let index = 0; index < keys.length; index += 1) { + const key = keys[index]; + const resource = value[key]; + if (!isRecord(resource) || typeof resource.content !== "string" || typeof resource.contentType !== "string") { + return false; + } + if (resource.base64 !== undefined && typeof resource.base64 !== "string") return false; + } + return true; +}; + +const isPageScriptInfo = (value: unknown, envTag: "it" | "ct"): value is TScriptInfo => { + if (!isRecord(value)) return false; + if ( + typeof value.uuid !== "string" || + value.uuid.length === 0 || + typeof value.name !== "string" || + typeof value.flag !== "string" || + value.flag.length === 0 || + typeof value.code !== "string" || + !isRecord(value.metadata) || + !isRecord(value.value) || + !isPageResourceMap(value.resource) || + (value.requireCssResource !== undefined && !isPageResourceMap(value.requireCssResource)) || + !isExecutionToken(value.executionHandle) || + value.executionEnvTag !== envTag || + !isExecutionToken(value.executionRunFlag) + ) { + return false; + } + const metadataKeys = Native.objectKeys(value.metadata); + for (let index = 0; index < metadataKeys.length; index += 1) { + const key = metadataKeys[index]; + if (!isStringArray(value.metadata[key])) return false; + } + return true; +}; + +const hasOnlyKeys = (value: Record, required: readonly string[], optional: readonly string[] = []) => { + const keys = Native.objectKeys(value); + for (let index = 0; index < required.length; index += 1) { + if (!Native.objectHasOwn(value, required[index])) return false; + } + for (let index = 0; index < keys.length; index += 1) { + const key = keys[index]; + let known = false; + for (let keyIndex = 0; keyIndex < required.length; keyIndex += 1) { + if (required[keyIndex] === key) { + known = true; + break; + } + } + if (!known) { + for (let keyIndex = 0; keyIndex < optional.length; keyIndex += 1) { + if (optional[keyIndex] === key) { + known = true; + break; + } + } + } + if (!known) return false; + } + return true; +}; + +const isEncodedValue = (value: unknown): boolean => { + if (!Native.arrayIsArray(value)) return false; + if (value.length === 1) return value[0] === 1 || value[0] === 2; + return value.length === 2 && value[0] === 0; +}; + +const cloneInjectValueUpdate = (data: unknown): ValueUpdateDataEncoded | undefined => { + const cloned = customClone(data); + if ( + !isRecord(cloned) || + !hasOnlyKeys(cloned, ["entries", "uuid", "storageName", "sender", "valueUpdated"], ["id"]) || + (cloned.id !== undefined && (typeof cloned.id !== "string" || cloned.id.length > MAX_EXECUTION_TOKEN_LENGTH)) || + typeof cloned.uuid !== "string" || + typeof cloned.storageName !== "string" || + typeof cloned.valueUpdated !== "boolean" || + !isRecord(cloned.sender) || + !hasOnlyKeys(cloned.sender, ["runFlag"], ["tabId"]) || + typeof cloned.sender.runFlag !== "string" || + (cloned.sender.tabId !== undefined && typeof cloned.sender.tabId !== "number") || + !Native.arrayIsArray(cloned.entries) + ) { + return undefined; + } + for (let index = 0; index < cloned.entries.length; index += 1) { + const entry = cloned.entries[index]; + if ( + !Native.arrayIsArray(entry) || + entry.length !== 3 || + typeof entry[0] !== "string" || + !isEncodedValue(entry[1]) || + !isEncodedValue(entry[2]) + ) { + return undefined; + } + } + return cloned as unknown as ValueUpdateDataEncoded; +}; + +const cloneInjectEmitEvent = (data: unknown): EmitEventRequest | undefined => { + const cloned = customClone(data); + if ( + !isRecord(cloned) || + !hasOnlyKeys(cloned, ["uuid", "event", "eventId"], ["data"]) || + typeof cloned.uuid !== "string" || + typeof cloned.event !== "string" || + typeof cloned.eventId !== "string" + ) { + return undefined; + } + return cloned as unknown as EmitEventRequest; +}; + +type InjectPageLoadData = { + scripts: TScriptInfo[]; + envInfo: GMInfoEnv; + reconnectToken?: string; +}; + +type PageLoadData = InjectPageLoadData & { + extensionOrigin?: ExtensionOrigin; +}; + +const isExtensionOrigin = (value: unknown): value is ExtensionOrigin => { + if (!isRecord(value) || !hasOnlyKeys(value, ["protocol", "hostname", "port"])) return false; + return ( + (value.protocol === "chrome-extension:" || value.protocol === "moz-extension:") && + typeof value.hostname === "string" && + value.hostname.length > 0 && + typeof value.port === "string" + ); +}; + +const clonePageLoad = ( + data: unknown, + envTag: "it" | "ct", + allowEmpty: boolean, + allowExtensionOrigin: boolean +): PageLoadData | undefined => { + const cloned = customClone(data); + if ( + !isRecord(cloned) || + !hasOnlyKeys( + cloned, + ["scripts", "envInfo"], + ["reconnectToken", ...(allowExtensionOrigin ? ["extensionOrigin"] : [])] + ) || + (cloned.reconnectToken !== undefined && !isExecutionToken(cloned.reconnectToken)) + ) + return undefined; + if (!Native.objectHasOwn(cloned, "scripts") || !Native.objectHasOwn(cloned, "envInfo")) return undefined; + if (!Native.arrayIsArray(cloned.scripts) || (!allowEmpty && cloned.scripts.length === 0)) return undefined; + for (let index = 0; index < cloned.scripts.length; index += 1) { + if (!isPageScriptInfo(cloned.scripts[index], envTag)) return undefined; + } + if (!isRecord(cloned.envInfo)) return undefined; + if (cloned.envInfo.sandboxMode !== "raw" || typeof cloned.envInfo.isIncognito !== "boolean") { + return undefined; + } + if (cloned.envInfo.userAgentData !== undefined && !isRecord(cloned.envInfo.userAgentData)) return undefined; + if (cloned.extensionOrigin !== undefined && !isExtensionOrigin(cloned.extensionOrigin)) return undefined; + return { + scripts: cloned.scripts, + envInfo: cloned.envInfo as unknown as GMInfoEnv, + reconnectToken: cloned.reconnectToken as string | undefined, + extensionOrigin: cloned.extensionOrigin as ExtensionOrigin | undefined, + }; +}; + +const cloneInjectPageLoad = (data: unknown): InjectPageLoadData | undefined => clonePageLoad(data, "it", false, false); export class ScriptRuntime { + // USER_SCRIPT 重连会重放同一份 bootstrap;按服务端签发的句柄去重,导航换文档时句柄也会随之更换。 + private readonly startedScriptKeys = new Native.Set(); + constructor( private readonly scripEnvTag: ScriptEnvTag, private readonly server: Server, @@ -19,11 +223,24 @@ export class ScriptRuntime { ) {} // content环境的特殊初始化 - contentInit() { - this.server.on("runtime/addElement", (data: { params: [number | null, string, Record | null] }) => { - const [parentNodeId, tagName, tmpAttr] = data.params; + contentInit(domServer: Server = this.server, domMsg: CustomEventMessage = this.msg as CustomEventMessage) { + domServer.on("runtime/addElement", (data: { params: [number | null, string, Record | null] }) => { + const safeData = customClone(data) as typeof data | undefined; + if (!safeData || !Array.isArray(safeData.params) || safeData.params.length !== 3) return undefined; + const [parentNodeId, tagName, tmpAttr] = safeData.params; + + // 此请求来自页面事件,只接受可验证的节点编号、标签名和扁平属性,避免把对象行为带入 DOM 操作。 + if ( + (parentNodeId !== null && (!Number.isInteger(parentNodeId) || parentNodeId <= 0)) || + typeof tagName !== "string" || + tagName.length === 0 || + tagName.length > 128 || + (tmpAttr !== null && (typeof tmpAttr !== "object" || Array.isArray(tmpAttr))) + ) { + return undefined; + } - const msg = this.msg as CustomEventMessage; + const msg = domMsg; // 取回 parentNode(如果存在) let parentNode: Node | undefined; @@ -33,7 +250,16 @@ export class ScriptRuntime { // 创建元素并设置属性 const el = document.createElement(tagName); - const attr = tmpAttr ? { ...tmpAttr } : {}; + const attr: Record = Object.create(null); + if (tmpAttr) { + for (const key of Object.keys(tmpAttr)) { + const descriptor = Object.getOwnPropertyDescriptor(tmpAttr, key); + if (!descriptor || !("value" in descriptor)) return undefined; + const value = descriptor.value; + if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") return undefined; + attr[key] = String(value); + } + } let textContent = ""; if (attr.textContent) { textContent = attr.textContent; @@ -54,19 +280,30 @@ export class ScriptRuntime { }); } + async loadPage(beforeStart?: (scripts: TScriptInfo[]) => void | Promise) { + const client = new RuntimeClient(this.msg); + const result = await client.pageLoad(this.scripEnvTag); + if (!result.ok) return; + const scripts = this.scripEnvTag === "ct" ? result.contentScriptList : result.injectScriptList; + if (scripts.length) { + await beforeStart?.(scripts); + this.startScripts(scripts, result.envInfo); + } + } + init() { this.server.on("runtime/emitEvent", (data: EmitEventRequest) => { - // 转发给脚本 - this.scriptExecutor.emitEvent(data); + this.receiveEmitEvent(data); }); this.server.on("runtime/valueUpdate", (data: ValueUpdateDataEncoded) => { - this.scriptExecutor.valueUpdate(data); + this.receiveValueUpdate(data); }); this.server.on("pageLoad", (data: { scripts: TScriptInfo[]; envInfo: GMInfoEnv }) => { - // 监听事件 - this.startScripts(data.scripts, data.envInfo); + this.receivePageLoad(data); }); + // Older MAIN worlds may receive a forward-compatible native bootstrap token but cannot open a runtime port. + this.server.on("bootstrap", () => undefined); // 用于 early-start 的扩充参数 const { inIncognitoContext } = this.extensionEnv || {}; @@ -78,10 +315,48 @@ export class ScriptRuntime { } startScripts(scripts: TScriptInfo[], envInfo: GMInfoEnv) { - this.scriptExecutor.startScripts(scripts, envInfo); + if (scripts.length === 0) { + this.scriptExecutor.startScripts(scripts, envInfo); + return; + } + const freshScripts: TScriptInfo[] = []; + for (let index = 0; index < scripts.length; index += 1) { + const script = scripts[index]; + const key = script.executionHandle || `${this.scripEnvTag}:${script.uuid}`; + if (this.startedScriptKeys.has(key)) continue; + this.startedScriptKeys.add(key); + freshScripts.push(script); + } + if (freshScripts.length > 0) this.scriptExecutor.startScripts(freshScripts, envInfo); + } + + receivePageLoad(data: unknown): string | undefined { + if (this.scripEnvTag === "it") { + const safeData = cloneInjectPageLoad(data); + if (!safeData) return undefined; + this.startScripts(safeData.scripts, safeData.envInfo); + return safeData.reconnectToken; + } + const safeData = clonePageLoad(data, "ct", true, true); + if (!safeData) return undefined; + setPageRpcExtensionOrigin(safeData.extensionOrigin); + this.startScripts(safeData.scripts, safeData.envInfo); + return safeData.reconnectToken; + } + + receiveEmitEvent(data: unknown): void { + const safeData = cloneInjectEmitEvent(data); + if (!safeData) return; + this.scriptExecutor.emitEvent(safeData); + } + + receiveValueUpdate(data: unknown): void { + const safeData = cloneInjectValueUpdate(data); + if (!safeData) return; + this.scriptExecutor.valueUpdate(safeData); } - externalMessage() { - onInjectPageLoaded(this.msg); + externalMessage(messagePrefix = "scripting", message: Message = this.msg) { + onInjectPageLoaded(message, messagePrefix); } } diff --git a/src/app/service/content/scripting.test.ts b/src/app/service/content/scripting.test.ts new file mode 100644 index 000000000..2b75f8fe1 --- /dev/null +++ b/src/app/service/content/scripting.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it, vi, afterEach } from "vitest"; +import type { MessageSend } from "@Packages/message/types"; +import type { TClientPageLoadInfo, TScriptInfo } from "@App/app/repo/scripts"; +import type { Server } from "@Packages/message/server"; +import { RuntimeClient } from "../service_worker/client"; +import ScriptingRuntime, { serializeDocumentResponse } from "./scripting"; + +const makeSender = () => ({ + sendMessage: vi.fn().mockResolvedValue({ code: 0, data: undefined }), + connect: vi.fn(), +}); + +const makeScript = (uuid: string): TScriptInfo => + ({ + uuid, + metadata: { grant: ["GM_getValue"] }, + resource: {}, + value: {}, + flag: `${uuid}-flag`, + code: "", + }) as unknown as TScriptInfo; + +describe("ScriptingRuntime page bootstrap", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("requests the combined page list so USER_SCRIPT content receives its bootstrap", async () => { + const pageLoad = vi.spyOn(RuntimeClient.prototype, "pageLoad").mockResolvedValue({ + ok: true, + injectScriptList: [makeScript("inject-script")], + contentScriptList: [makeScript("content-script")], + envInfo: { userAgentData: {}, sandboxMode: "raw", isIncognito: false }, + userScriptBootstrapToken: "bootstrap-token", + userScriptInjectBootstrapToken: "inject-bootstrap-token", + } as TClientPageLoadInfo); + const senderToExt = makeSender(); + const senderToContent = makeSender(); + const senderToInject = makeSender(); + const handlers = new Map unknown>(); + const server = { + on: vi.fn((action: string, handler: (data: unknown) => unknown) => handlers.set(action, handler)), + }; + const extServer = { on: vi.fn() }; + const storageLocal = chrome.storage.local as unknown as { + onChanged?: { addListener: (listener: (changes: unknown) => void) => void }; + }; + const originalOnChanged = storageLocal.onChanged; + storageLocal.onChanged = { addListener: vi.fn() }; + const runtime = new ScriptingRuntime( + extServer as unknown as Server, + server as unknown as Server, + senderToExt as unknown as MessageSend, + senderToContent as any, + senderToInject as any + ); + + try { + runtime.init(); + runtime.pageLoad(); + await Promise.resolve(); + await Promise.resolve(); + + expect(pageLoad).toHaveBeenCalledWith("it"); + expect(senderToContent.sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ + action: "content/pageLoad", + data: expect.objectContaining({ + bootstrapToken: "bootstrap-token", + extensionOrigin: { + protocol: "chrome-extension:", + hostname: chrome.runtime.id, + port: "", + }, + }), + }) + ); + expect(senderToInject.sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ + action: "inject/bootstrap", + data: { bootstrapToken: "inject-bootstrap-token" }, + }) + ); + expect(senderToInject.sendMessage).not.toHaveBeenCalledWith( + expect.objectContaining({ action: "inject/pageLoad" }) + ); + + handlers.get("pageLoadFallback")?.({}); + await Promise.resolve(); + expect(senderToInject.sendMessage).toHaveBeenCalledWith(expect.objectContaining({ action: "inject/pageLoad" })); + } finally { + storageLocal.onChanged = originalOnChanged; + } + }); + + it("serializes CAT_fetchDocument responses instead of returning a live document reference", () => { + const document = new DOMParser().parseFromString("
ok
", "text/html"); + expect(serializeDocumentResponse(document, "text/html")).toEqual({ + text: expect.stringContaining("
ok
"), + contentType: "text/html", + }); + }); +}); diff --git a/src/app/service/content/scripting.ts b/src/app/service/content/scripting.ts index 7286d82b0..4baddadb8 100644 --- a/src/app/service/content/scripting.ts +++ b/src/app/service/content/scripting.ts @@ -2,11 +2,15 @@ import { Client, sendMessage } from "@Packages/message/client"; import { type CustomEventMessage } from "@Packages/message/custom_event_message"; import { forwardMessage, type Server } from "@Packages/message/server"; import type { MessageSend } from "@Packages/message/types"; +import type { TScriptInfo } from "@App/app/repo/scripts"; +import type { SerializedDocumentResponse } from "./gm_api/gm_xhr"; import { RuntimeClient } from "../service_worker/client"; import { getStorageName, makeBlobURL } from "@App/pkg/utils/utils"; import type { Logger } from "@App/app/repo/logger"; import LoggerCore from "@App/app/logger/core"; -import type { ValueUpdateDataEncoded } from "./types"; +import type { GMInfoEnv, ValueUpdateDataEncoded } from "./types"; +import { getExtensionOrigin, getPageRpcAllowedAPIs, PageRpcRegistry, validatePageGMRequest } from "./page_rpc"; +import { uuidv4 } from "@App/pkg/utils/uuid"; const PageOrContent = { PAGE: 1, @@ -16,6 +20,18 @@ const PageOrContent = { type PageOrContent = ValueOf; +export const serializeDocumentResponse = ( + response: Document | null, + contentType: string +): SerializedDocumentResponse | undefined => { + if (!response) return undefined; + try { + return { text: new XMLSerializer().serializeToString(response), contentType }; + } catch { + return undefined; + } +}; + // For Firefox, StorageArea.setAccessLevel is not implemented. // See https://bugzilla.mozilla.org/show_bug.cgi?id=1724754 // const deliveryStorage = isFirefox() ? chrome.storage.local : chrome.storage.session; @@ -23,7 +39,12 @@ const deliveryStorage = chrome.storage.local; // 日后再处理 // scripting页的处理 export default class ScriptingRuntime { - private activeStorageNames: Map | null = null; + // 只记录当前页面仍有脚本使用的 storageName,storage 广播不应唤醒无关脚本。 + private activeStorageNames = new Map(); + // MAIN world 的完整脚本资料只在原生通道失败时才走页面桥;原生成功时由 service worker 直接投递。 + private fallbackInjectPageLoad?: { scripts: TScriptInfo[]; envInfo: GMInfoEnv }; + // 页面请求必须先在此注册句柄,再由 transform 解析为隔离 broker 可接受的身份。 + private readonly pageRpc = new PageRpcRegistry(); constructor( // 监听来自service_worker的消息 private readonly extServer: Server, @@ -34,7 +55,7 @@ export default class ScriptingRuntime { // 发送给 content的消息接口 private readonly senderToContent: CustomEventMessage, // 发送给inject的消息接口 - private readonly senderToInject: CustomEventMessage + private readonly senderToInject: MessageSend ) {} // 广播消息给 content 和 inject @@ -51,12 +72,18 @@ export default class ScriptingRuntime { init() { this.extServer.on("runtime/emitEvent", (data) => { - // 转发给inject和content - return this.broadcastToPage("runtime/emitEvent", data); + // USER_SCRIPT 的私有回调通过原生扩展端口投递。 + return this.broadcastToPage("runtime/emitEvent", data, PageOrContent.PAGE); }); this.extServer.on("runtime/valueUpdate", (data) => { - // 转发给inject和content - return this.broadcastToPage("runtime/valueUpdate", data); + // USER_SCRIPT 的私有值更新通过原生扩展端口投递。 + return this.broadcastToPage("runtime/valueUpdate", data, PageOrContent.PAGE); + }); + this.server.on("pageLoadFallback", () => { + const pageLoad = this.fallbackInjectPageLoad; + if (!pageLoad) return undefined; + this.fallbackInjectPageLoad = undefined; + return new Client(this.senderToInject, "inject").do("pageLoad", pageLoad); }); this.server.on("logger", (data: Logger) => { LoggerCore.logger().log(data.level, data.message, data.label); @@ -74,13 +101,10 @@ export default class ScriptingRuntime { const record = changes["valueUpdateDelivery"]; if (record?.newValue) { const sendData = (record.newValue as { sendData: ValueUpdateDataEncoded }).sendData; - const activeOn = - this.activeStorageNames === null - ? PageOrContent.PAGE_AND_CONTENT - : this.activeStorageNames.get(sendData.storageName); + const activeOn = this.activeStorageNames.get(sendData.storageName); if (activeOn) { // 转发给 content 和 inject - this.broadcastToPage("runtime/valueUpdate", sendData, activeOn); + this.broadcastToPage("runtime/valueUpdate", sendData, (activeOn & PageOrContent.PAGE) as PageOrContent); } } }); @@ -91,7 +115,7 @@ export default class ScriptingRuntime { "runtime/gmApi", this.server, this.senderToExt, - (data: { api: string; params: any; uuid: string }) => { + (data: { api: string; params: any }) => { // 拦截关注的 API,未命中则返回 false 交由默认转发处理 switch (data.api) { case "CAT_createBlobUrl": { @@ -111,18 +135,19 @@ export default class ScriptingRuntime { return false; // 继续转发到 SW } case "CAT_fetchDocument": { - const [url, isContent] = data.params; - // 根据来源选择不同的消息桥(content / inject) - let msg: CustomEventMessage | null = isContent ? this.senderToContent : this.senderToInject; return new Promise((resolve) => { const xhr = new XMLHttpRequest(); xhr.responseType = "document"; - xhr.open("GET", url); - xhr.onloadend = function () { - const nodeId = msg!.sendRelatedTarget(this.response); - resolve(nodeId); - msg = null; + xhr.open("GET", data.params[0]); + xhr.onloadend = () => { + resolve( + serializeDocumentResponse( + xhr.response as Document | null, + xhr.getResponseHeader("Content-Type") || "" + ) + ); }; + xhr.onerror = () => resolve(undefined); xhr.send(); }); } @@ -142,6 +167,21 @@ export default class ScriptingRuntime { break; } return false; + }, + (data) => { + // 所有来自页面的 GM RPC 都在转发前完成字段、句柄、授权和参数复制检查。 + const request = validatePageGMRequest(data, this.pageRpc); + return { + uuid: request.uuid, + api: request.api, + params: request.params, + runFlag: request.runFlag, + executionHandle: request.handle, + version: 1 as const, + requestId: request.requestId, + handle: request.handle, + envTag: request.envTag, + }; } ); } @@ -157,29 +197,44 @@ export default class ScriptingRuntime { }); } // 向service_worker请求脚本列表及环境信息 - client.pageLoad().then((o) => { + client.pageLoad("it").then((o) => { if (!o.ok) return; - const { injectScriptList, contentScriptList, envInfo } = o; + const { injectScriptList, envInfo, userScriptBootstrapToken, userScriptInjectBootstrapToken } = o; + // 每次页面加载都废弃旧句柄,避免无 documentId 的浏览器复用上一文档的授权。 + this.pageRpc.revokeAll(); + const prepareScripts = (scripts: typeof injectScriptList, envTag: "it" | "ct") => + scripts.map((script) => { + const allowedAPIs = getPageRpcAllowedAPIs(script.metadata.grant || []); + const executionRunFlag = script.executionRunFlag || uuidv4(); + const executionHandle = + script.executionHandle || + this.pageRpc.register(script.uuid, envTag, allowedAPIs, undefined, executionRunFlag); + if (script.executionHandle) { + // service worker 已签发的句柄要在本页 registry 中恢复,保持跨 context 身份一致。 + this.pageRpc.register(script.uuid, envTag, allowedAPIs, script.executionHandle, executionRunFlag); + } + return { ...script, executionHandle, executionEnvTag: envTag, executionRunFlag }; + }); + const preparedInjectScriptList = prepareScripts(injectScriptList, "it"); const pairs = {} as Record; - for (const script of injectScriptList) { + for (const script of preparedInjectScriptList) { pairs[getStorageName(script)] |= PageOrContent.PAGE; } - for (const script of contentScriptList) { - pairs[getStorageName(script)] |= PageOrContent.CONTENT; - } this.activeStorageNames = new Map(Object.entries(pairs)); - // 向页面 发送脚本列表及环境信息 - if (contentScriptList.length) { + if (typeof userScriptBootstrapToken === "string" && userScriptBootstrapToken.length > 0) { const contentClient = new Client(this.senderToContent, "content"); - // 根据@inject-into content过滤脚本 - contentClient.do("pageLoad", { scripts: contentScriptList, envInfo }); + contentClient.do("pageLoad", { + bootstrapToken: userScriptBootstrapToken, + envInfo, + extensionOrigin: getExtensionOrigin(), + }); } - if (injectScriptList.length) { + if (typeof userScriptInjectBootstrapToken === "string" && userScriptInjectBootstrapToken.length > 0) { + this.fallbackInjectPageLoad = { scripts: preparedInjectScriptList, envInfo }; const injectClient = new Client(this.senderToInject, "inject"); - // 根据@inject-into content过滤脚本 - injectClient.do("pageLoad", { scripts: injectScriptList, envInfo }); + injectClient.do("bootstrap", { bootstrapToken: userScriptInjectBootstrapToken }); } }); } diff --git a/src/app/service/content/types.ts b/src/app/service/content/types.ts index 30fe88e80..61d0f060f 100644 --- a/src/app/service/content/types.ts +++ b/src/app/service/content/types.ts @@ -1,6 +1,6 @@ import type { REncoded } from "@App/pkg/utils/message_value"; -export type ScriptFunc = (named: { [key: string]: any } | undefined, scriptName: string) => any; +export type ScriptFunc = (s: string, ctx: any, named: { [key: string]: any } | undefined, scriptName: string) => any; // exec_script.ts diff --git a/src/app/service/content/user_script_connection.test.ts b/src/app/service/content/user_script_connection.test.ts new file mode 100644 index 000000000..d68ef656d --- /dev/null +++ b/src/app/service/content/user_script_connection.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it, vi } from "vitest"; +import type { Message, MessageConnect, TMessage } from "@Packages/message/types"; +import { connectUserScriptChannel, requestUserScriptReconnect } from "./user_script_connection"; + +const makeConnection = (): MessageConnect => ({ + onMessage: vi.fn(), + sendMessage: vi.fn(), + disconnect: vi.fn(), + onDisconnect: vi.fn(), +}); + +describe("connectUserScriptChannel", () => { + it("enables the native listener before opening the USER_SCRIPT port", async () => { + const connection = makeConnection(); + const order: string[] = []; + const message = { + sendMessage: vi.fn(async (packet: TMessage) => { + order.push(`send:${(packet as { type?: string }).type}`); + return true; + }), + connect: vi.fn(async (packet: TMessage) => { + order.push(`connect:${packet.action}`); + return connection; + }), + } as unknown as Message; + + await connectUserScriptChannel(message, "bootstrap-token", vi.fn()); + + expect(order).toEqual(["send:userScripts.LISTEN_CONNECTIONS", "connect:serviceWorker/runtime/registerUserScript"]); + expect(connection.onMessage).toHaveBeenCalledOnce(); + expect(connection.sendMessage).toHaveBeenCalledWith({ action: "userScript/bootstrap" }); + }); + + it("uses the constrained extension transport for the MAIN world port", async () => { + const connection = makeConnection(); + const message = { + sendMessage: vi.fn().mockResolvedValue(true), + connect: vi.fn().mockResolvedValue(connection), + } as unknown as Message; + + await connectUserScriptChannel(message, "inject-bootstrap", vi.fn(), undefined, "MAIN"); + + expect(message.connect).toHaveBeenCalledWith({ + action: "serviceWorker/runtime/registerUserScript", + data: { world: "MAIN", bootstrapToken: "inject-bootstrap", transport: "extension" }, + }); + }); + + it("returns no channel when the browser cannot enable any runtime port", async () => { + const message = { + sendMessage: vi.fn().mockResolvedValue(false), + connect: vi.fn().mockRejectedValue(new Error("runtime.connect is unavailable")), + } as unknown as Message; + + await expect(connectUserScriptChannel(message, "bootstrap-token", vi.fn())).resolves.toBeUndefined(); + expect(message.connect).toHaveBeenCalledWith({ + action: "serviceWorker/runtime/registerUserScript", + data: { world: "USER_SCRIPT", bootstrapToken: "bootstrap-token", transport: "extension" }, + }); + }); + + it("uses a constrained extension-port fallback when dedicated listeners are unavailable", async () => { + const connection = makeConnection(); + const message = { + sendMessage: vi.fn().mockResolvedValue(false), + connect: vi.fn().mockResolvedValue(connection), + } as unknown as Message; + + await connectUserScriptChannel(message, "bootstrap-token", vi.fn()); + + expect(message.connect).toHaveBeenCalledWith({ + action: "serviceWorker/runtime/registerUserScript", + data: { world: "USER_SCRIPT", bootstrapToken: "bootstrap-token", transport: "extension" }, + }); + expect(connection.sendMessage).toHaveBeenCalledWith({ action: "userScript/bootstrap" }); + }); + + it("uses the extension fallback when listener capability probing has no response", async () => { + const connection = makeConnection(); + const message = { + sendMessage: vi.fn().mockResolvedValue(undefined), + connect: vi.fn().mockResolvedValue(connection), + } as unknown as Message; + + await connectUserScriptChannel(message, "bootstrap-token", vi.fn(), undefined, "MAIN"); + + expect(message.connect).toHaveBeenCalledWith({ + action: "serviceWorker/runtime/registerUserScript", + data: { world: "MAIN", bootstrapToken: "bootstrap-token", transport: "extension" }, + }); + }); + + it("reports remote disconnects so the caller can reconnect natively", async () => { + const connection = makeConnection(); + const onDisconnect = vi.fn(); + const message = { + sendMessage: vi.fn().mockResolvedValue(true), + connect: vi.fn().mockResolvedValue(connection), + } as unknown as Message; + + await connectUserScriptChannel(message, "bootstrap-token", vi.fn(), onDisconnect); + + expect(connection.onDisconnect).toHaveBeenCalledOnce(); + const disconnectHandler = (connection.onDisconnect as ReturnType).mock.calls[0][0] as ( + isSelfDisconnected: boolean + ) => void; + disconnectHandler(false); + expect(onDisconnect).toHaveBeenCalledWith(false); + }); + + it("accepts only a valid native reconnect token response", async () => { + const message = { + sendMessage: vi.fn().mockResolvedValue({ code: 0, data: { bootstrapToken: "next-token" } }), + } as unknown as Message; + + await expect(requestUserScriptReconnect(message, "current-token")).resolves.toBe("next-token"); + expect(message.sendMessage).toHaveBeenCalledWith({ + action: "serviceWorker/runtime/reconnectUserScript", + data: { reconnectToken: "current-token" }, + }); + + (message.sendMessage as ReturnType).mockResolvedValue({ code: 0, data: {} }); + await expect(requestUserScriptReconnect(message, "current-token")).resolves.toBeUndefined(); + }); +}); diff --git a/src/app/service/content/user_script_connection.ts b/src/app/service/content/user_script_connection.ts new file mode 100644 index 000000000..61bf2933a --- /dev/null +++ b/src/app/service/content/user_script_connection.ts @@ -0,0 +1,53 @@ +import type { Message, MessageConnect, TMessage } from "@Packages/message/types"; + +type UserScriptPacketHandler = (connection: MessageConnect, packet: TMessage) => void; +type UserScriptDisconnectHandler = (isSelfDisconnected: boolean) => void; + +type UserScriptReconnectResponse = { + code?: unknown; + data?: unknown; +}; +type UserScriptWorld = "USER_SCRIPT" | "MAIN"; + +/** + * 先让 service worker 开启 USER_SCRIPT 监听,再建立连接;浏览器可能立即投递端口, + * 并发执行两步会丢失首个连接。 + */ +export async function connectUserScriptChannel( + message: Message, + bootstrapToken: string, + onPacket: UserScriptPacketHandler, + onDisconnect?: UserScriptDisconnectHandler, + world: UserScriptWorld = "USER_SCRIPT" +): Promise { + const enabled = await message.sendMessage({ type: "userScripts.LISTEN_CONNECTIONS" } as unknown as TMessage); + const useExtensionFallback = world === "MAIN" || enabled !== true; + let connection: MessageConnect; + try { + // 缺少专用 USER_SCRIPT 监听器时仍使用扩展原生端口;服务端会用文档绑定的令牌限制该降级路径。 + connection = await message.connect({ + action: "serviceWorker/runtime/registerUserScript", + data: useExtensionFallback ? { world, bootstrapToken, transport: "extension" } : { world, bootstrapToken }, + }); + } catch (error) { + if (!useExtensionFallback) throw error; + return undefined; + } + connection.onMessage((packet) => onPacket(connection, packet)); + if (onDisconnect) connection.onDisconnect(onDisconnect); + connection.sendMessage({ action: "userScript/bootstrap" }); + return connection; +} + +export async function requestUserScriptReconnect( + message: Message, + reconnectToken: string +): Promise { + const response = await message.sendMessage({ + action: "serviceWorker/runtime/reconnectUserScript", + data: { reconnectToken }, + }); + if (response?.code !== 0 || response.data === null || typeof response.data !== "object") return undefined; + const token = (response.data as { bootstrapToken?: unknown }).bootstrapToken; + return typeof token === "string" && token.length > 0 && token.length <= 256 ? token : undefined; +} diff --git a/src/app/service/content/utils.test.ts b/src/app/service/content/utils.test.ts index 837728eec..3cac0cfba 100644 --- a/src/app/service/content/utils.test.ts +++ b/src/app/service/content/utils.test.ts @@ -3,16 +3,38 @@ import { compileScriptCode, compileScript, compileInjectScript, + compilePreInjectScript, compileScriptletCode, isScriptletUnwrap, addStyle, addStyleSheet, + preInjectScriptDocumentIdKey, + preInjectScriptDocumentUrlKey, + preInjectScriptInfoKey, trimScriptInfo, } from "./utils"; import type { SCMetadata, ScriptLoadInfo, ScriptRunResource } from "@App/app/repo/scripts"; import type { ScriptFunc } from "./types"; import { RuleType, type URLRuleEntry } from "@App/pkg/utils/url_matcher"; +const fnStrIntegrity = process.env.SC_RANDOM_FNKEY!; +const znRand = process.env.SC_ZN_RAND!; + +type GeneratedWindow = Record; + +function executeGeneratedScript( + code: string, + targetWindow: GeneratedWindow, + testPerformance: Pick = globalThis.performance +) { + const execute = new Function("window", "performance", "CustomEvent", code) as ( + window: GeneratedWindow, + performance: Pick, + customEvent: typeof CustomEvent + ) => void; + execute(targetWindow, testPerformance, globalThis.CustomEvent); +} + // 设置 console mock 来避免测试输出污染 vi.spyOn(console, "error").mockImplementation(() => {}); vi.spyOn(console, "log").mockImplementation(() => {}); @@ -60,7 +82,8 @@ describe("utils", () => { expect(result).toContain("try {"); expect(result).toContain("} catch (e) {"); expect(result).toContain("with(arguments[0]||this.$)"); - expect(result).toContain("return(async function(){"); + expect(result).toContain("this[arguments[0]='$$'+Date.now()/Math.random()]=async function(){"); + expect(result).toContain("return this[arguments[0]](...((delete this[arguments[0]]),[]));"); }); it.concurrent("应该处理自定义脚本代码参数", () => { @@ -481,6 +504,19 @@ describe("utils", () => { contentType: "text/plain", }); }); + + it("copies public values and metadata before crossing the page boundary", () => { + const script = createScript({ grant: ["GM_getValue"] }, []); + script.value = { nested: { count: 1 } }; + script.metadata.grant!.push("GM_setValue"); + + const trimmed = trimScriptInfo(script); + (trimmed.value.nested as { count: number }).count = 9; + trimmed.metadata.grant!.push("GM_deleteValue"); + + expect(script.value.nested).toEqual({ count: 1 }); + expect(script.metadata.grant).toEqual(["GM_getValue", "GM_setValue"]); + }); }); describe("compileScript", () => { @@ -495,7 +531,7 @@ describe("utils", () => { const code = "return arguments[0].value + arguments[1];"; const func: ScriptFunc = compileScript(code); - const result = func({ value: 10 }, "test-script"); + const result = func(fnStrIntegrity, {}, { value: 10 }, "test-script"); expect(result).toBe("10test-script"); }); @@ -511,8 +547,8 @@ describe("utils", () => { `; const func: ScriptFunc = compileScript(code); - const result1 = func({ value: 5, multiply: 3 }, "test"); - const result2 = func({ value: 5 }, "fallback"); + const result1 = func(fnStrIntegrity, {}, { value: 5, multiply: 3 }, "test"); + const result2 = func(fnStrIntegrity, {}, { value: 5 }, "fallback"); expect(result1).toBe(15); expect(result2).toBe("fallback"); @@ -526,7 +562,7 @@ describe("utils", () => { `; const func: ScriptFunc = compileScript(code); - const result = await func({ value: 5 }, "async-test"); + const result = await func(fnStrIntegrity, {}, { value: 5 }, "async-test"); expect(result).toBe(10); }); @@ -535,7 +571,13 @@ describe("utils", () => { const code = "throw new Error('Test error');"; const func: ScriptFunc = compileScript(code); - expect(() => func({}, "error-test")).toThrow("Test error"); + expect(() => func(fnStrIntegrity, {}, {}, "error-test")).toThrow("Test error"); + }); + + it.concurrent("完整性标记不匹配时不应执行脚本", () => { + const func: ScriptFunc = compileScript("throw new Error('should not run');"); + + expect(func("invalid", {}, {}, "blocked")).toBeUndefined(); }); }); @@ -559,13 +601,48 @@ describe("utils", () => { ...overrides, }); + it("生成的腳本包裝不依賴被 require 內容改寫的 Function.prototype 调用方法", async () => { + const script = createMockScript({ + code: "return this;", + resource: { + library: { + url: "https://example.com/library.js", + content: + "Function.prototype.call = Function.prototype.apply = Function.prototype.bind = () => { throw new Error('poisoned invocation'); };", + base64: "", + hash: { md5: "", sha1: "", sha256: "", sha384: "", sha512: "" }, + type: "require", + link: {}, + contentType: "text/javascript", + createtime: Date.now(), + }, + }, + metadata: { require: ["library"] }, + }); + const func = compileScript(compileScriptCode(script)); + const originalCall = Function.prototype.call; + const originalApply = Function.prototype.apply; + const originalBind = Function.prototype.bind; + let result: unknown; + try { + result = await func(fnStrIntegrity, globalThis, {}, script.name); + } finally { + Function.prototype.call = originalCall; + Function.prototype.apply = originalApply; + Function.prototype.bind = originalBind; + } + expect(result).toBe(globalThis); + }); + it.concurrent("应该生成基本的注入脚本代码", () => { const script = createMockScript(); const scriptCode = "console.log('injected');"; const result = compileInjectScript(script, scriptCode); - expect(result).toBe(`window['inject-test-flag'] = function(){console.log('injected');}`); + expect(result).toBe( + `((w, k, fn) => { const d = Object.getOwnPropertyDescriptor(w, k); if (d?.set) { w[k] = fn; } else { let mounted = true; Object.defineProperty(w, k, { configurable: false, enumerable: false, get() { if (!mounted) return undefined; mounted = false; return fn; } }); } })(window, 'inject-test-flag', ((k, y, fn) => { const f = (t, u, ...args) => { if (t === k) { u[y] = fn; return u[y](...((delete u[y]), args)) } }; Object.defineProperty(f, k, { value: true }); return f; })('${fnStrIntegrity}', '${znRand}' + Math.random(), function(){console.log('injected');}));` + ); }); it.concurrent("应该包含自动删除挂载函数的代码", () => { @@ -577,7 +654,7 @@ describe("utils", () => { expect(result).toContain(`try{delete window['inject-test-flag']}catch(e){}`); expect(result).toContain("console.log('with auto delete');"); expect(result).toBe( - `window['inject-test-flag'] = function(){try{delete window['inject-test-flag']}catch(e){}console.log('with auto delete');}` + `((w, k, fn) => { const d = Object.getOwnPropertyDescriptor(w, k); if (d?.set) { w[k] = fn; } else { let mounted = true; Object.defineProperty(w, k, { configurable: false, enumerable: false, get() { if (!mounted) return undefined; mounted = false; return fn; } }); } })(window, 'inject-test-flag', ((k, y, fn) => { const f = (t, u, ...args) => { if (t === k) { u[y] = fn; return u[y](...((delete u[y]), args)) } }; Object.defineProperty(f, k, { value: true }); return f; })('${fnStrIntegrity}', '${znRand}' + Math.random(), function(){try{delete window['inject-test-flag']}catch(e){}console.log('with auto delete');}));` ); }); @@ -588,7 +665,64 @@ describe("utils", () => { const result = compileInjectScript(script, scriptCode); expect(result).not.toContain("try{delete window"); - expect(result).toBe(`window['inject-test-flag'] = function(){console.log('without auto delete');}`); + expect(result).toBe( + `((w, k, fn) => { const d = Object.getOwnPropertyDescriptor(w, k); if (d?.set) { w[k] = fn; } else { let mounted = true; Object.defineProperty(w, k, { configurable: false, enumerable: false, get() { if (!mounted) return undefined; mounted = false; return fn; } }); } })(window, 'inject-test-flag', ((k, y, fn) => { const f = (t, u, ...args) => { if (t === k) { u[y] = fn; return u[y](...((delete u[y]), args)) } }; Object.defineProperty(f, k, { value: true }); return f; })('${fnStrIntegrity}', '${znRand}' + Math.random(), function(){console.log('without auto delete');}));` + ); + }); + + it.concurrent("生成的注入脚本应在运行时传递上下文和参数,并清理临时挂载", () => { + const script = createMockScript(); + const targetWindow: GeneratedWindow = {}; + const context = {}; + const named = { value: 42 }; + + executeGeneratedScript( + compileInjectScript( + script, + "return { thisValue: this, args: Array.from(arguments), contextKeys: Reflect.ownKeys(this) };" + ), + targetWindow + ); + + const generated = targetWindow[script.flag] as ScriptFunc; + expect(generated(fnStrIntegrity, context, named, script.name)).toEqual({ + thisValue: context, + args: [named, script.name], + contextKeys: [], + }); + expect(Reflect.ownKeys(context)).toEqual([]); + }); + + it.concurrent("生成的注入脚本应拒绝错误的完整性标记", () => { + const script = createMockScript(); + const targetWindow: GeneratedWindow = {}; + + executeGeneratedScript(compileInjectScript(script, "throw new Error('should not run');"), targetWindow); + + const generated = targetWindow[script.flag] as ScriptFunc; + expect(generated("invalid", {}, {}, "blocked")).toBeUndefined(); + }); + + it.concurrent("生成的注入脚本应按选项自动删除挂载函数", () => { + const script = createMockScript(); + const targetWindow: GeneratedWindow = {}; + + executeGeneratedScript(compileInjectScript(script, "return 'ran';", true), targetWindow); + + const generated = targetWindow[script.flag] as ScriptFunc; + expect(generated(fnStrIntegrity, {}, {}, script.name)).toBe("ran"); + expect(targetWindow[script.flag]).toBeUndefined(); + }); + + it.concurrent("生成的注入脚本默认应保留挂载函数", () => { + const script = createMockScript(); + const targetWindow: GeneratedWindow = {}; + + executeGeneratedScript(compileInjectScript(script, "return 'ran';"), targetWindow); + + const generated = targetWindow[script.flag] as ScriptFunc; + expect(generated(fnStrIntegrity, {}, {}, script.name)).toBe("ran"); + expect(targetWindow[script.flag]).toBeUndefined(); }); it.concurrent("应该处理复杂的脚本代码", () => { @@ -613,7 +747,143 @@ describe("utils", () => { const result = compileInjectScript(script, scriptCode); - expect(result).toContain(`window['flag-with-special-chars_123']`); + expect(result).toContain(`'flag-with-special-chars_123'`); + }); + }); + + describe("compilePreInjectScript", () => { + it.concurrent("生成的预注入脚本应可执行并发出脚本加载事件", () => { + const script: ScriptLoadInfo = { + uuid: "pre-inject-test-uuid", + name: "Pre Inject Test Script", + namespace: "pre.inject.test", + type: 1, + status: 1, + sort: 0, + runStatus: "complete", + createtime: Date.now(), + checktime: Date.now(), + code: "", + value: {}, + flag: "pre-inject-test-flag", + resource: {}, + metadata: {}, + originalMetadata: {}, + metadataStr: "", + userConfigStr: "", + }; + const targetWindow: GeneratedWindow = {}; + const testPerformance = { + dispatchEvent: vi.fn(() => false), + addEventListener: vi.fn(), + }; + + executeGeneratedScript( + compilePreInjectScript(script, "return { thisValue: this, args: Array.from(arguments) };"), + targetWindow, + testPerformance + ); + + const generated = targetWindow[script.flag] as ScriptFunc; + expect(Object.getOwnPropertyDescriptor(generated, preInjectScriptInfoKey)).toMatchObject({ + configurable: false, + writable: false, + value: expect.any(String), + }); + expect(Object.getOwnPropertyDescriptor(generated, preInjectScriptDocumentUrlKey)).toMatchObject({ + configurable: false, + writable: false, + value: window.location.href, + }); + expect(Object.getOwnPropertyDescriptor(generated, preInjectScriptDocumentIdKey)).toMatchObject({ + configurable: false, + writable: false, + value: expect.any(String), + }); + const context = {}; + const named = { value: 42 }; + expect(generated(fnStrIntegrity, context, named, script.name)).toEqual({ + thisValue: context, + args: [named, script.name], + }); + expect(Reflect.ownKeys(context)).toEqual([]); + expect(testPerformance.dispatchEvent).toHaveBeenCalledTimes(1); + expect(testPerformance.addEventListener).not.toHaveBeenCalled(); + }); + + it.concurrent("does not expose stored values or user config in the observable preload event", () => { + const script: ScriptLoadInfo = { + uuid: "pre-inject-private-uuid", + name: "Pre Inject Private Script", + namespace: "pre.inject.private", + type: 1, + status: 1, + sort: 0, + runStatus: "complete", + createtime: Date.now(), + checktime: Date.now(), + code: "", + value: { secret: "stored-value" }, + config: { private: { secret: { title: "Private", description: "", index: 0, default: "secret" } } }, + flag: "pre-inject-private-flag", + resource: {}, + metadata: {}, + originalMetadata: {}, + metadataStr: "", + userConfigStr: "", + }; + let detail: Record | undefined; + const testPerformance = { + dispatchEvent: vi.fn((event: Event) => { + detail = (event as CustomEvent).detail; + return false; + }), + addEventListener: vi.fn(), + }; + + executeGeneratedScript(compilePreInjectScript(script, "return undefined;"), {}, testPerformance); + + expect(detail).toEqual({ scriptFlag: script.flag }); + }); + + it.concurrent("does not mount a regex-excluded early-start script", () => { + const script: ScriptLoadInfo = { + uuid: "pre-inject-excluded-uuid", + name: "Pre Inject Excluded Script", + namespace: "pre.inject.excluded", + type: 1, + status: 1, + sort: 0, + runStatus: "complete", + createtime: Date.now(), + checktime: Date.now(), + code: "", + value: {}, + flag: "pre-inject-excluded-flag", + resource: {}, + metadata: {}, + originalMetadata: {}, + metadataStr: "", + userConfigStr: "", + scriptUrlPatterns: [ + { + ruleType: RuleType.REGEX_INCLUDE, + ruleContent: ["allowed", ""], + ruleTag: "include", + patternString: "/allowed/", + }, + ], + }; + const targetWindow: GeneratedWindow = {}; + const testPerformance = { + dispatchEvent: vi.fn(() => false), + addEventListener: vi.fn(), + }; + + executeGeneratedScript(compilePreInjectScript(script, "return undefined;"), targetWindow, testPerformance); + + expect(targetWindow[script.flag]).toBeUndefined(); + expect(testPerformance.dispatchEvent).not.toHaveBeenCalled(); }); }); diff --git a/src/app/service/content/utils.ts b/src/app/service/content/utils.ts index 64181c489..7caa009c8 100644 --- a/src/app/service/content/utils.ts +++ b/src/app/service/content/utils.ts @@ -7,6 +7,19 @@ import { ScriptEnvTag } from "@Packages/message/consts"; import { embeddedPatternCheckerString, type EmbeddedURLRuleEntry, type URLRuleEntry } from "@App/pkg/utils/url_matcher"; import { parseResourceDeclaration } from "@App/pkg/utils/resource"; import { getGrantCandidates } from "./gm_api/grant"; +import { customClone } from "./global"; + +const cloneTransportValue = (value: any) => { + // USER_SCRIPT 只能接收数据副本;共享 customClone 的 data-only 检查,避免 getter/Proxy 进入页面资料。 + return customClone(value); +}; + +// 与 rspack 注入的构建级密钥配对;页面只能看到包装函数,拿不到正确的调用标记。 +const lnStrIntegrity = process.env.SC_RANDOM_FNKEY; +const znRand = process.env.SC_ZN_RAND; +export const preInjectScriptInfoKey = `${lnStrIntegrity}:scriptInfo`; +export const preInjectScriptDocumentUrlKey = `${lnStrIntegrity}:documentUrl`; +export const preInjectScriptDocumentIdKey = `${lnStrIntegrity}:documentId`; export type CompileScriptCodeResource = { name: string; @@ -141,7 +154,7 @@ export function compileScriptCodeByResource(resource: CompileScriptCodeResource) // arguments = [named: Object, scriptName: string] // 使用sandboxContext时,arguments[0]为undefined, this.$则为一次性Proxy变量,用于全域拦截context // 非沙盒环境时,先读取 arguments[0],因此不会读取页面环境的 this.$ - // 在UserScripts API中,由于执行不是在物件导向里呼叫,使用arrow function的话会把this改变。须使用 .call(this) [ 或 .bind(this)() ] + // 临时方法调用保留 userscript 的 this,避免在页面解析可变的 call/apply/bind。 if (resource.isContextMenu) { // 脚本体整体延后到菜单回调里执行,它自己的 GM_registerMenuCommand 也随之推迟到点击后才注册 @@ -151,9 +164,9 @@ export function compileScriptCodeByResource(resource: CompileScriptCodeResource) const joinedCode = [ "with(arguments[0]||this.$){", `${preCode}`, - "return(async function(){", + "this[arguments[0]='$$'+Date.now()/Math.random()]=async function(){", `${code}`, - "}).call(this);}", + "};return this[arguments[0]](...((delete this[arguments[0]]),[]));}", ] .filter(Boolean) .join("\n"); @@ -161,9 +174,51 @@ export function compileScriptCodeByResource(resource: CompileScriptCodeResource) return `${codeBody}${sourceMapTo(`${resource.name}.user.js`)}\n`; } +const codeFunction = ( + code: string, + scriptInfoJSON?: string, + documentUrlExpression?: string, + documentIdExpression?: string +) => { + // 临时方法调用不依赖页面改写的 call、apply、bind;完整性标记也阻止页面直接调用包装器。 + const infoProperty = + scriptInfoJSON === undefined + ? "" + : ` Object.defineProperty(f, '${preInjectScriptInfoKey}', { value: ${JSON.stringify(scriptInfoJSON)} }); Object.defineProperty(f, 'name', { configurable: false, value: ${JSON.stringify(scriptInfoJSON)} });${ + documentUrlExpression === undefined + ? "" + : ` Object.defineProperty(f, '${preInjectScriptDocumentUrlKey}', { value: ${documentUrlExpression} });${ + documentIdExpression === undefined + ? "" + : ` Object.defineProperty(f, '${preInjectScriptDocumentIdKey}', { value: ${documentIdExpression} });` + }` + }`; + return `((k, y, fn) => { const f = (t, u, ...args) => { if (t === k) { u[y] = fn; return u[y](...((delete u[y]), args)) } }; Object.defineProperty(f, k, { value: true });${infoProperty} return f; })('${lnStrIntegrity}', '${znRand}' + Math.random(), function(){${code}})`; +}; + +// 有 setter 时沿用页面属性语义;否则用不可配置的一次性 getter,避免挂载函数被页面再次取走。 +const mountCodeFunction = ( + flag: string, + code: string, + scriptInfoJSON?: string, + documentUrlExpression?: string, + documentIdExpression?: string +) => + `((w, k, fn) => { const d = Object.getOwnPropertyDescriptor(w, k); if (d?.set) { w[k] = fn; } else { let mounted = true; Object.defineProperty(w, k, { configurable: false, enumerable: false, get() { if (!mounted) return undefined; mounted = false; return fn; } }); } })(window, '${flag}', ${codeFunction(code, scriptInfoJSON, documentUrlExpression, documentIdExpression)})`; + +const ZFunction = Function; + // 通过脚本代码编译脚本函数 export function compileScript(code: string): ScriptFunc { - return new Function(code); + const fn = new ZFunction(code); + const k = lnStrIntegrity; + const y = `${znRand}` + Math.random(); + return (t: any, u: any, ...args: any[]) => { + if (t === k) { + u[y] = fn; + return u[y](...(delete u[y], args)); + } + }; } /** @@ -186,7 +241,7 @@ export function compileInjectScriptByFlag( autoDeleteMountFunction: boolean = false ): string { const autoDeleteMountCode = autoDeleteMountFunction ? `try{delete window['${flag}']}catch(e){}` : ""; - return `window['${flag}'] = function(){${autoDeleteMountCode}${scriptCode}}`; + return `${mountCodeFunction(flag, `${autoDeleteMountCode}${scriptCode}`)};`; } /** @@ -216,7 +271,18 @@ export const trimScriptInfo = (script: ScriptLoadInfo): TScriptInfo => { } // --- 处理 resource --- // --- 处理 scriptInfo --- - const scriptInfo = { ...script, resource, requireCssResource, code: "" } as TScriptInfo; + const metadata = Object.fromEntries( + Object.entries(script.metadata).map(([key, values]) => [key, Array.isArray(values) ? [...values] : values]) + ); + const scriptInfo = { + ...script, + metadata, + value: cloneTransportValue(script.value) ?? {}, + config: script.config === undefined ? undefined : cloneTransportValue(script.config), + resource, + requireCssResource, + code: "", + } as TScriptInfo; // 删除其他不需要注入的 script 信息 delete scriptInfo.originalMetadata; delete scriptInfo.selfMetadata; @@ -232,10 +298,25 @@ export const trimScriptInfo = (script: ScriptLoadInfo): TScriptInfo => { delete scriptInfo.runStatus; // 前台脚本不用 delete scriptInfo.type; // 脚本类型总是普通脚本 delete scriptInfo.status; // 脚本状态总是启用 + delete scriptInfo.executionHandle; + delete scriptInfo.executionEnvTag; + // 这些绑定令牌只在隔离 broker 内有效,不能随脚本资料暴露给页面或 USER_SCRIPT。 + delete scriptInfo.executionRunFlag; // --- 处理 scriptInfo --- return scriptInfo; }; +/** + * 预注入事件会经过页面可观察的 performance 通道;不要把用户值或配置放进它的 detail。 + * 资源仍需在脚本最早执行时可用,后续 pageLoad 会补回权威的值与配置。 + */ +export const trimPreInjectScriptInfo = (script: ScriptLoadInfo): TScriptInfo => { + const scriptInfo = trimScriptInfo(script); + scriptInfo.value = {}; + scriptInfo.config = undefined; + return scriptInfo; +}; + /** * 将脚本函数编译为预注入脚本代码 */ @@ -247,16 +328,28 @@ export function compilePreInjectScript( const scriptEnvTag = isInjectIntoContent(script.metadata) ? ScriptEnvTag.content : ScriptEnvTag.inject; const eventNamePrefix = `evt${process.env.SC_RANDOM_KEY}.${scriptEnvTag}`; // 仅用于early-start初始化 const flag = `${script.flag}`; - const scriptInfo = trimScriptInfo(script); + const scriptInfo = trimPreInjectScriptInfo(script); const scriptInfoJSON = `${JSON.stringify(scriptInfo)}`; + const scriptUrlPatterns = script.scriptUrlPatterns?.map(({ ruleType, ruleContent }) => ({ ruleType, ruleContent })); + const urlCondition = scriptUrlPatterns + ? embeddedPatternCheckerString("location.href", JSON.stringify(scriptUrlPatterns)) + : "true"; const autoDeleteMountCode = autoDeleteMountFunction ? `try{delete window['${flag}']}catch(e){}` : ""; + const documentIdExpression = `(()=>{const k='${preInjectScriptDocumentIdKey}',d=Object.getOwnPropertyDescriptor(window,k);if(d&&'value'in d&&typeof d.value==='string')return d.value;const v=Date.now().toString(36)+'-'+Math.random().toString(36).slice(2);Object.defineProperty(window,k,{configurable:false,writable:false,value:v});return v})()`; const evScriptLoad = `${eventNamePrefix}${DefinedFlags.scriptLoadComplete}`; const evEnvLoad = `${eventNamePrefix}${DefinedFlags.envLoadComplete}`; - return `window['${flag}'] = function(){${autoDeleteMountCode}${scriptCode}}; -{ - let o = { cancelable: true, detail: { scriptFlag: '${flag}', scriptInfo: (${scriptInfoJSON}) } }, - c = typeof cloneInto === "function" ? cloneInto(o, performance) : o, - f = () => performance.dispatchEvent(new CustomEvent('${evScriptLoad}', c)), + return `{ + let mounted = false, + f = () => { + if (!(${urlCondition})) return false; + if (!mounted) { + ${mountCodeFunction(flag, `${autoDeleteMountCode}${scriptCode}`, scriptInfoJSON, "location.href", documentIdExpression)}; + mounted = true; + } + const o = { cancelable: true, detail: { scriptFlag: '${flag}' } }, + c = typeof cloneInto === "function" ? cloneInto(o, performance) : o; + return performance.dispatchEvent(new CustomEvent('${evScriptLoad}', c)); + }, needWait = f(); if (needWait) performance.addEventListener('${evEnvLoad}', f, { once: true }); } @@ -337,16 +430,32 @@ export const getScriptFlag = (uuid: string) => { // 监听属性设置 export function definePropertyListener(obj: any, prop: string, listener: (val: T) => void) { - if (obj[prop] !== undefined) { - listener(obj[prop]); - delete obj[prop]; + const sameProperty = (left: PropertyDescriptor | undefined, right: PropertyDescriptor | undefined) => + left?.configurable === right?.configurable && + left?.enumerable === right?.enumerable && + left?.value === right?.value && + left?.get === right?.get && + left?.set === right?.set; + const current = obj[prop]; + if (current !== undefined) { + const descriptor = Object.getOwnPropertyDescriptor(obj, prop); + listener(current); + // 页面可能在回调里替换属性;只有描述符仍是原来的才可以清理自身监听器。 + if (sameProperty(descriptor, Object.getOwnPropertyDescriptor(obj, prop)) && descriptor?.configurable) { + delete obj[prop]; + } return; } + const setter = (val: T) => { + listener(val); + const descriptor = Object.getOwnPropertyDescriptor(obj, prop); + // 不删除页面后来安装的 setter,只删除本函数仍拥有的那一个。 + if (descriptor?.configurable && descriptor.set === setter) { + delete obj[prop]; + } + }; Object.defineProperty(obj, prop, { configurable: true, - set: (val: any) => { - delete obj[prop]; // 删除 property setter - listener(val); - }, + set: setter, }); } diff --git a/src/app/service/service_worker/client.ts b/src/app/service/service_worker/client.ts index 07ea7b6fb..e585c7bf8 100644 --- a/src/app/service/service_worker/client.ts +++ b/src/app/service/service_worker/client.ts @@ -339,8 +339,9 @@ export class RuntimeClient extends Client { return this.do("stopScript", uuid); } - pageLoad(): Promise { - return this.doThrow("pageLoad"); + // envTag 让 service worker 区分主世界请求与 content-world bootstrap,分别签发/回收句柄。 + pageLoad(envTag?: "it" | "ct"): Promise { + return this.doThrow("pageLoad", envTag ? { envTag } : undefined); } /** bfcache 还原上报:只告知本页仍在运行,不请求脚本 */ diff --git a/src/app/service/service_worker/gm_api/gm_agent.ts b/src/app/service/service_worker/gm_api/gm_agent.ts index d6b5bc59b..d6dcfc89f 100644 --- a/src/app/service/service_worker/gm_api/gm_agent.ts +++ b/src/app/service/service_worker/gm_api/gm_agent.ts @@ -38,7 +38,7 @@ class GMAgentApi { if (!this.agentService) { throw new Error("AgentService is not available"); } - return this.agentService.handleConversationApi(request.params[0]); + return this.agentService.handleConversationApi({ ...request.params[0], scriptUuid: request.script.uuid }); } @PermissionVerify.API({ @@ -50,7 +50,10 @@ class GMAgentApi { if (!this.agentService) { throw new Error("AgentService is not available"); } - return this.agentService.handleConversationChatFromGmApi(request.params[0], sender); + return this.agentService.handleConversationChatFromGmApi( + { ...request.params[0], scriptUuid: request.script.uuid }, + sender + ); } @PermissionVerify.API({ @@ -62,7 +65,10 @@ class GMAgentApi { if (!this.agentService) { throw new Error("AgentService is not available"); } - return this.agentService.handleAttachToConversationFromGmApi(request.params[0], sender); + return this.agentService.handleAttachToConversationFromGmApi( + { ...request.params[0], scriptUuid: request.script.uuid }, + sender + ); } } diff --git a/src/app/service/service_worker/gm_api/gm_agent_dom.ts b/src/app/service/service_worker/gm_api/gm_agent_dom.ts index c9e087265..043f22247 100644 --- a/src/app/service/service_worker/gm_api/gm_agent_dom.ts +++ b/src/app/service/service_worker/gm_api/gm_agent_dom.ts @@ -36,7 +36,7 @@ class GMAgentDomApi { if (!this.agentService) { throw new Error("AgentService is not available"); } - return this.agentService.handleDomApi(request.params[0]); + return this.agentService.handleDomApi({ ...request.params[0], scriptUuid: request.script.uuid }); } } diff --git a/src/app/service/service_worker/gm_api/gm_agent_model.ts b/src/app/service/service_worker/gm_api/gm_agent_model.ts index 87eb9474b..ce4cc36de 100644 --- a/src/app/service/service_worker/gm_api/gm_agent_model.ts +++ b/src/app/service/service_worker/gm_api/gm_agent_model.ts @@ -38,7 +38,7 @@ class GMAgentModelApi { if (!this.agentService) { throw new Error("AgentService is not available"); } - return this.agentService.handleModelApi(request.params[0]); + return this.agentService.handleModelApi({ ...request.params[0], scriptUuid: request.script.uuid }); } } diff --git a/src/app/service/service_worker/gm_api/gm_agent_opfs.ts b/src/app/service/service_worker/gm_api/gm_agent_opfs.ts index 70d993688..9122bfeff 100644 --- a/src/app/service/service_worker/gm_api/gm_agent_opfs.ts +++ b/src/app/service/service_worker/gm_api/gm_agent_opfs.ts @@ -50,7 +50,7 @@ class GMAgentOPFSApi { if (!this.agentService) { throw new Error("AgentService is not available"); } - return this.agentService.handleOPFSApi(request.params[0], sender); + return this.agentService.handleOPFSApi({ ...request.params[0], scriptUuid: request.script.uuid }, sender); } } diff --git a/src/app/service/service_worker/gm_api/gm_agent_skills.ts b/src/app/service/service_worker/gm_api/gm_agent_skills.ts index 47b961793..90c8e33a0 100644 --- a/src/app/service/service_worker/gm_api/gm_agent_skills.ts +++ b/src/app/service/service_worker/gm_api/gm_agent_skills.ts @@ -50,7 +50,7 @@ class GMAgentSkillsApi { if (!this.agentService) { throw new Error("AgentService is not available"); } - return this.agentService.handleSkillsApi(request.params[0]); + return this.agentService.handleSkillsApi({ ...request.params[0], scriptUuid: request.script.uuid }); } } diff --git a/src/app/service/service_worker/gm_api/gm_agent_task.ts b/src/app/service/service_worker/gm_api/gm_agent_task.ts index 7de5a2880..1d942c05c 100644 --- a/src/app/service/service_worker/gm_api/gm_agent_task.ts +++ b/src/app/service/service_worker/gm_api/gm_agent_task.ts @@ -36,7 +36,7 @@ class GMAgentTaskApi { if (!this.agentService) { throw new Error("AgentService is not available"); } - return this.agentService.handleAgentTaskApi(request.params[0]); + return this.agentService.handleAgentTaskApi(request.params[0], request.script.uuid); } } 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 63b937997..dc1d00fff 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 @@ -9,6 +9,11 @@ import GMApi, { } from "./gm_api"; import { PermissionVerifyApiGet, type ConfirmParam } from "../permission_verify"; import type { GMApiRequest } from "../types"; +import GMAgentApi from "./gm_agent"; +import GMAgentDomApi from "./gm_agent_dom"; +import GMAgentModelApi from "./gm_agent_model"; +import GMAgentOPFSApi from "./gm_agent_opfs"; +import GMAgentSkillsApi from "./gm_agent_skills"; // 触发所有 GM API 装饰器注册(与 gm_api.ts 中的 import 保持同步) import "./gm_api"; @@ -124,6 +129,281 @@ describe.concurrent("GM API 注册完整性", () => { }); }); +describe("CAT.agent.conversation identity binding", () => { + it("overrides a forged payload owner with the authenticated script", async () => { + const handleConversationApi = vi.fn().mockResolvedValue(null); + const api = { agentService: { handleConversationApi } } as unknown as GMApi; + const request = { + params: [{ action: "get", id: "conv-1", scriptUuid: "forged" }], + script: { uuid: "script-authenticated" }, + } as unknown as GMApiRequest; + + await GMAgentApi.prototype.CAT_agentConversation.call(api, request, makeSender()); + + expect(handleConversationApi).toHaveBeenCalledWith({ + action: "get", + id: "conv-1", + scriptUuid: "script-authenticated", + }); + }); + + it("binds streaming chat and background attach to the authenticated script", async () => { + const handleConversationChatFromGmApi = vi.fn().mockResolvedValue(undefined); + const handleAttachToConversationFromGmApi = vi.fn().mockResolvedValue(undefined); + const api = { + agentService: { handleConversationChatFromGmApi, handleAttachToConversationFromGmApi }, + } as unknown as GMApi; + const chatRequest = { + params: [{ conversationId: "conv-1", message: "hi", scriptUuid: "forged" }], + script: { uuid: "script-authenticated" }, + } as unknown as GMApiRequest; + const attachRequest = { + params: [{ conversationId: "conv-1", generation: "gen-1", scriptUuid: "forged" }], + script: { uuid: "script-authenticated" }, + } as unknown as GMApiRequest; + const sender = makeSender(); + + await GMAgentApi.prototype.CAT_agentConversationChat.call(api, chatRequest, sender); + await GMAgentApi.prototype.CAT_agentAttachToConversation.call(api, attachRequest, sender); + + expect(handleConversationChatFromGmApi).toHaveBeenCalledWith( + expect.objectContaining({ conversationId: "conv-1", scriptUuid: "script-authenticated" }), + sender + ); + expect(handleAttachToConversationFromGmApi).toHaveBeenCalledWith( + expect.objectContaining({ conversationId: "conv-1", scriptUuid: "script-authenticated" }), + sender + ); + }); +}); + +describe("CAT agent identity binding", () => { + it("overrides forged nested scriptUuid values for every agent service boundary", async () => { + const handleDomApi = vi.fn().mockResolvedValue(undefined); + const handleModelApi = vi.fn().mockResolvedValue(undefined); + const handleSkillsApi = vi.fn().mockResolvedValue(undefined); + const handleOPFSApi = vi.fn().mockResolvedValue(undefined); + const api = { + agentService: { handleDomApi, handleModelApi, handleSkillsApi, handleOPFSApi }, + } as unknown as GMApi; + const script = { uuid: "script-authenticated" }; + const sender = makeSender(); + + await GMAgentDomApi.prototype.CAT_agentDom.call( + api, + { params: [{ action: "listTabs", scriptUuid: "forged" }], script } as unknown as GMApiRequest, + sender + ); + await GMAgentModelApi.prototype.CAT_agentModel.call( + api, + { params: [{ action: "list", scriptUuid: "forged" }], script } as unknown as GMApiRequest, + sender + ); + await GMAgentSkillsApi.prototype.CAT_agentSkills.call( + api, + { params: [{ action: "list", scriptUuid: "forged" }], script } as unknown as GMApiRequest, + sender + ); + await GMAgentOPFSApi.prototype.CAT_agentOPFS.call( + api, + { params: [{ action: "list", scriptUuid: "forged" }], script } as unknown as GMApiRequest, + sender + ); + + expect(handleDomApi).toHaveBeenCalledWith({ action: "listTabs", scriptUuid: "script-authenticated" }); + expect(handleModelApi).toHaveBeenCalledWith({ action: "list", scriptUuid: "script-authenticated" }); + expect(handleSkillsApi).toHaveBeenCalledWith({ action: "list", scriptUuid: "script-authenticated" }); + expect(handleOPFSApi).toHaveBeenCalledWith({ action: "list", scriptUuid: "script-authenticated" }, sender); + }); +}); + +describe("page execution binding gate", () => { + it("rejects a page-originated request that has no binding handle", async () => { + const api = Object.create(GMApi.prototype) as GMApi; + Object.defineProperty(api, "logger", { configurable: true, value: { trace: vi.fn(), error: vi.fn() } }); + const sender = makeSender(); + sender.getSender = () => ({ tab: { id: 42 } as chrome.tabs.Tab }); + + await expect( + api.handlerRequest({ uuid: "script-a", api: "GM_getTab", params: [], runFlag: "forged" }, sender) + ).rejects.toThrow("page execution binding is required"); + }); + + it("rejects an unknown page binding before parsing or invoking a GM API", async () => { + const api = Object.create(GMApi.prototype) as GMApi; + Object.defineProperty(api, "logger", { configurable: true, value: { trace: vi.fn(), error: vi.fn() } }); + const resolveBinding = vi.fn().mockReturnValue(undefined); + Object.defineProperty(api, "resolvePageExecutionBinding", { configurable: true, value: resolveBinding }); + const sender = makeSender(); + sender.getSender = () => ({ tab: { id: 42 } as chrome.tabs.Tab }); + + await expect( + api.handlerRequest( + { uuid: "script-a", api: "GM_getTab", params: [], runFlag: "forged", executionHandle: "missing" }, + sender + ) + ).rejects.toThrow("page execution binding is invalid"); + expect(resolveBinding).toHaveBeenCalledTimes(1); + }); + + it("rejects a page API that is outside the binding capability set", async () => { + const api = Object.create(GMApi.prototype) as GMApi; + Object.defineProperty(api, "logger", { configurable: true, value: { trace: vi.fn(), error: vi.fn() } }); + const parseRequest = vi.fn(); + Object.defineProperty(api, "parseRequest", { configurable: true, value: parseRequest }); + const binding = { + handle: "handle-a", + uuid: "script-a", + envTag: "it" as const, + runFlag: "run-a", + tabId: 42, + frameId: 0, + allowedAPIs: new Set(["GM_getTab"]), + requestIds: new Set(), + }; + Object.defineProperty(api, "resolvePageExecutionBinding", { + configurable: true, + value: vi.fn().mockReturnValue(binding), + }); + const sender = makeSender(); + sender.getSender = () => ({ tab: { id: 42 } as chrome.tabs.Tab, frameId: 0 }); + + await expect( + api.handlerRequest( + { + uuid: "script-a", + api: "GM_log", + params: ["hello"], + runFlag: "forged", + executionHandle: "handle-a", + requestId: "request-a", + version: 1, + }, + sender + ) + ).rejects.toThrow("API is not granted to this execution"); + expect(parseRequest).not.toHaveBeenCalled(); + expect(binding.requestIds.size).toBe(0); + }); + + it("rejects a replayed page request id before invoking the GM API", async () => { + const api = Object.create(GMApi.prototype) as GMApi; + Object.defineProperty(api, "logger", { configurable: true, value: { trace: vi.fn(), error: vi.fn() } }); + Object.defineProperty(api, "permissionVerify", { + configurable: true, + value: { verify: vi.fn().mockResolvedValue(undefined) }, + }); + Object.defineProperty(api, "parseRequest", { + configurable: true, + value: vi.fn().mockResolvedValue({ + uuid: "script-a", + api: "GM_log", + params: ["hello"], + script: { uuid: "script-a", name: "script-a" }, + }), + }); + const binding = { + handle: "handle-a", + uuid: "script-a", + envTag: "it" as const, + runFlag: "run-a", + tabId: 42, + frameId: 0, + allowedAPIs: new Set(["GM_log"]), + requestIds: new Set(), + }; + Object.defineProperty(api, "resolvePageExecutionBinding", { + configurable: true, + value: vi.fn().mockReturnValue(binding), + }); + const sender = makeSender(); + sender.getSender = () => ({ tab: { id: 42 } as chrome.tabs.Tab, frameId: 0 }); + + const request = { + uuid: "script-a", + api: "GM_log", + params: ["hello"], + runFlag: "forged", + executionHandle: "handle-a", + requestId: "request-a", + version: 1 as const, + envTag: "ct" as const, + }; + await expect(api.handlerRequest(request, sender)).rejects.toThrow("page execution binding is invalid"); + expect(binding.requestIds).toHaveLength(0); + + const validRequest = { ...request, envTag: "it" as const }; + await expect(api.handlerRequest(validRequest, sender)).resolves.toBe(true); + await expect(api.handlerRequest(validRequest, sender)).rejects.toThrow("page RPC requestId was already used"); + }); + + it("accepts a unique request when replay state is full while still rejecting replay", async () => { + const api = Object.create(GMApi.prototype) as GMApi; + Object.defineProperty(api, "logger", { configurable: true, value: { trace: vi.fn(), error: vi.fn() } }); + Object.defineProperty(api, "permissionVerify", { + configurable: true, + value: { verify: vi.fn().mockResolvedValue(undefined) }, + }); + Object.defineProperty(api, "parseRequest", { + configurable: true, + value: vi.fn().mockResolvedValue({ + uuid: "script-a", + api: "GM_log", + params: ["hello"], + script: { uuid: "script-a", name: "script-a" }, + }), + }); + const requestIds = new Set(["request-0"]); + // 直接模拟满集合,验证唯一 ID 仍可用且重放仍被拒绝,避免 CI 发送数千个请求。 + Object.defineProperty(requestIds, "size", { configurable: true, value: 4096 }); + const binding = { + handle: "handle-a", + uuid: "script-a", + envTag: "it" as const, + runFlag: "run-a", + tabId: 42, + frameId: 0, + allowedAPIs: new Set(["GM_log"]), + requestIds, + }; + Object.defineProperty(api, "resolvePageExecutionBinding", { + configurable: true, + value: vi.fn().mockReturnValue(binding), + }); + const sender = makeSender(); + sender.getSender = () => ({ tab: { id: 42 } as chrome.tabs.Tab, frameId: 0 }); + + await expect( + api.handlerRequest( + { + uuid: "script-a", + api: "GM_log", + params: ["hello"], + runFlag: "forged", + executionHandle: "handle-a", + requestId: "request-4097", + version: 1, + }, + sender + ) + ).resolves.toBe(true); + await expect( + api.handlerRequest( + { + uuid: "script-a", + api: "GM_log", + params: ["hello"], + runFlag: "forged", + executionHandle: "handle-a", + requestId: "request-0", + version: 1, + }, + sender + ) + ).rejects.toThrow("page RPC requestId was already used"); + }); +}); + describe("window.focus", () => { it("应同时激活标签页并将其所在窗口置于前台", async () => { const tabsUpdate = vi.fn().mockResolvedValue(undefined); 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 853ebc3aa..eb8619c32 100644 --- a/src/app/service/service_worker/gm_api/gm_api.ts +++ b/src/app/service/service_worker/gm_api/gm_api.ts @@ -33,6 +33,7 @@ import type { MessageRequest, NotificationMessageOption, GMApiRequest, + ServiceWorkerExecutionBinding, } from "../types"; import type { TScriptMenuRegister, TScriptMenuUnregister } from "../../queue"; import type { NotificationOptionCache } from "../utils"; @@ -361,7 +362,11 @@ export default class GMApi { private msgSender: MessageSend, private mq: IMessageQueue, private value: ValueService, - private gmExternalDependencies: IGMExternalDependencies + private gmExternalDependencies: IGMExternalDependencies, + private readonly resolvePageExecutionBinding?: ( + handle: string, + sender: IGetSender + ) => ServiceWorkerExecutionBinding | undefined ) { this.logger = LoggerCore.logger().with({ service: "runtime/gm_api" }); } @@ -374,6 +379,37 @@ export default class GMApi { // sendMessage from Content Script, etc async handlerRequest(data: MessageRequest, sender: IGetSender) { this.logger.trace("GM API request", { api: data.api, uuid: data.uuid, param: data.params }); + const source = sender.getSender(); + const isPageRequest = typeof source?.tab?.id === "number"; + if (isPageRequest && !data.executionHandle) { + throw new Error("page execution binding is required"); + } + if (data.executionHandle) { + if (data.version !== undefined && data.version !== 1) { + throw new Error("unsupported page execution binding version"); + } + if (data.handle !== undefined && data.handle !== data.executionHandle) { + throw new Error("page execution binding is invalid"); + } + const binding = this.resolvePageExecutionBinding?.(data.executionHandle, sender); + if (!binding || (data.uuid && data.uuid !== binding.uuid)) { + throw new Error("page execution binding is invalid"); + } + if (!binding.allowedAPIs.has(data.api)) { + throw new Error("API is not granted to this execution"); + } + if (data.envTag !== undefined && data.envTag !== binding.envTag) { + throw new Error("page execution binding is invalid"); + } + if (typeof data.requestId !== "string" || !data.requestId || data.requestId.length > 256) { + throw new Error("page RPC requestId is invalid"); + } + if (binding.requestIds.has(data.requestId)) { + throw new Error("page RPC requestId was already used"); + } + binding.requestIds.add(data.requestId); + data = { ...data, uuid: binding.uuid, runFlag: binding.runFlag }; + } const api = PermissionVerifyApiGet(data.api); if (!api) { throw new Error("gm api is not found"); @@ -598,7 +634,7 @@ export default class GMApi { const keyValuePairs = [[key, encodeRValue(value)]] as TKeyValuePair[]; const valueSender = { runFlag: request.runFlag, - tabId: sender.getSender()?.tab?.id || -1, + tabId: sender.getSender()?.tab?.id ?? -1, }; await this.value.setValues({ uuid: request.script.uuid, id, keyValuePairs, isReplace: false, valueSender }); } @@ -611,7 +647,7 @@ export default class GMApi { const [id, keyValuePairs] = request.params; const valueSender = { runFlag: request.runFlag, - tabId: sender.getSender()?.tab?.id || -1, + tabId: sender.getSender()?.tab?.id ?? -1, }; await this.value.setValues({ uuid: request.script.uuid, id, keyValuePairs, isReplace: false, valueSender }); } @@ -1133,7 +1169,7 @@ export default class GMApi { key, name, options, - tabId: sender.getSender()?.tab?.id || -1, + tabId: sender.getSender()?.tab?.id ?? -1, frameId: sender.getSender()?.frameId, documentId: sender.getSender()?.documentId, }); @@ -1146,7 +1182,7 @@ export default class GMApi { this.mq.emit("unregisterMenuCommand", { uuid: request.script.uuid, key, - tabId: sender.getSender()?.tab?.id || -1, + tabId: sender.getSender()?.tab?.id ?? -1, frameId: sender.getSender()?.frameId, documentId: sender.getSender()?.documentId, }); diff --git a/src/app/service/service_worker/index.ts b/src/app/service/service_worker/index.ts index dbe139672..a393a6aba 100644 --- a/src/app/service/service_worker/index.ts +++ b/src/app/service/service_worker/index.ts @@ -484,6 +484,7 @@ export default class ServiceWorkerManager { // 无视错误 } onTabRemoved(tabId); + runtime.revokePageBindingsForTab(tabId); }); } } diff --git a/src/app/service/service_worker/runtime.test.ts b/src/app/service/service_worker/runtime.test.ts index fc272def4..4a07fbe65 100644 --- a/src/app/service/service_worker/runtime.test.ts +++ b/src/app/service/service_worker/runtime.test.ts @@ -20,7 +20,7 @@ import type { ResourceService } from "./resource"; import type { ScriptDAO } from "@App/app/repo/scripts"; import { LocalStorageDAO } from "@App/app/repo/localStorage"; import type { MessageConnect, TMessage } from "@Packages/message/types"; -import { obtainBlackList } from "@App/pkg/utils/utils"; +import { getStorageName, obtainBlackList } from "@App/pkg/utils/utils"; import type { CompiledResource, Resource } from "@App/app/repo/resource"; initTestEnv(); @@ -1077,6 +1077,16 @@ describe("pageLoad 按消息发送方标签页区分隐身上下文", () => { }, }); + it("拒绝 USER_SCRIPT 来源直接请求页面脚本清单", async () => { + const { runtime } = _createRuntimeContext(); + const getScriptsForTab = vi.spyOn(runtime, "getScriptsForTab"); + + const result = await runtime.pageLoad(undefined, new SenderRuntime(createSender(false), "userScript")); + + expect(result).toEqual({ ok: false }); + expect(getScriptsForTab).not.toHaveBeenCalled(); + }); + it.each([ ["普通", false], ["隐身", true], @@ -1094,6 +1104,64 @@ describe("pageLoad 按消息发送方标签页区分隐身上下文", () => { }); }); + it("preserves tab ID zero for page matching and BFCache reporting", async () => { + const { runtime, mockGroup } = _createRuntimeContext(); + const getScriptsForTab = vi.spyOn(runtime, "getScriptsForTab").mockResolvedValue(null); + const sender = new SenderRuntime({ + ...createSender(false), + tab: { ...(createSender(false).tab as chrome.tabs.Tab), id: 0 } as chrome.tabs.Tab, + }); + + await runtime.pageLoad(undefined, sender); + await runtime.pageShow(undefined, sender); + + expect(getScriptsForTab).toHaveBeenCalledWith({ + url: "https://www.example.com/page", + tabId: 0, + frameId: 0, + incognito: false, + }); + expect(mockGroup.emit).toHaveBeenCalledWith("popupPageRestored", { + tabId: 0, + frameId: 0, + url: "https://www.example.com/page", + }); + }); + + it("discards an older same-frame pageLoad response that resolves after a newer one", async () => { + const { runtime } = _createRuntimeContext(); + const firstScript = _createScriptRunResource(_createMockScript({ uuid: "first-page-load" })); + const secondScript = _createScriptRunResource(_createMockScript({ uuid: "second-page-load" })); + const loadResult = (script: ScriptRunResource) => + ({ + injectScriptList: [script], + contentScriptList: [], + envInfo: { userAgentData: {}, sandboxMode: "raw", isIncognito: false }, + scriptmenus: [], + }) as unknown as Awaited>; + let resolveFirst!: (result: Awaited>) => void; + let resolveSecond!: (result: Awaited>) => void; + vi.spyOn(runtime, "getScriptsForTab") + .mockImplementationOnce(() => new Promise((resolve) => (resolveFirst = resolve))) + .mockImplementationOnce(() => new Promise((resolve) => (resolveSecond = resolve))); + const sender = new SenderRuntime({ + url: "https://www.example.com/page", + frameId: 0, + tab: { id: 41, incognito: false } as chrome.tabs.Tab, + } as chrome.runtime.MessageSender); + + const firstLoad = runtime.pageLoad(undefined, sender); + const secondLoad = runtime.pageLoad(undefined, sender); + resolveSecond(loadResult(secondScript)); + const second = await secondLoad; + expect(second.ok).toBe(true); + const secondHandle = second.ok ? second.injectScriptList[0].executionHandle : undefined; + + resolveFirst(loadResult(firstScript)); + await expect(firstLoad).resolves.toEqual({ ok: false }); + expect(runtime.resolvePageExecutionBinding(secondHandle!, sender)).toBeDefined(); + }); + // bfcache 还原不会重新注入 content script,页面里的脚本却还活着; // 这条上报只用来重新确认「本页扩展触及得到」,绝不能顺带重放脚本。 it("bfcache 还原上报只广播 popupPageRestored,不重新下发脚本", async () => { @@ -1109,6 +1177,572 @@ describe("pageLoad 按消息发送方标签页区分隐身上下文", () => { url: "https://www.example.com/page", }); }); + + it("为每个页面文档签发绑定,并拒绝跨标签页、跨 frame 和旧文档复用", async () => { + const { runtime } = _createRuntimeContext(); + const script = _createScriptRunResource( + _createMockScript({ uuid: "bound-script", metadata: { grant: ["GM_getTab"] } }) + ); + vi.spyOn(runtime, "getScriptsForTab").mockResolvedValue({ + injectScriptList: [script], + contentScriptList: [], + envInfo: { userAgentData: {}, sandboxMode: "raw", isIncognito: false }, + scriptmenus: [], + } as unknown as Awaited>); + + const rawSender = { + url: "https://www.example.com/page", + frameId: 0, + documentId: "doc-a", + tab: { id: 41, incognito: false } as chrome.tabs.Tab, + } as chrome.runtime.MessageSender; + const sender = new SenderRuntime(rawSender); + const first = await runtime.pageLoad(undefined, sender); + const firstHandle = first.ok ? first.injectScriptList[0].executionHandle : undefined; + const firstRunFlag = first.ok ? first.injectScriptList[0].executionRunFlag : undefined; + expect(firstHandle).toEqual(expect.any(String)); + expect(firstRunFlag).toEqual(expect.any(String)); + expect(runtime.resolvePageExecutionBinding(firstHandle!, sender)).toMatchObject({ + uuid: "bound-script", + envTag: "it", + tabId: 41, + frameId: 0, + documentId: "doc-a", + }); + + const otherTab = new SenderRuntime({ ...rawSender, tab: { ...rawSender.tab, id: 42 } as chrome.tabs.Tab }); + const otherFrame = new SenderRuntime({ ...rawSender, frameId: 1 }); + expect(runtime.resolvePageExecutionBinding(firstHandle!, otherTab)).toBeUndefined(); + expect(runtime.resolvePageExecutionBinding(firstHandle!, otherFrame)).toBeUndefined(); + + const secondSender = new SenderRuntime({ ...rawSender, documentId: "doc-b" }); + const second = await runtime.pageLoad(undefined, secondSender); + const secondHandle = second.ok ? second.injectScriptList[0].executionHandle : undefined; + const secondRunFlag = second.ok ? second.injectScriptList[0].executionRunFlag : undefined; + expect(secondHandle).toEqual(expect.any(String)); + expect(secondRunFlag).toEqual(expect.any(String)); + expect(secondHandle).not.toBe(firstHandle); + expect(secondRunFlag).not.toBe(firstRunFlag); + expect(runtime.resolvePageExecutionBinding(firstHandle!, sender)).toBeUndefined(); + expect(runtime.resolvePageExecutionBinding(secondHandle!, secondSender)).toBeDefined(); + + runtime.revokePageBindingsForTab(41); + expect(runtime.resolvePageExecutionBinding(firstHandle!, sender)).toBeUndefined(); + expect(runtime.resolvePageExecutionBinding(secondHandle!, secondSender)).toBeUndefined(); + }); + + it("新文档加载时撤销上一文档的执行绑定", async () => { + const { runtime } = _createRuntimeContext(); + const script = _createScriptRunResource(_createMockScript({ uuid: "navigation-bound-script" })); + vi.spyOn(runtime, "getScriptsForTab").mockResolvedValue({ + injectScriptList: [script], + contentScriptList: [], + envInfo: { userAgentData: {}, sandboxMode: "raw", isIncognito: false }, + scriptmenus: [], + } as unknown as Awaited>); + const firstRawSender = { + url: "https://www.example.com/first", + frameId: 0, + documentId: "doc-first", + tab: { id: 41, incognito: false } as chrome.tabs.Tab, + } as chrome.runtime.MessageSender; + const firstSender = new SenderRuntime(firstRawSender); + const firstLoad = await runtime.pageLoad(undefined, firstSender); + expect(firstLoad.ok).toBe(true); + if (!firstLoad.ok) return; + const firstHandle = firstLoad.injectScriptList[0].executionHandle; + expect(runtime.resolvePageExecutionBinding(firstHandle!, firstSender)).toBeDefined(); + + const secondRawSender = { ...firstRawSender, url: "https://www.example.com/second", documentId: "doc-second" }; + const secondSender = new SenderRuntime(secondRawSender); + const secondLoad = await runtime.pageLoad(undefined, secondSender); + expect(secondLoad.ok).toBe(true); + if (!secondLoad.ok) return; + + expect(runtime.resolvePageExecutionBinding(firstHandle!, firstSender)).toBeUndefined(); + }); + + it("rejects a stale URL when the browser omits documentId", async () => { + const { runtime } = _createRuntimeContext(); + const script = _createScriptRunResource( + _createMockScript({ uuid: "url-bound-script", metadata: { grant: ["GM_getTab"] } }) + ); + vi.spyOn(runtime, "getScriptsForTab").mockResolvedValue({ + injectScriptList: [script], + contentScriptList: [], + envInfo: { userAgentData: {}, sandboxMode: "raw", isIncognito: false }, + scriptmenus: [], + } as unknown as Awaited>); + const initialSender = new SenderRuntime({ + url: "https://www.example.com/page", + frameId: 0, + tab: { id: 41, incognito: false } as chrome.tabs.Tab, + } as chrome.runtime.MessageSender); + + const load = await runtime.pageLoad(undefined, initialSender); + expect(load.ok).toBe(true); + if (!load.ok) return; + const handle = load.injectScriptList[0].executionHandle; + expect(runtime.resolvePageExecutionBinding(handle!, initialSender)).toBeDefined(); + + const navigatedSender = new SenderRuntime({ + url: "https://www.example.com/next", + frameId: 0, + tab: { id: 41, incognito: false } as chrome.tabs.Tab, + } as chrome.runtime.MessageSender); + expect(runtime.resolvePageExecutionBinding(handle!, navigatedSender)).toBeUndefined(); + }); + + it("content USER_SCRIPT 的 pageLoad 只轮换 content 绑定", async () => { + const { runtime } = _createRuntimeContext(); + const inject = _createScriptRunResource(_createMockScript({ uuid: "inject-script" })); + const content = _createScriptRunResource(_createMockScript({ uuid: "content-script" })); + vi.spyOn(runtime, "getScriptsForTab").mockResolvedValue({ + injectScriptList: [inject], + contentScriptList: [content], + envInfo: { userAgentData: {}, sandboxMode: "raw", isIncognito: false }, + scriptmenus: [], + } as unknown as Awaited>); + const sender = new SenderRuntime({ + url: "https://www.example.com/page", + frameId: 0, + documentId: "doc-a", + tab: { id: 41, incognito: false } as chrome.tabs.Tab, + } as chrome.runtime.MessageSender); + + const first = await runtime.pageLoad(undefined, sender); + expect(first.ok).toBe(true); + if (!first.ok) return; + const injectHandle = first.injectScriptList[0].executionHandle; + const contentLoad = await runtime.pageLoad({ envTag: "ct" }, sender); + + expect(contentLoad.ok).toBe(true); + if (!contentLoad.ok) return; + expect(contentLoad.injectScriptList).toEqual([]); + expect(contentLoad.contentScriptList[0].executionHandle).toEqual(expect.any(String)); + expect(runtime.resolvePageExecutionBinding(injectHandle!, sender)).toBeDefined(); + }); + + it("isolated scripting 的 pageLoad 会撤销上一轮 content 绑定", async () => { + const { runtime } = _createRuntimeContext(); + const inject = _createScriptRunResource(_createMockScript({ uuid: "inject-script" })); + const content = _createScriptRunResource(_createMockScript({ uuid: "content-script" })); + vi.spyOn(runtime, "getScriptsForTab").mockResolvedValue({ + injectScriptList: [inject], + contentScriptList: [content], + envInfo: { userAgentData: {}, sandboxMode: "raw", isIncognito: false }, + scriptmenus: [], + } as unknown as Awaited>); + const sender = new SenderRuntime({ + url: "https://www.example.com/page", + frameId: 0, + documentId: "doc-a", + tab: { id: 41, incognito: false } as chrome.tabs.Tab, + } as chrome.runtime.MessageSender); + + const contentLoad = await runtime.pageLoad({ envTag: "ct" }, sender); + expect(contentLoad.ok).toBe(true); + if (!contentLoad.ok) return; + const contentHandle = contentLoad.contentScriptList[0].executionHandle; + const injectLoad = await runtime.pageLoad({ envTag: "it" }, sender); + + expect(injectLoad.ok).toBe(true); + expect(runtime.resolvePageExecutionBinding(contentHandle!, sender)).toBeUndefined(); + }); + + it("没有匹配脚本时也会撤销当前页面的旧绑定", async () => { + const { runtime } = _createRuntimeContext(); + const script = _createScriptRunResource(_createMockScript({ uuid: "stale-script" })); + const getScriptsForTab = vi.spyOn(runtime, "getScriptsForTab"); + getScriptsForTab.mockResolvedValueOnce({ + injectScriptList: [script], + contentScriptList: [], + envInfo: { userAgentData: {}, sandboxMode: "raw", isIncognito: false }, + scriptmenus: [], + } as unknown as Awaited>); + getScriptsForTab.mockResolvedValueOnce(null); + const sender = new SenderRuntime({ + url: "https://www.example.com/page", + frameId: 0, + tab: { id: 41, incognito: false } as chrome.tabs.Tab, + } as chrome.runtime.MessageSender); + + const first = await runtime.pageLoad(undefined, sender); + expect(first.ok).toBe(true); + if (!first.ok) return; + const handle = first.injectScriptList[0].executionHandle; + expect(runtime.resolvePageExecutionBinding(handle!, sender)).toBeDefined(); + + await runtime.pageLoad(undefined, sender); + expect(runtime.resolvePageExecutionBinding(handle!, sender)).toBeUndefined(); + }); +}); + +describe("USER_SCRIPT native callbacks", () => { + it("rejects bootstrap and reconnect tokens from a different URL when documentId is missing", async () => { + const { runtime } = _createRuntimeContext(); + const script = _createScriptRunResource( + _createMockScript({ uuid: "url-bound-user-script", metadata: { match: ["https://www.example.com/*"] } }) + ); + vi.spyOn(runtime, "getScriptsForTab").mockResolvedValue({ + injectScriptList: [script], + contentScriptList: [], + envInfo: { userAgentData: {}, sandboxMode: "raw", isIncognito: false }, + scriptmenus: [], + } as unknown as Awaited>); + + const originalSender = { + url: "https://www.example.com/page", + frameId: 0, + tab: { id: 41, incognito: false } as chrome.tabs.Tab, + } as chrome.runtime.MessageSender; + const connection = { + onMessage: vi.fn(), + sendMessage: vi.fn(), + disconnect: vi.fn(), + onDisconnect: vi.fn(), + } as unknown as MessageConnect; + const bootstrapSender = { + getType: () => 3, + isType: (type: number) => type === 3, + getSender: () => originalSender, + getExtMessageSender: () => ({ tabId: 41, frameId: 0 }), + getConnect: () => connection, + getConnectOrigin: () => "userScript" as const, + }; + const pageLoad = await runtime.pageLoad({ envTag: "it" }, new SenderRuntime(originalSender)); + const bootstrapToken = pageLoad.ok ? pageLoad.userScriptInjectBootstrapToken : undefined; + expect(bootstrapToken).toEqual(expect.any(String)); + + const navigatedSender = { + ...bootstrapSender, + getSender: () => ({ ...originalSender, url: "https://www.example.com/next" }), + }; + expect(runtime.registerUserScriptConnection({ world: "MAIN", bootstrapToken }, navigatedSender)).toBe(false); + expect( + runtime.reconnectUserScript( + { reconnectToken: bootstrapToken }, + { + ...navigatedSender, + getType: () => 4, + isType: (type: number) => type === 4, + getConnect: () => undefined, + } + ) + ).toBeUndefined(); + }); + + it("issues a separate MAIN bootstrap and routes its private callbacks over the native port", async () => { + const { runtime } = _createRuntimeContext(); + const script = _createScriptRunResource( + _createMockScript({ uuid: "inject-script", metadata: { match: ["https://www.example.com/*"] } }) + ); + const contentScript = _createScriptRunResource( + _createMockScript({ uuid: "content-script", metadata: { match: ["https://www.example.com/*"] } }) + ); + vi.spyOn(runtime, "getScriptsForTab").mockResolvedValue({ + injectScriptList: [script], + contentScriptList: [contentScript], + envInfo: { userAgentData: {}, sandboxMode: "raw", isIncognito: false }, + scriptmenus: [], + } as unknown as Awaited>); + + const rawSender = { + url: "https://www.example.com/page", + frameId: 0, + documentId: "doc-main", + tab: { id: 41, incognito: false } as chrome.tabs.Tab, + } as chrome.runtime.MessageSender; + const sendMessage = vi.fn(); + const connection = { + onMessage: vi.fn(), + sendMessage, + disconnect: vi.fn(), + onDisconnect: vi.fn(), + } as unknown as MessageConnect; + const connectionSender = { + getType: () => 3, + isType: () => true, + getSender: () => rawSender, + getExtMessageSender: () => ({ tabId: 41, frameId: 0, documentId: "doc-main" }), + getConnect: () => connection, + getConnectOrigin: () => "userScript" as const, + }; + + const pageLoad = await runtime.pageLoad({ envTag: "it" }, new SenderRuntime(rawSender)); + expect(pageLoad.ok && pageLoad.userScriptInjectBootstrapToken).toEqual(expect.any(String)); + const bootstrapToken = pageLoad.ok ? pageLoad.userScriptInjectBootstrapToken : undefined; + expect(runtime.registerUserScriptConnection({ world: "USER_SCRIPT", bootstrapToken }, connectionSender)).toBe( + false + ); + expect(runtime.registerUserScriptConnection({ world: "MAIN", bootstrapToken }, connectionSender)).toBe(true); + + const contentConnection = { + onMessage: vi.fn(), + sendMessage: vi.fn(), + disconnect: vi.fn(), + onDisconnect: vi.fn(), + } as unknown as MessageConnect; + const contentSender = { ...connectionSender, getConnect: () => contentConnection }; + const contentBootstrapToken = pageLoad.ok ? pageLoad.userScriptBootstrapToken : undefined; + expect( + runtime.registerUserScriptConnection( + { world: "USER_SCRIPT", bootstrapToken: contentBootstrapToken }, + contentSender + ) + ).toBe(true); + expect((runtime as any).userScriptConnections.size).toBe(2); + + const bootstrapHandler = (connection.onMessage as ReturnType).mock.calls[0]?.[0] as + | ((packet: TMessage) => void) + | undefined; + bootstrapHandler?.({ action: "userScript/bootstrap" }); + expect(sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ + action: "inject/pageLoad", + data: expect.objectContaining({ scripts: expect.any(Array) }), + }) + ); + + sendMessage.mockClear(); + (runtime as any).sendUserScriptMessage(undefined, "runtime/emitEvent", { + uuid: "inject-script", + event: "click", + eventId: "1", + }); + expect(sendMessage).toHaveBeenCalledWith({ + action: "inject/runtime/emitEvent", + data: { uuid: "inject-script", event: "click", eventId: "1" }, + }); + }); + + it("queues USER_SCRIPT value updates until a reconnect finishes its bootstrap", async () => { + const { runtime } = _createRuntimeContext(); + const script = _createScriptRunResource( + _createMockScript({ uuid: "queued-content-script", metadata: { match: ["https://www.example.com/*"] } }) + ); + vi.spyOn(runtime, "getScriptsForTab").mockResolvedValue({ + injectScriptList: [], + contentScriptList: [script], + envInfo: { userAgentData: {}, sandboxMode: "raw", isIncognito: false }, + scriptmenus: [], + } as unknown as Awaited>); + + const rawSender = { + url: "https://www.example.com/page", + frameId: 0, + documentId: "doc-a", + tab: { id: 41, incognito: false } as chrome.tabs.Tab, + } as chrome.runtime.MessageSender; + const makeConnection = () => + ({ + onMessage: vi.fn(), + sendMessage: vi.fn(), + disconnect: vi.fn(), + onDisconnect: vi.fn(), + }) as unknown as MessageConnect; + const firstConnection = makeConnection(); + const sender = { + getType: () => 3, + isType: () => true, + getSender: () => rawSender, + getExtMessageSender: () => ({ tabId: 41, frameId: 0, documentId: "doc-a" }), + getConnect: () => firstConnection, + getConnectOrigin: () => "userScript" as const, + }; + const pageLoad = await runtime.pageLoad({ envTag: "it" }, new SenderRuntime(rawSender)); + const contentBootstrapToken = pageLoad.ok ? pageLoad.userScriptBootstrapToken : undefined; + expect(contentBootstrapToken).toEqual(expect.any(String)); + expect( + runtime.registerUserScriptConnection({ world: "USER_SCRIPT", bootstrapToken: contentBootstrapToken }, sender) + ).toBe(true); + + const update = { + uuid: script.uuid, + storageName: getStorageName(script), + entries: [["beforeReconnect", [0, "new"], [0, "old"]]], + sender: { runFlag: "remote", tabId: 42 }, + valueUpdated: true, + }; + (runtime as any).sendUserScriptMessage(undefined, "runtime/valueUpdate", update); + expect(firstConnection.sendMessage).not.toHaveBeenCalled(); + + const firstBootstrapHandler = (firstConnection.onMessage as ReturnType).mock.calls[0][0] as ( + packet: TMessage + ) => void; + firstBootstrapHandler({ action: "userScript/bootstrap" }); + expect(firstConnection.sendMessage).toHaveBeenCalledTimes(2); + expect(firstConnection.sendMessage).toHaveBeenLastCalledWith({ + action: "content/runtime/valueUpdate", + data: update, + }); + + const disconnectHandler = (firstConnection.onDisconnect as ReturnType).mock.calls[0][0] as ( + isSelfDisconnected: boolean + ) => void; + disconnectHandler(false); + (runtime as any).sendUserScriptMessage(undefined, "runtime/valueUpdate", { + ...update, + entries: [["afterReconnect", [0, "next"], [0, "old-next"]]], + }); + (runtime as any).sendUserScriptMessage(undefined, "runtime/valueUpdate", { + ...update, + entries: [["afterReconnectAgain", [0, "latest"], [0, "old-latest"]]], + }); + const reconnect = runtime.reconnectUserScript( + { reconnectToken: contentBootstrapToken }, + { + getType: () => 4, + isType: (type: number) => type === 4, + getSender: () => rawSender, + getExtMessageSender: () => ({ tabId: 41, frameId: 0, documentId: "doc-a" }), + getConnect: () => undefined, + getConnectOrigin: () => "userScript" as const, + } + ); + expect(reconnect).toEqual({ bootstrapToken: expect.any(String) }); + + const secondConnection = makeConnection(); + expect( + runtime.registerUserScriptConnection( + { world: "USER_SCRIPT", bootstrapToken: reconnect?.bootstrapToken }, + { ...sender, getConnect: () => secondConnection } + ) + ).toBe(true); + const secondBootstrapHandler = (secondConnection.onMessage as ReturnType).mock.calls[0][0] as ( + packet: TMessage + ) => void; + secondBootstrapHandler({ action: "userScript/bootstrap" }); + + expect(secondConnection.sendMessage).toHaveBeenCalledTimes(2); + expect(secondConnection.sendMessage).toHaveBeenLastCalledWith( + expect.objectContaining({ + action: "content/runtime/valueUpdate", + data: expect.objectContaining({ + entries: [ + ["afterReconnect", [0, "next"], [0, "old-next"]], + ["afterReconnectAgain", [0, "latest"], [0, "old-latest"]], + ], + }), + }) + ); + }); + + it("只向当前文档中声明了对应脚本或 storageName 的连接投递更新", async () => { + const { runtime } = _createRuntimeContext(); + const script = _createScriptRunResource( + _createMockScript({ uuid: "content-script", metadata: { match: ["https://www.example.com/*"] } }) + ); + vi.spyOn(runtime, "getScriptsForTab").mockResolvedValue({ + injectScriptList: [], + contentScriptList: [script], + envInfo: { userAgentData: {}, sandboxMode: "raw", isIncognito: false }, + scriptmenus: [], + } as unknown as Awaited>); + + const rawSender = { + url: "https://www.example.com/page", + frameId: 0, + documentId: "doc-a", + tab: { id: 41, incognito: false } as chrome.tabs.Tab, + } as chrome.runtime.MessageSender; + const sendMessage = vi.fn(); + const onMessage = vi.fn(); + const connection = { + onMessage, + sendMessage, + disconnect: vi.fn(), + onDisconnect: vi.fn(), + } as unknown as MessageConnect; + const connectionSender = { + getType: () => 3, + isType: () => true, + getSender: () => rawSender, + getExtMessageSender: () => ({ tabId: 41, frameId: 0, documentId: "doc-a" }), + getConnect: () => connection, + getConnectOrigin: () => "userScript" as const, + }; + + const pageLoad = await runtime.pageLoad({ envTag: "it" }, new SenderRuntime(rawSender)); + const contentBindings = [...(runtime as any).pageExecutionBindings.values()] as Array<{ handle: string }>; + const handles = contentBindings.map(({ handle }) => handle); + expect(handles).toHaveLength(1); + expect(pageLoad.ok && pageLoad.userScriptBootstrapToken).toEqual(expect.any(String)); + const bootstrapToken = pageLoad.ok ? pageLoad.userScriptBootstrapToken : undefined; + expect( + runtime.registerUserScriptConnection( + { world: "USER_SCRIPT", bootstrapToken }, + { ...connectionSender, getConnectOrigin: () => "extension" as const } + ) + ).toBe(false); + expect( + runtime.registerUserScriptConnection( + { world: "USER_SCRIPT", bootstrapToken, transport: "extension" }, + connectionSender + ) + ).toBe(false); + expect( + runtime.registerUserScriptConnection( + { world: "USER_SCRIPT", bootstrapToken, transport: "extension" }, + { ...connectionSender, getConnectOrigin: () => "extension" as const } + ) + ).toBe(true); + expect(runtime.registerUserScriptConnection({ world: "USER_SCRIPT" }, connectionSender)).toBe(false); + const bootstrapHandler = onMessage.mock.calls[0]?.[0] as ((packet: TMessage) => void) | undefined; + bootstrapHandler?.({ action: "userScript/bootstrap" }); + expect(sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ + action: "content/pageLoad", + data: expect.objectContaining({ scripts: expect.any(Array) }), + }) + ); + + const sendUserScriptMessage = (runtime as any).sendUserScriptMessage.bind(runtime); + sendMessage.mockClear(); + sendUserScriptMessage(undefined, "runtime/valueUpdate", { + uuid: "other-script", + storageName: getStorageName(script), + }); + expect(sendMessage).toHaveBeenCalledTimes(1); + + sendMessage.mockClear(); + sendUserScriptMessage(undefined, "runtime/valueUpdate", { + uuid: "content-script", + storageName: "unrelated-storage", + }); + expect(sendMessage).not.toHaveBeenCalled(); + + const reconnect = runtime.reconnectUserScript( + { reconnectToken: bootstrapToken }, + { + getType: () => 4, + isType: (type: number) => type === 4, + getSender: () => rawSender, + getExtMessageSender: () => ({ tabId: 41, frameId: 0, documentId: "doc-a" }), + getConnect: () => undefined, + getConnectOrigin: () => "extension" as const, + } + ); + expect(reconnect).toEqual({ bootstrapToken: expect.any(String) }); + expect((runtime as any).userScriptBootstraps.size).toBe(1); + expect( + runtime.reconnectUserScript( + { reconnectToken: bootstrapToken }, + { + getType: () => 4, + isType: (type: number) => type === 4, + getSender: () => rawSender, + getExtMessageSender: () => ({ tabId: 41, frameId: 0, documentId: "doc-a" }), + getConnect: () => undefined, + getConnectOrigin: () => "userScript" as const, + } + ) + ).toBeUndefined(); + + (runtime as any).revokePageBindingsForScript("content-script"); + expect(connection.disconnect).toHaveBeenCalledWith(true); + expect((runtime as any).userScriptConnections.size).toBe(0); + }); }); describe("sandbox verified 初始化重放", () => { diff --git a/src/app/service/service_worker/runtime.ts b/src/app/service/service_worker/runtime.ts index d44ef0215..d0233c5b8 100644 --- a/src/app/service/service_worker/runtime.ts +++ b/src/app/service/service_worker/runtime.ts @@ -1,7 +1,13 @@ -import type { EmitEventRequest, ScriptLoadInfo, ScriptMatchInfo, ScriptMenu } from "./types"; +import type { + EmitEventRequest, + ScriptLoadInfo, + ScriptMatchInfo, + ScriptMenu, + ServiceWorkerExecutionBinding, +} from "./types"; import type { IMessageQueue } from "@Packages/message/message_queue"; -import type { Group, IGetSender } from "@Packages/message/server"; -import type { ExtMessageSender, MessageSend } from "@Packages/message/types"; +import { GetSenderType, type Group, type IGetSender } from "@Packages/message/server"; +import type { ExtMessageSender, MessageConnect, MessageSend } from "@Packages/message/types"; import type { TClientPageLoadInfo } from "@App/app/repo/scripts"; import type { Script, ScriptDAO, ScriptRunResource, ScriptSite, TScriptInfo, UserConfig } from "@App/app/repo/scripts"; import { SCRIPT_STATUS_DISABLE, SCRIPT_STATUS_ENABLE, SCRIPT_TYPE_NORMAL } from "@App/app/repo/scripts"; @@ -34,6 +40,7 @@ import { stackAsyncTask } from "@App/pkg/utils/async_queue"; import { ExtensionContentMessageSend } from "@Packages/message/extension_message"; import { sendMessage } from "@Packages/message/client"; import type { CompileScriptCodeResource } from "../content/utils"; +import { getExtensionOrigin, getPageRpcAllowedAPIs, type ExtensionOrigin } from "../content/page_rpc"; import { compileInjectScriptByFlag, compileScriptCodeByResource, @@ -60,6 +67,7 @@ import { CompiledResourceDAO, CompiledResourceNamespace } from "@App/app/repo/re import { setOnTabURLChanged } from "./url_monitor"; import { scriptToMenu, type TPopupPageLoadInfo, type TPopupPageRestoreInfo } from "./popup_scriptmenu"; import { getExtensionUserAgentData } from "../extension/extension_env"; +import { uuidv4 } from "@App/pkg/utils/uuid"; const ORIGINAL_URLMATCH_SUFFIX = "{ORIGINAL}"; // 用于标记原始URLPatterns的后缀 @@ -123,6 +131,22 @@ export type TScriptsForTab = { scriptmenus: ScriptMenu[]; } | null; +type UserScriptSession = { + scripts: TScriptInfo[]; + envInfo: GMInfoEnv; + extensionOrigin?: ExtensionOrigin; + reconnectToken: string; + envTag: "it" | "ct"; + url: string; + tabId: number; + frameId?: number; + documentId?: string; + transport: "userScript" | "extension"; + // 断线窗口内按 storageName 合并值更新,重连握手完成后再投递。 + pendingValueUpdates: Map; +}; +type UserScriptBootstrap = Omit; + const bgScriptStorageNames = new Set(); // For Firefox, StorageArea.setAccessLevel is not implemented. @@ -134,11 +158,450 @@ export class RuntimeService { scriptMatchEnable: UrlMatch = new UrlMatch(); blackMatch: UrlMatch = new UrlMatch(); private gmApi?: GMApi; + // 句柄绑定到 tab/frame/document;页面导航、脚本变更或窗口关闭时必须整体撤销。 + private readonly pageExecutionBindings = new Map(); + // 原生 page/content 端口只保留各自签发的句柄,回调发送前再按该集合过滤一次。 + private readonly userScriptConnections = new Map< + string, + { + connection: MessageConnect; + handles: Set; + envTag: "it" | "ct"; + tabId: number; + frameId?: number; + documentId?: string; + ready: boolean; + } + >(); + private readonly userScriptBootstraps = new Map(); + // 连接断开后保留当前文档的已验证资料与待投递值更新,供 USER_SCRIPT 通过原生消息重连;导航或脚本撤销会同步清除。 + private readonly userScriptSessions = new Map(); + // Only the newest load for a tab/frame/environment may issue bindings; navigation can resolve old requests late. + private readonly pageLoadSequences = new Map(); getGMApi(): GMApi | undefined { return this.gmApi; } + private revokePageBindings(sender: IGetSender, envTag?: "it" | "ct"): void { + // pageLoad 是文档切换信号;按 tab/frame 退休旧句柄,避免旧文档继续使用上一页的权限。 + const source = sender.getSender(); + const tabId = source?.tab?.id; + const frameId = source?.frameId; + for (const [handle, binding] of this.pageExecutionBindings) { + if ( + binding.tabId === tabId && + binding.frameId === frameId && + (envTag === undefined || binding.envTag === envTag || (envTag === "it" && binding.envTag === "ct")) + ) { + this.pageExecutionBindings.delete(handle); + } + } + if (envTag === "it") { + for (const [key, entry] of this.userScriptConnections) { + if (entry.tabId === tabId && entry.frameId === frameId) { + entry.connection.disconnect(true); + this.userScriptConnections.delete(key); + this.userScriptSessions.delete(key); + } + } + for (const [key, session] of this.userScriptSessions) { + if (session.tabId === tabId && session.frameId === frameId) this.userScriptSessions.delete(key); + } + } + if (envTag !== "ct") { + for (const [token, bootstrap] of this.userScriptBootstraps) { + if (bootstrap.tabId === tabId && bootstrap.frameId === frameId) this.userScriptBootstraps.delete(token); + } + } + } + + revokePageBindingsForTab(tabId: number): void { + for (const [handle, binding] of this.pageExecutionBindings) { + if (binding.tabId === tabId) this.pageExecutionBindings.delete(handle); + } + for (const [key, entry] of this.userScriptConnections) { + if (entry.tabId === tabId) { + entry.connection.disconnect(true); + this.userScriptConnections.delete(key); + this.userScriptSessions.delete(key); + } + } + for (const [token, bootstrap] of this.userScriptBootstraps) { + if (bootstrap.tabId === tabId) this.userScriptBootstraps.delete(token); + } + for (const [key, session] of this.userScriptSessions) { + if (session.tabId === tabId) this.userScriptSessions.delete(key); + } + const prefix = `${tabId}:`; + for (const key of this.pageLoadSequences.keys()) { + if (key.startsWith(prefix)) this.pageLoadSequences.delete(key); + } + } + + private beginPageLoadSequence(sender: IGetSender, envTag: "it" | "ct" | undefined): [string, number] | undefined { + const tabId = sender.getSender()?.tab?.id; + if (typeof tabId !== "number") return undefined; + const key = `${tabId}:${sender.getSender()?.frameId ?? -1}:${envTag ?? "it"}`; + const sequence = (this.pageLoadSequences.get(key) ?? 0) + 1; + this.pageLoadSequences.set(key, sequence); + return [key, sequence]; + } + + private userScriptConnectionKey( + tabId: number, + frameId: number | undefined, + documentId: string | undefined, + envTag: "it" | "ct" + ): string { + return `${tabId}:${frameId ?? -1}:${documentId ?? ""}:${envTag}`; + } + + /** Register the native USER_SCRIPT channel used for private bootstrap and callbacks; fallback ports remain token-bound. */ + registerUserScriptConnection(data: unknown, sender: IGetSender): boolean { + // bootstrap token 只允许对应 tab/frame/document 使用一次;documentId 缺失时以 URL 作为文档身份,并且必须覆盖本次下发的全部句柄。 + if (!sender.isType(GetSenderType.EXTCONNECT)) return false; + if (data === null || typeof data !== "object") return false; + const handshake = data as { world?: unknown; bootstrapToken?: unknown; transport?: unknown }; + const origin = sender.getConnectOrigin?.(); + const isExtensionFallback = origin === "extension" && handshake.transport === "extension"; + if (origin === "userScript" ? handshake.transport !== undefined : !isExtensionFallback) return false; + if ( + Object.keys(data).length !== (isExtensionFallback ? 3 : 2) || + typeof handshake.bootstrapToken !== "string" || + handshake.bootstrapToken.length === 0 || + handshake.bootstrapToken.length > 256 + ) { + return false; + } + const source = sender.getSender(); + const connection = sender.getConnect(); + const tabId = source?.tab?.id; + if (!source || typeof tabId !== "number" || !connection) return false; + const bootstrap = this.userScriptBootstraps.get(handshake.bootstrapToken); + if ( + !bootstrap || + bootstrap.tabId !== tabId || + bootstrap.frameId !== source.frameId || + bootstrap.documentId !== source.documentId || + (bootstrap.documentId === undefined && + (typeof source.url !== "string" || source.url.length === 0 || bootstrap.url !== source.url)) + ) { + return false; + } + // bootstrap 令牌决定唯一可消费这些句柄的 world,调用方不能借握手字段改投其他环境。 + const expectedWorld = bootstrap.envTag === "it" ? "MAIN" : "USER_SCRIPT"; + if (handshake.world !== expectedWorld) return false; + const handles = new Set(); + for (const script of bootstrap.scripts) { + const handle = script.executionHandle; + if (typeof handle !== "string" || handle.length === 0 || handle.length > 256) return false; + const binding = this.pageExecutionBindings.get(handle); + if ( + !binding || + binding.envTag !== bootstrap.envTag || + binding.tabId !== tabId || + binding.frameId !== source.frameId || + binding.documentId !== source.documentId + ) { + return false; + } + handles.add(handle); + } + if (handles.size === 0) return false; + const frameId = source.frameId; + const documentId = source.documentId; + const key = this.userScriptConnectionKey(tabId, frameId, documentId, bootstrap.envTag); + const session = { ...bootstrap, transport: isExtensionFallback ? ("extension" as const) : ("userScript" as const) }; + this.userScriptSessions.set(key, session); + this.userScriptBootstraps.delete(handshake.bootstrapToken); + const previous = this.userScriptConnections.get(key); + if (previous) previous.connection.disconnect(true); + const entry = { connection, handles, envTag: bootstrap.envTag, tabId, frameId, documentId, ready: false }; + this.userScriptConnections.set(key, entry); + connection.onDisconnect(() => { + if (this.userScriptConnections.get(key)?.connection === connection) this.userScriptConnections.delete(key); + }); + let bootstrapped = false; + connection.onMessage((packet) => { + if ( + bootstrapped || + packet === null || + typeof packet !== "object" || + Object.keys(packet).length !== 1 || + packet.action !== "userScript/bootstrap" + ) { + return; + } + bootstrapped = true; + try { + const pageLoadData = { + scripts: bootstrap.scripts, + envInfo: bootstrap.envInfo, + reconnectToken: bootstrap.reconnectToken, + ...(bootstrap.envTag === "ct" ? { extensionOrigin: bootstrap.extensionOrigin } : {}), + }; + connection.sendMessage({ + action: `${bootstrap.envTag === "it" ? "inject" : "content"}/pageLoad`, + data: pageLoadData, + }); + entry.ready = true; + this.flushPendingUserScriptValueUpdates(key, entry); + } catch { + this.userScriptConnections.delete(key); + } + }); + return true; + } + + reconnectUserScript(data: unknown, sender: IGetSender): { bootstrapToken: string } | undefined { + if (!sender.isType(GetSenderType.RUNTIME)) { + return undefined; + } + if ( + data === null || + typeof data !== "object" || + Object.keys(data).length !== 1 || + typeof (data as { reconnectToken?: unknown }).reconnectToken !== "string" || + (data as { reconnectToken: string }).reconnectToken.length === 0 || + (data as { reconnectToken: string }).reconnectToken.length > 256 + ) { + return undefined; + } + const source = sender.getSender(); + const tabId = source?.tab?.id; + if (!source || typeof tabId !== "number") return undefined; + let key: string | undefined; + let session: UserScriptSession | undefined; + for (const [candidateKey, candidateSession] of this.userScriptSessions) { + if ( + candidateSession.tabId === tabId && + candidateSession.frameId === source.frameId && + candidateSession.documentId === source.documentId && + (candidateSession.documentId !== undefined || + (typeof source.url === "string" && source.url.length > 0 && candidateSession.url === source.url)) && + candidateSession.reconnectToken === (data as { reconnectToken: string }).reconnectToken + ) { + key = candidateKey; + session = candidateSession; + break; + } + } + if (!key || !session) return undefined; + if (sender.getConnectOrigin?.() !== session.transport) return undefined; + for (const script of session.scripts) { + const handle = script.executionHandle; + const binding = typeof handle === "string" ? this.pageExecutionBindings.get(handle) : undefined; + if ( + !binding || + binding.envTag !== session.envTag || + binding.tabId !== tabId || + binding.frameId !== source.frameId || + binding.documentId !== source.documentId + ) { + this.userScriptSessions.delete(key); + return undefined; + } + } + const bootstrapToken = uuidv4(); + const nextSession = { ...session, reconnectToken: uuidv4() }; + for (const [token, bootstrap] of this.userScriptBootstraps) { + if ( + bootstrap.tabId === session.tabId && + bootstrap.frameId === session.frameId && + bootstrap.documentId === session.documentId + ) { + this.userScriptBootstraps.delete(token); + } + } + this.userScriptSessions.set(key, nextSession); + this.userScriptBootstraps.set(bootstrapToken, nextSession); + return { bootstrapToken }; + } + + private queuePendingUserScriptValueUpdate(key: string, data: ValueUpdateDataEncoded): void { + const session = this.userScriptSessions.get(key); + if (!session) return; + const previous = session.pendingValueUpdates.get(data.storageName); + if (!previous) { + session.pendingValueUpdates.set(data.storageName, data); + return; + } + const entries: ValueUpdateDataEncoded["entries"] = previous.entries.map((entry) => [entry[0], entry[1], entry[2]]); + const entryIndexes = new Map(); + for (let index = 0; index < entries.length; index += 1) entryIndexes.set(entries[index][0], index); + for (const entry of data.entries) { + const index = entryIndexes.get(entry[0]); + if (index === undefined) { + entryIndexes.set(entry[0], entries.length); + entries.push([entry[0], entry[1], entry[2]]); + } else { + entries[index] = [entry[0], entry[1], entries[index][2]]; + } + } + session.pendingValueUpdates.set(data.storageName, { + ...data, + entries, + valueUpdated: previous.valueUpdated || data.valueUpdated, + }); + } + + private flushPendingUserScriptValueUpdates( + key: string, + entry: { connection: MessageConnect; envTag: "it" | "ct" } + ): void { + const session = this.userScriptSessions.get(key); + if (!session) return; + for (const [storageName, data] of session.pendingValueUpdates) { + entry.connection.sendMessage({ + action: `${entry.envTag === "it" ? "inject" : "content"}/runtime/valueUpdate`, + data, + }); + session.pendingValueUpdates.delete(storageName); + } + } + + private sendUserScriptMessage(to: ExtMessageSender | undefined, action: string, data: unknown): void { + const dataRecord = + typeof data === "object" && data !== null ? (data as { uuid?: unknown; storageName?: unknown }) : undefined; + const targetUuid = action === "runtime/emitEvent" ? dataRecord?.uuid : undefined; + const targetStorageName = action === "runtime/valueUpdate" ? dataRecord?.storageName : undefined; + const valueUpdate = + action === "runtime/valueUpdate" && typeof dataRecord?.storageName === "string" + ? (data as ValueUpdateDataEncoded) + : undefined; + // 先按页面定位,再按句柄对应的脚本或 storageName 过滤,避免跨脚本广播私有回调。 + for (const [key, entry] of this.userScriptConnections) { + if ( + to && + (entry.tabId !== to.tabId || + (to.frameId !== undefined && entry.frameId !== to.frameId) || + (to.documentId !== undefined && entry.documentId !== to.documentId)) + ) { + continue; + } + let bindingMatches = false; + for (const handle of entry.handles) { + const binding = this.pageExecutionBindings.get(handle); + if ( + binding && + ((targetUuid !== undefined && targetUuid === binding.uuid) || + (targetStorageName !== undefined && targetStorageName === binding.storageName)) + ) { + bindingMatches = true; + break; + } + } + if (!bindingMatches) continue; + if (!entry.ready) { + if (valueUpdate) this.queuePendingUserScriptValueUpdate(key, valueUpdate); + continue; + } + try { + entry.connection.sendMessage({ action: `${entry.envTag === "it" ? "inject" : "content"}/${action}`, data }); + } catch { + this.userScriptConnections.delete(key); + if (valueUpdate) this.queuePendingUserScriptValueUpdate(key, valueUpdate); + } + } + if (!valueUpdate) return; + for (const [key, session] of this.userScriptSessions) { + if (this.userScriptConnections.has(key)) continue; + if ( + to && + (session.tabId !== to.tabId || + (to.frameId !== undefined && session.frameId !== to.frameId) || + (to.documentId !== undefined && session.documentId !== to.documentId)) + ) { + continue; + } + let bindingMatches = false; + for (const script of session.scripts) { + const handle = script.executionHandle; + const binding = typeof handle === "string" ? this.pageExecutionBindings.get(handle) : undefined; + if ( + binding && + ((targetUuid !== undefined && targetUuid === binding.uuid) || + (targetStorageName !== undefined && targetStorageName === binding.storageName)) + ) { + bindingMatches = true; + break; + } + } + if (bindingMatches) this.queuePendingUserScriptValueUpdate(key, valueUpdate); + } + } + + private revokePageBindingsForScript(uuid: string): void { + for (const [handle, binding] of this.pageExecutionBindings) { + if (binding.uuid === uuid) this.pageExecutionBindings.delete(handle); + } + for (const [key, entry] of this.userScriptConnections) { + // 脚本撤销后同步裁剪句柄集;没有任何有效句柄的端口必须关闭,避免残留授权接收器。 + for (const handle of entry.handles) { + const binding = this.pageExecutionBindings.get(handle); + if (!binding || binding.uuid === uuid) entry.handles.delete(handle); + } + if (entry.handles.size === 0) { + entry.connection.disconnect(true); + this.userScriptConnections.delete(key); + } + } + for (const [token, bootstrap] of this.userScriptBootstraps) { + if (bootstrap.scripts.some((script) => script.uuid === uuid)) this.userScriptBootstraps.delete(token); + } + for (const [key, session] of this.userScriptSessions) { + if (session.scripts.some((script) => script.uuid === uuid)) this.userScriptSessions.delete(key); + } + } + + private issuePageBinding( + uuid: string, + envTag: "it" | "ct", + storageName: string, + allowedAPIs: readonly string[], + sender: IGetSender + ): ServiceWorkerExecutionBinding { + const source = sender.getSender(); + const tabId = source?.tab?.id; + const url = source?.url; + if (typeof tabId !== "number" || typeof url !== "string" || url.length === 0) { + throw new Error("page execution binding requires a tab and URL"); + } + // 每次 pageLoad 都签发新句柄和 runFlag;它们共同绑定当前文档的授权生命周期。 + const handle = uuidv4(); + const binding = { + handle, + uuid, + envTag, + runFlag: uuidv4(), + url, + tabId, + frameId: source?.frameId, + documentId: source?.documentId, + storageName, + allowedAPIs: new Set(allowedAPIs), + requestIds: new Set(), + } satisfies ServiceWorkerExecutionBinding; + this.pageExecutionBindings.set(handle, binding); + return binding; + } + + resolvePageExecutionBinding(handle: string, sender: IGetSender): ServiceWorkerExecutionBinding | undefined { + const binding = this.pageExecutionBindings.get(handle); + const source = sender.getSender(); + if ( + !binding || + !source?.tab || + source.tab.id !== binding.tabId || + source.frameId !== binding.frameId || + (binding.documentId === undefined && source.url !== binding.url) + ) + return undefined; + if (binding.documentId !== undefined && source.documentId !== binding.documentId) return undefined; + return binding; + } + private readonly disabledMatcherTaskKey = `runtime_disabled_matcher:${Math.random()}`; private disabledMatcher: UrlMatch | null = null; private disabledMatcherVersion = 0; @@ -478,6 +941,8 @@ export class RuntimeService { sendData, }, }); + // USER_SCRIPT 看不到 scripting world 的页面广播,改经原生扩展连接投递同一份编码 DTO。 + this.sendUserScriptMessage(undefined, "runtime/valueUpdate", sendData); // 後台腳本 if (bgScriptStorageNames.has(sendData.storageName)) { @@ -527,7 +992,8 @@ export class RuntimeService { this.msgSender, this.mq, this.value, - new GMExternalDependencies(this) + new GMExternalDependencies(this), + this.resolvePageExecutionBinding.bind(this) ); permission.init(); this.gmApi.start(); @@ -536,6 +1002,8 @@ export class RuntimeService { this.group.on("runScript", this.runScript.bind(this)); this.group.on("pageLoad", this.pageLoad.bind(this)); this.group.on("pageShow", this.pageShow.bind(this)); + this.group.on("registerUserScript", this.registerUserScriptConnection.bind(this)); + this.group.on("reconnectUserScript", this.reconnectUserScript.bind(this)); // 监听脚本开启 this.mq.subscribe("enableScripts", async (data) => { @@ -548,6 +1016,7 @@ export class RuntimeService { const unregisterUuids = [] as string[]; for (const { uuid, enable } of data) { + this.revokePageBindingsForScript(uuid); const script = await this.scriptDAO.get(uuid); if (!script) { this.logger.error("script enable failed, script not found", { @@ -582,6 +1051,7 @@ export class RuntimeService { // 监听脚本安装 this.mq.subscribe("installScript", async (data) => { const uuid = data.script.uuid; + this.revokePageBindingsForScript(uuid); this.invalidateDisabledMatcher(); this.deleteScriptRuntimeCache(uuid); @@ -620,6 +1090,7 @@ export class RuntimeService { const unregisterUuids = [] as string[]; this.updateSorter((next) => { for (const { uuid } of data) { + this.revokePageBindingsForScript(uuid); unregisterUuids.push(uuid); this.deleteScriptRuntimeCache(uuid); this.deleteScriptSort(next, uuid); @@ -844,6 +1315,12 @@ export class RuntimeService { // 取消脚本注册 async unregisterUserscripts() { + this.pageExecutionBindings.clear(); + this.userScriptSessions.clear(); + for (const [key, entry] of this.userScriptConnections) { + entry.connection.disconnect(true); + this.userScriptConnections.delete(key); + } // 检查 registered 避免重复操作增加系统开支 // 已成功注册(true)或是未知有无注册(null)的情况下执行 if (runtimeGlobal.registerState !== RuntimeRegisterCode.UNREGISTER_DONE) { @@ -1160,6 +1637,7 @@ export class RuntimeService { // 如果是-1, 代表给offscreen发送消息 return sendMessage(this.msgSender, "offscreen/runtime/emitEvent", req); } + this.sendUserScriptMessage(to, "runtime/emitEvent", req); return sendMessage( new ExtensionContentMessageSend(to.tabId, { documentId: to.documentId, @@ -1266,17 +1744,26 @@ export class RuntimeService { } } - async pageLoad(_: any, sender: IGetSender): Promise { + async pageLoad(data: { envTag?: "it" | "ct" } | undefined, sender: IGetSender): Promise { + // USER_SCRIPT 只能通过一次性 bootstrap 获取 content-world 资料,不能自行请求 pageLoad。 + if (sender.getConnectOrigin?.() === "userScript") return { ok: false }; const chromeSender = sender.getSender(); const url = chromeSender?.url; if (!url) { // 异常加载 return { ok: false }; } - const tabId = chromeSender.tab?.id || -1; + const tabId = chromeSender.tab?.id ?? -1; const frameId = chromeSender.frameId; const incognito = chromeSender.tab?.incognito ?? false; + const pageLoadSequence = this.beginPageLoadSequence(sender, data?.envTag); const res = await this.getScriptsForTab({ url, tabId, frameId, incognito }); + if (pageLoadSequence && this.pageLoadSequences.get(pageLoadSequence[0]) !== pageLoadSequence[1]) { + return { ok: false }; + } + + // 即使新 URL 没有匹配脚本也要退休旧绑定,关闭不提供 documentId 的浏览器复用窗口。 + this.revokePageBindings(sender, data?.envTag); this.mq.emit("popupPageLoadUpdate", { tabId: tabId, @@ -1286,12 +1773,55 @@ export class RuntimeService { }); if (res) { + const prepareScripts = (scripts: TScriptInfo[], envTag: "it" | "ct") => + scripts.map((script) => { + const binding = this.issuePageBinding( + script.uuid, + envTag, + getStorageName(script), + getPageRpcAllowedAPIs(script.metadata.grant || []), + sender + ); + return { + ...script, + executionHandle: binding.handle, + executionEnvTag: envTag, + executionRunFlag: binding.runFlag, + }; + }); + const injectScriptList = data?.envTag === "ct" ? [] : prepareScripts(res.injectScriptList, "it"); + const contentScriptList = prepareScripts(res.contentScriptList, "ct"); + let userScriptBootstrapToken: string | undefined; + let userScriptInjectBootstrapToken: string | undefined; + if (data?.envTag === "it") { + const createBootstrap = (scripts: TScriptInfo[], envTag: "it" | "ct"): string | undefined => { + if (scripts.length === 0) return undefined; + const token = uuidv4(); + this.userScriptBootstraps.set(token, { + scripts, + envInfo: res.envInfo, + extensionOrigin: getExtensionOrigin(), + reconnectToken: token, + envTag, + url, + tabId, + frameId, + documentId: chromeSender.documentId, + pendingValueUpdates: new Map(), + }); + return token; + }; + userScriptInjectBootstrapToken = createBootstrap(injectScriptList, "it"); + userScriptBootstrapToken = createBootstrap(contentScriptList, "ct"); + } // 返回脚本资料,在页面加载 return { ok: true, - injectScriptList: res.injectScriptList, - contentScriptList: res.contentScriptList, + injectScriptList, + contentScriptList: data?.envTag === "it" ? [] : contentScriptList, envInfo: res.envInfo, + userScriptBootstrapToken, + userScriptInjectBootstrapToken, }; } else { // 没有脚本资料,不需要加载 @@ -1308,7 +1838,7 @@ export class RuntimeService { const url = chromeSender?.url; if (!url) return; this.mq.emit("popupPageRestored", { - tabId: chromeSender.tab?.id || -1, + tabId: chromeSender.tab?.id ?? -1, frameId: chromeSender.frameId, url, }); diff --git a/src/app/service/service_worker/types.ts b/src/app/service/service_worker/types.ts index 5197fa039..00397d794 100644 --- a/src/app/service/service_worker/types.ts +++ b/src/app/service/service_worker/types.ts @@ -46,6 +46,31 @@ export type MessageRequest = { api: string; runFlag: string; params: T; + /** 页面执行环境绑定的能力句柄;后台脚本不携带此字段。 */ + executionHandle?: string; + /** 页面 GM RPC 的版本和请求关联字段。 */ + version?: 1; + requestId?: string; + handle?: string; + envTag?: "it" | "ct"; +}; + +export type ServiceWorkerExecutionBinding = { + handle: string; + uuid: string; + envTag: "it" | "ct"; + runFlag: string; + /** The URL observed when this execution binding was issued. */ + url: string; + tabId: number; + frameId?: number; + documentId?: string; + /** 用于只向运行该脚本的文档投递值更新的存储命名空间。 */ + storageName: string; + /** 隔离 GM API broker 为本次页面执行接受的能力名称。 */ + allowedAPIs: ReadonlySet; + /** 已接受的页面请求 ID;绑定销毁时一并释放,确保绑定存续期间拒绝重放。 */ + requestIds: Set; }; export type GMApiRequest = MessageRequest & { diff --git a/src/app/service/service_worker/utils.test.ts b/src/app/service/service_worker/utils.test.ts index 94c31b627..f856b1954 100644 --- a/src/app/service/service_worker/utils.test.ts +++ b/src/app/service/service_worker/utils.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import { isBase64, parseUrlSRI, @@ -6,14 +6,16 @@ import { selfMetadataUpdate, getUserScriptRegister, compileInjectionCode, + parseScriptLoadInfo, shouldAutoOpenChangelog, scriptURLPatternResults, } from "./utils"; -import type { SCMetadata, Script, ScriptRunResource } from "@App/app/repo/scripts"; +import type { SCMetadata, ScriptLoadInfo, Script } from "@App/app/repo/scripts"; import { SELF_METADATA_ONLY_RUN_ON_URL } from "@App/app/repo/metadata"; import { SCRIPT_TYPE_NORMAL, SCRIPT_STATUS_ENABLE, SCRIPT_RUN_STATUS_COMPLETE } from "@App/app/repo/scripts"; import type { ScriptMatchInfo } from "./types"; import { extractUrlPatterns, RuleTypeBit } from "@App/pkg/utils/url_matcher"; +import { compilePreInjectScript } from "../content/utils"; describe.concurrent("parseUrlSRI", () => { it.concurrent("should parse URL SRI", () => { @@ -311,7 +313,7 @@ describe.concurrent("getUserScriptRegister", () => { }); describe.concurrent("compileInjectionCode", () => { - const createMockScriptRes = (overrides: Partial = {}): ScriptRunResource => ({ + const createMockScriptRes = (overrides: Partial = {}): ScriptLoadInfo => ({ uuid: "test-uuid", name: "Test Script", namespace: "test.namespace", @@ -327,6 +329,8 @@ describe.concurrent("compileInjectionCode", () => { resource: {}, metadata: {}, originalMetadata: {}, + metadataStr: "", + userConfigStr: "", ...overrides, }); @@ -354,9 +358,32 @@ describe.concurrent("compileInjectionCode", () => { // 包含沙箱封装 expect(result).toContain("with(arguments[0]||this.$)"); - expect(result).toContain("return(async function(){"); - // 使用 compileInjectScript 包裹(window[flag] = function(){...}) - expect(result).toContain("window['#-test-uuid']"); + expect(result).toContain("this[arguments[0]='$$'+Date.now()/Math.random()]=async function(){"); + // 使用 compileInjectScript 包裹并挂载脚本标志 + expect(result).toContain("window, '#-test-uuid'"); + }); + + it.concurrent("预注入脚本在派发事件前执行精确 URL 规则", () => { + const scriptRes = createMockScriptRes({ + metadata: { "early-start": [""], "run-at": ["document-start"] }, + scriptUrlPatterns: extractUrlPatterns(["@include /example\\.com/"]), + }); + const result = compilePreInjectScript(parseScriptLoadInfo(scriptRes, scriptRes.scriptUrlPatterns ?? []), "", false); + const dispatchEvent = vi.fn(() => true); + const performance = { dispatchEvent, addEventListener: vi.fn() }; + const customEvent = class { + constructor( + readonly type: string, + readonly init: unknown + ) {} + }; + const run = new Function("window", "performance", "CustomEvent", "location", result); + + run(Object.create(null), performance, customEvent, { href: "https://other.example/" }); + expect(dispatchEvent).not.toHaveBeenCalled(); + + run(Object.create(null), performance, customEvent, { href: "https://example.com/" }); + expect(dispatchEvent).toHaveBeenCalledTimes(1); }); }); diff --git a/src/app/service/service_worker/value.test.ts b/src/app/service/service_worker/value.test.ts index 7dcc2ae9a..a73e0a486 100644 --- a/src/app/service/service_worker/value.test.ts +++ b/src/app/service/service_worker/value.test.ts @@ -100,6 +100,51 @@ describe("ValueService - setValue 方法测试", () => { vi.restoreAllMocks(); }); + it("persists __proto__ as an own value key without polluting inherited values", async () => { + const mockScript = createMockScript(); + const stored = { leaked: "secret" }; + vi.mocked(mockScriptDAO.get).mockResolvedValue(mockScript); + vi.mocked(mockValueDAO.get).mockResolvedValue(undefined); + vi.mocked(mockValueDAO.save).mockResolvedValue({} as any); + + await valueService.setValues({ + uuid: mockScript.uuid, + keyValuePairs: [["__proto__", encodeRValue(stored)]], + valueSender: createMockValueSender(), + isReplace: false, + }); + + const savedData = vi.mocked(mockValueDAO.save).mock.calls[0][1].data; + expect(Object.prototype.hasOwnProperty.call(savedData, "__proto__")).toBe(true); + expect(Object.getPrototypeOf(savedData)).toBe(Object.prototype); + expect(savedData.__proto__).toEqual(stored); + expect((savedData as Record).leaked).toBeUndefined(); + }); + + it("does not let a bound config key change the returned value object's prototype", async () => { + const mockScript = createMockScript({ + config: { + settings: { + setting: { + bind: "$__proto__", + default: { polluted: true }, + index: 0, + }, + }, + } as any, + }); + const stored = {}; + vi.mocked(mockScriptDAO.get).mockResolvedValue(mockScript); + vi.mocked(mockValueDAO.get).mockResolvedValue({ data: stored } as any); + + const values = await valueService.getScriptValue(mockScript); + + expect(Object.getPrototypeOf(values)).toBeNull(); + expect(Object.prototype.hasOwnProperty.call(values, "__proto__")).toBe(true); + expect(values.__proto__).toBeUndefined(); + expect((values as Record).polluted).toBeUndefined(); + }); + it("应该成功设置新脚本的值", async () => { // 准备测试数据 const mockScript = createMockScript(); diff --git a/src/app/service/service_worker/value.ts b/src/app/service/service_worker/value.ts index 6f5af7ba5..fd741d9c8 100644 --- a/src/app/service/service_worker/value.ts +++ b/src/app/service/service_worker/value.ts @@ -15,6 +15,15 @@ import { stackAsyncTask } from "@App/pkg/utils/async_queue"; import type { TKeyValuePair } from "@App/pkg/utils/message_value"; import { decodeRValue, R_UNDEFINED, encodeRValue } from "@App/pkg/utils/message_value"; +const setOwnValue = (store: Record, key: string, value: any): void => { + Object.defineProperty(store, key, { + configurable: true, + enumerable: true, + writable: true, + value, + }); +}; + export type TSetValuesParams = { uuid: string; id?: string; @@ -41,10 +50,12 @@ export class ValueService { } async getScriptValueDetails(script: Script) { - let data: { [key: string]: any } = {}; + const data: { [key: string]: any } = Object.create(null); const ret = await this.valueDAO.get(getStorageName(script)); if (ret) { - data = ret.data; + for (const key of Object.keys(ret.data)) { + setOwnValue(data, key, ret.data[key]); + } } const newValues = data; // 和userconfig组装 @@ -62,10 +73,13 @@ export class ValueService { // 动态变量 if (tab[key].bind) { const bindKey = tab[key].bind!.substring(1); - newValues[bindKey] = data[bindKey] === undefined ? undefined : data[bindKey]; + setOwnValue(newValues, bindKey, data[bindKey] === undefined ? undefined : data[bindKey]); } - newValues[`${tabKey}.${key}`] = - data[`${tabKey}.${key}`] === undefined ? tab[key].default : data[`${tabKey}.${key}`]; + setOwnValue( + newValues, + `${tabKey}.${key}`, + data[`${tabKey}.${key}`] === undefined ? tab[key].default : data[`${tabKey}.${key}`] + ); } } } @@ -107,7 +121,7 @@ export class ValueService { for (const [key, rTyped1] of keyValuePairs) { const value = decodeRValue(rTyped1); if (value !== undefined) { - dataModel[key] = value; + setOwnValue(dataModel, key, value); entries.push([key, rTyped1, R_UNDEFINED]); } } @@ -134,7 +148,7 @@ export class ValueService { if (value === undefined) { delete dataModel[key]; } else { - dataModel[key] = value; + setOwnValue(dataModel, key, value); } const rTyped2 = encodeRValue(oldValue); entries.push([key, rTyped1, rTyped2]); diff --git a/src/content.ts b/src/content.ts index 094f345ac..3f68af55c 100644 --- a/src/content.ts +++ b/src/content.ts @@ -1,20 +1,28 @@ import LoggerCore from "./app/logger/core"; import MessageWriter from "./app/logger/message_writer"; +import { ExtensionMessage } from "@Packages/message/extension_message"; import { CustomEventMessage } from "@Packages/message/custom_event_message"; import { Server } from "@Packages/message/server"; import { ScriptExecutor } from "./app/service/content/script_executor"; -import type { Message } from "@Packages/message/types"; +import type { Message, MessageConnect, TMessage } from "@Packages/message/types"; import { getEventFlag } from "@Packages/message/common"; import { ScriptRuntime } from "./app/service/content/script_runtime"; import { ScriptEnvTag } from "@Packages/message/consts"; import { type TExtensionEnv } from "./app/service/extension/extension_env"; +import { connectUserScriptChannel, requestUserScriptReconnect } from "./app/service/content/user_script_connection"; +import type { GMInfoEnv } from "./app/service/content/types"; +import type { ExtensionOrigin } from "./app/service/content/page_rpc"; const messageFlag = process.env.SC_RANDOM_KEY!; getEventFlag(messageFlag, (eventFlag: string, extensionEnv: TExtensionEnv | undefined) => { const scriptEnvTag = ScriptEnvTag.content; - const msg: Message = new CustomEventMessage(eventFlag, false, scriptEnvTag); + // USER_SCRIPT 使用浏览器原生扩展通道;DOM 通道只保留同步元素辅助 API, + // 因为节点引用必须留在当前 content realm。 + const msg: Message = new ExtensionMessage(false); + const domMsg = new CustomEventMessage(eventFlag, false, scriptEnvTag); + const domContentMsg = new CustomEventMessage(eventFlag, true, scriptEnvTag); // 初始化日志组件 const logger = new LoggerCore({ @@ -26,8 +34,46 @@ getEventFlag(messageFlag, (eventFlag: string, extensionEnv: TExtensionEnv | unde logger.logger().debug("content start"); const server = new Server("content", msg); - const scriptExecutor = new ScriptExecutor(msg, new CustomEventMessage(eventFlag, true, scriptEnvTag)); + const domServer = new Server("content", domMsg); + const scriptExecutor = new ScriptExecutor(msg, domContentMsg, "serviceWorker"); const runtime = new ScriptRuntime(scriptEnvTag, server, msg, scriptExecutor, extensionEnv); - runtime.contentInit(); + runtime.contentInit(domServer, domMsg); + let reconnecting = false; + let reconnectToken: string | undefined; + const handleUserScriptPacket = (_connection: MessageConnect, packet: TMessage) => { + if (packet.action === "content/pageLoad") { + const nextToken = runtime.receivePageLoad(packet.data); + if (nextToken) reconnectToken = nextToken; + } else if (packet.action === "content/runtime/valueUpdate") { + runtime.receiveValueUpdate(packet.data); + } else if (packet.action === "content/runtime/emitEvent") { + runtime.receiveEmitEvent(packet.data); + } + }; + const openUserScriptChannel = async (bootstrapToken: string): Promise => { + try { + await connectUserScriptChannel(msg, bootstrapToken, handleUserScriptPacket, (isSelfDisconnected) => { + if (isSelfDisconnected || reconnecting) return; + if (!reconnectToken) return; + reconnecting = true; + void requestUserScriptReconnect(msg, reconnectToken) + .then((nextToken) => (nextToken ? openUserScriptChannel(nextToken) : undefined)) + .catch((error) => logger.logger().debug("USER_SCRIPT reconnect failed", { error: String(error) })) + .finally(() => { + reconnecting = false; + }); + }); + } catch (error) { + logger.logger().debug("USER_SCRIPT channel failed", { error: String(error) }); + } + }; + domServer.on( + "pageLoad", + (data: { bootstrapToken?: unknown; envInfo?: GMInfoEnv; extensionOrigin?: ExtensionOrigin }) => { + if (typeof data?.bootstrapToken !== "string" || data.bootstrapToken.length === 0) return; + reconnectToken = data.bootstrapToken; + void openUserScriptChannel(data.bootstrapToken); + } + ); runtime.init(); }); diff --git a/src/inject.ts b/src/inject.ts index 0d13290da..ec44e611c 100644 --- a/src/inject.ts +++ b/src/inject.ts @@ -1,24 +1,36 @@ import LoggerCore from "./app/logger/core"; import MessageWriter from "./app/logger/message_writer"; import { CustomEventMessage } from "@Packages/message/custom_event_message"; +import { PageMessage } from "@Packages/message/page_message"; +import { ExtensionMessage, hasNativeRuntimeChannel } from "@Packages/message/extension_message"; import { Server } from "@Packages/message/server"; +import { Client } from "@Packages/message/client"; import { ScriptExecutor } from "./app/service/content/script_executor"; import type { Message } from "@Packages/message/types"; import { getEventFlag } from "@Packages/message/common"; import { ScriptRuntime } from "./app/service/content/script_runtime"; import { ScriptEnvTag } from "@Packages/message/consts"; import { type TExtensionEnv } from "./app/service/extension/extension_env"; +import { connectUserScriptChannel, requestUserScriptReconnect } from "./app/service/content/user_script_connection"; +import type { MessageConnect, TMessage } from "@Packages/message/types"; +import { createMainWorldPageLoadGate } from "./app/service/content/main_world_page_load_gate"; const messageFlag = process.env.SC_RANDOM_KEY!; +const NATIVE_BOOTSTRAP_TIMEOUT_MS = 1000; + getEventFlag(messageFlag, (eventFlag: string, extensionEnv: TExtensionEnv | undefined) => { const scriptEnvTag = ScriptEnvTag.inject; - const msg: Message = new CustomEventMessage(eventFlag, false, scriptEnvTag); + const pageMsg: Message = new PageMessage(eventFlag, "inject"); + const nativeMsg: Message = new ExtensionMessage(false); + // 特权 GM RPC 使用浏览器标记的 USER_SCRIPT 来源;页面桥只保留 bootstrap 与 DOM 引用辅助。 + const canUseNativeChannel = hasNativeRuntimeChannel; + const msg: Message = canUseNativeChannel ? nativeMsg : pageMsg; // 初始化日志组件 const logger = new LoggerCore({ - writer: new MessageWriter(msg, "scripting/logger"), + writer: new MessageWriter(msg, canUseNativeChannel ? "serviceWorker/logger" : "scripting/logger"), consoleLevel: process.env.NODE_ENV === "development" ? "debug" : "none", // 只让日志在scripting环境中打印 labels: { env: "inject", href: window.location.href }, }); @@ -26,10 +38,123 @@ getEventFlag(messageFlag, (eventFlag: string, extensionEnv: TExtensionEnv | unde logger.logger().debug("inject start"); const server = new Server("inject", msg); - const scriptExecutor = new ScriptExecutor(msg, new CustomEventMessage(eventFlag, true, ScriptEnvTag.content)); + const scriptExecutor = new ScriptExecutor( + msg, + new CustomEventMessage(eventFlag, true, ScriptEnvTag.content), + canUseNativeChannel ? "serviceWorker" : "scripting" + ); const runtime = new ScriptRuntime(scriptEnvTag, server, msg, scriptExecutor, extensionEnv); - runtime.init(); + const pageServer = canUseNativeChannel ? new Server("inject", pageMsg) : undefined; + let reconnecting = false; + let openingNative = false; + let nativeConnection: MessageConnect | undefined; + let pendingNativeReady: + | { + resolve: (connected: boolean) => void; + timer: ReturnType; + } + | undefined; + let reconnectToken: string | undefined; + + const settleNativeReady = (connected: boolean): void => { + const pending = pendingNativeReady; + if (!pending) return; + pendingNativeReady = undefined; + clearTimeout(pending.timer); + pending.resolve(connected); + }; + + const handleNativePacket = (_connection: MessageConnect, packet: TMessage) => { + if (packet.action === "inject/pageLoad") { + if (!pendingNativeReady) return; + nativeConnection = _connection; + settleNativeReady(true); + const nextToken = runtime.receivePageLoad(packet.data); + if (nextToken) reconnectToken = nextToken; + } else if (packet.action === "inject/runtime/valueUpdate") { + runtime.receiveValueUpdate(packet.data); + } else if (packet.action === "inject/runtime/emitEvent") { + runtime.receiveEmitEvent(packet.data); + } + }; + const openNativeChannel = async (bootstrapToken: string): Promise => { + if (openingNative || nativeConnection) return Boolean(nativeConnection); + openingNative = true; + return new Promise((resolve) => { + const timer = setTimeout(() => { + const connection = nativeConnection; + nativeConnection = undefined; + settleNativeReady(false); + try { + connection?.disconnect(true); + } catch (error) { + logger.logger().debug("MAIN USER_SCRIPT channel cleanup failed", { error: String(error) }); + } + }, NATIVE_BOOTSTRAP_TIMEOUT_MS); + pendingNativeReady = { resolve, timer }; + + void connectUserScriptChannel( + nativeMsg, + bootstrapToken, + handleNativePacket, + (isSelfDisconnected) => { + nativeConnection = undefined; + settleNativeReady(false); + if (isSelfDisconnected || reconnecting || !reconnectToken) return; + reconnecting = true; + void requestUserScriptReconnect(nativeMsg, reconnectToken) + .then((nextToken) => (nextToken ? openNativeChannel(nextToken) : undefined)) + .catch((error) => logger.logger().debug("MAIN USER_SCRIPT reconnect failed", { error: String(error) })) + .finally(() => { + reconnecting = false; + }); + }, + "MAIN" + ) + .then((connection) => { + if (!connection) { + settleNativeReady(false); + return; + } + if (pendingNativeReady || nativeConnection === connection) { + nativeConnection = connection; + return; + } + connection.disconnect(true); + }) + .catch((error) => { + logger.logger().debug("MAIN USER_SCRIPT channel failed", { error: String(error) }); + settleNativeReady(false); + }) + .finally(() => { + openingNative = false; + }); + }); + }; + + if (pageServer) { + const pageLoadGate = createMainWorldPageLoadGate( + openNativeChannel, + (data) => runtime.receivePageLoad(data), + () => { + void new Client(pageMsg, "scripting").do("pageLoadFallback"); + } + ); + pageServer.on("bootstrap", (data: { bootstrapToken?: unknown }) => { + if (typeof data?.bootstrapToken !== "string" || data.bootstrapToken.length === 0) return; + reconnectToken = data.bootstrapToken; + pageLoadGate.onBootstrap(data.bootstrapToken); + }); + pageServer.on("pageLoad", pageLoadGate.onPageLoad); + } + runtime.init(); + if (!pageServer) { + // 没有原生 runtime 通道时,bootstrap 只作为页面桥上的兼容握手,随后请求完整 pageLoad。 + server.on("bootstrap", () => { + void new Client(pageMsg, "scripting").do("pageLoadFallback"); + }); + } // inject环境,直接判断白名单,注入对外接口 - runtime.externalMessage(); + runtime.externalMessage("scripting", pageMsg); }); diff --git a/src/scripting.ts b/src/scripting.ts index fa943e7ba..0d14a8eaa 100644 --- a/src/scripting.ts +++ b/src/scripting.ts @@ -3,6 +3,7 @@ import LoggerCore from "./app/logger/core"; import MessageWriter from "./app/logger/message_writer"; import type { Message } from "@Packages/message/types"; import { CustomEventMessage } from "@Packages/message/custom_event_message"; +import { PageMessage } from "@Packages/message/page_message"; import { ScriptEnvTag } from "@Packages/message/consts"; import { Server } from "@Packages/message/server"; import ScriptingRuntime from "./app/service/content/scripting"; @@ -24,7 +25,7 @@ negotiateEventFlag(messageFlag, extensionEnv, 2, (eventFlag) => { logger.logger().debug("scripting start"); const contentMsg = new CustomEventMessage(eventFlag, true, ScriptEnvTag.content); - const injectMsg = new CustomEventMessage(eventFlag, true, ScriptEnvTag.inject); + const injectMsg = new PageMessage(eventFlag, "scripting"); const server = new Server("scripting", [contentMsg, injectMsg]); diff --git a/tests/runtime/gm_api.test.ts b/tests/runtime/gm_api.test.ts index e1840b447..ef407a2c9 100644 --- a/tests/runtime/gm_api.test.ts +++ b/tests/runtime/gm_api.test.ts @@ -146,7 +146,7 @@ describe.concurrent("测试GMApi环境 - XHR", async () => { }); const onload = vitest.fn(); await new Promise((resolve) => { - gmApi.GM_xmlhttpRequest({ + gmApi.GM_xmlhttpRequest(gmApi, { url: testUrl, onload: (res) => { resolve(true); @@ -169,7 +169,7 @@ describe.concurrent("测试GMApi环境 - XHR", async () => { }); const onload = vitest.fn(); await new Promise((resolve) => { - gmApi.GM_xmlhttpRequest({ + gmApi.GM_xmlhttpRequest(gmApi, { fetch: true, url: testUrl, onload: (res) => { @@ -209,7 +209,7 @@ describe.concurrent("测试GMApi环境 - XHR", async () => { }); const onload = vitest.fn(); await new Promise((resolve) => { - gmApi.GM_xmlhttpRequest({ + gmApi.GM_xmlhttpRequest(gmApi, { url: testUrl, responseType: "blob", onload: (res) => { @@ -250,7 +250,7 @@ describe.concurrent("测试GMApi环境 - XHR", async () => { const fn1 = vitest.fn(); const fn2 = vitest.fn(); await new Promise((resolve) => { - gmApi.GM_xmlhttpRequest({ + gmApi.GM_xmlhttpRequest(gmApi, { fetch: true, responseType: "blob", url: "https://mock-xmlhttprequest.test/", @@ -288,7 +288,7 @@ describe.concurrent("测试GMApi环境 - XHR", async () => { const fn1 = vitest.fn(); const fn2 = vitest.fn(); await new Promise((resolve) => { - gmApi.GM_xmlhttpRequest({ + gmApi.GM_xmlhttpRequest(gmApi, { url: testUrl, responseType: "json", onload: (res) => { @@ -319,7 +319,7 @@ describe.concurrent("测试GMApi环境 - XHR", async () => { const fn1 = vitest.fn(); const fn2 = vitest.fn(); await new Promise((resolve) => { - gmApi.GM_xmlhttpRequest({ + gmApi.GM_xmlhttpRequest(gmApi, { fetch: true, url: testUrl, responseType: "json", @@ -346,7 +346,7 @@ describe.concurrent("GM xmlHttpRequest", () => { }); it.concurrent("get", () => { return new Promise((resolve) => { - gmApi.GM_xmlhttpRequest({ + gmApi.GM_xmlhttpRequest(gmApi, { url: "https://www.example.com", onreadystatechange: (resp) => { if (resp.readyState === 4 && resp.status === 200) { @@ -361,7 +361,7 @@ describe.concurrent("GM xmlHttpRequest", () => { // xml原版是没有responseText的,但是tampermonkey有,恶心的兼容性 it.concurrent("json", async () => { await new Promise((resolve) => { - gmApi.GM_xmlhttpRequest({ + gmApi.GM_xmlhttpRequest(gmApi, { url: "https://example.com/json", method: "GET", responseType: "json", @@ -375,7 +375,7 @@ describe.concurrent("GM xmlHttpRequest", () => { }); // bad json await new Promise((resolve) => { - gmApi.GM_xmlhttpRequest({ + gmApi.GM_xmlhttpRequest(gmApi, { url: "https://www.example.com/", method: "GET", responseType: "json", @@ -389,7 +389,7 @@ describe.concurrent("GM xmlHttpRequest", () => { }); it.concurrent("header", async () => { await new Promise((resolve) => { - gmApi.GM_xmlhttpRequest({ + gmApi.GM_xmlhttpRequest(gmApi, { url: "https://www.example.com/header", method: "GET", headers: { @@ -409,7 +409,7 @@ describe.concurrent("GM xmlHttpRequest", () => { }); it.concurrent("404", async () => { await new Promise((resolve) => { - gmApi.GM_xmlhttpRequest({ + gmApi.GM_xmlhttpRequest(gmApi, { url: "https://www.example.com/notexist", method: "GET", onload: (resp) => { @@ -441,7 +441,7 @@ describe("GM download", () => { const onprogress = vitest.fn(); await new Promise((resolve) => { - gmApi.GM_download({ + gmApi.GM_download(gmApi, { url: "https://download.test/", name: "example.txt", onprogress: onprogress, diff --git a/vitest.config.ts b/vitest.config.ts index dcb067830..26ac0d507 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -45,6 +45,8 @@ const sharedTest = { env: { VI_TESTING: "true", SC_RANDOM_KEY: "005a7deb-3a6e-4337-83ea-b9626c02ea38", + SC_RANDOM_FNKEY: "843078d2-403b-4ec0-a6e0-358488e135ec", + SC_ZN_RAND: "4622da29-026c-47d1-a8f8-ee52bad37129", }, };