diff --git a/src/app/message/common.ts b/src/app/message/common.ts index 5a4416c8d..527331e35 100644 --- a/src/app/message/common.ts +++ b/src/app/message/common.ts @@ -2,7 +2,7 @@ export const MouseEventClone = MouseEvent; export const CustomEventClone = CustomEvent; -const performanceClone = (typeof process !== "undefined" && process.env.VI_TESTING === "true" +const performanceClone = (process.env.VI_TESTING === "true" ? new EventTarget() : performance) as Performance; diff --git a/src/app/message/content.ts b/src/app/message/content.ts index 28bec38f2..8a319128e 100644 --- a/src/app/message/content.ts +++ b/src/app/message/content.ts @@ -29,6 +29,7 @@ export default class MessageContent constructor(eventId: string, isContent: boolean) { super(); + if (!eventId || eventId[0] === "{") throw new Error("eventId is missing"); this.eventId = eventId; this.isContent = isContent; this.channelManager = new WarpChannelManager((data) => { diff --git a/src/app/message/message.test.ts b/src/app/message/message.test.ts index d78b873d8..a80e9ab94 100644 --- a/src/app/message/message.test.ts +++ b/src/app/message/message.test.ts @@ -21,7 +21,7 @@ global.sandbox = {}; const center = new MessageCenter(); center.start(); -const content = new MessageInternal("background"); +const content = new MessageInternal("testing"); describe("message center", () => { it("set handler", async () => { diff --git a/src/app/message/message.ts b/src/app/message/message.ts index 15fbfe02b..59728eb8f 100644 --- a/src/app/message/message.ts +++ b/src/app/message/message.ts @@ -26,7 +26,7 @@ export type HandlerWithChannel = ( ) => void; export type TargetTag = - | "background" + | "testing" | "content" | "sandbox" | "popup" diff --git a/src/content.ts b/src/content.ts index f0254ecf9..b76db6192 100644 --- a/src/content.ts +++ b/src/content.ts @@ -17,18 +17,21 @@ const logger = new LoggerCore({ const scriptFlag = randomString(8); +// 通过flag与inject建立通讯 +const contentMessage = new MessageContent(scriptFlag, true); + // 注入运行框架 const temp = document.createElementNS("http://www.w3.org/1999/xhtml", "script"); temp.setAttribute("type", "text/javascript"); temp.setAttribute("charset", "UTF-8"); -temp.textContent = `(function (ScriptFlag) {\n${injectJs}\n})('${scriptFlag}')${sourceMapTo("injected.js")}`; +const injectJsConv = injectJs.replace("{{__ScriptFlag__}}", () =>`${scriptFlag}`); +temp.textContent = `(function () {\n${injectJsConv}\n})()${sourceMapTo("injected.js")}`; temp.className = "injected-js"; document.documentElement.appendChild(temp); temp.remove(); internalMessage.syncSend("pageLoad", null).then((resp) => { logger.logger().debug("content start"); - // 通过flag与inject建立通讯 - const contentMessage = new MessageContent(scriptFlag, true); - new ContentRuntime(contentMessage, internalMessage).start(resp); + const contentRuntime = new ContentRuntime(contentMessage, internalMessage); + contentRuntime.start(resp); }); diff --git a/src/inject.ts b/src/inject.ts index 8487f039e..6932e33de 100644 --- a/src/inject.ts +++ b/src/inject.ts @@ -1,12 +1,13 @@ import LoggerCore from "./app/logger/core"; import MessageWriter from "./app/logger/message_writer"; import MessageContent from "./app/message/content"; +import { type ScriptRunResource } from "./app/repo/scripts"; import InjectRuntime from "./runtime/content/inject"; // 通过flag与content建立通讯,这个ScriptFlag是后端注入时候生成的 -// eslint-disable-next-line no-undef -const flag = ScriptFlag; +const flag = "{{__ScriptFlag__}}"; +// 通过flag与content建立通讯 const message = new MessageContent(flag, false); // 加载logger组件 @@ -16,9 +17,8 @@ const logger = new LoggerCore({ labels: { env: "inject", href: window.location.href }, }); - -message.setHandler("pageLoad", (_action, data) => { +message.setHandler("pageLoad", (_action, resp: { scripts: ScriptRunResource[], executionToken?: string }) => { logger.logger().debug("inject start"); - const runtime = new InjectRuntime(message, data.scripts, flag); - runtime.start(); + const runtime = new InjectRuntime(message); + runtime.start(resp); }); diff --git a/src/runtime/background/gm_api.ts b/src/runtime/background/gm_api.ts index 25d5e7dc1..db7384641 100644 --- a/src/runtime/background/gm_api.ts +++ b/src/runtime/background/gm_api.ts @@ -39,6 +39,7 @@ export type MessageRequest = { scriptId: number; // 脚本id api: string; runFlag: string; + executionToken?: string; params: any[]; }; @@ -49,7 +50,30 @@ export type Request = MessageRequest & { export type Api = (request: Request, connect?: Channel) => Promise; +const executionMap = new Map< + string, + { scriptIds: Set; tabId: number } +>(); + +export const registerScriptExecution = (scriptIds: number[], tabId: number): string => { + const token = uuidv4(); + executionMap.set(token, { + scriptIds: new Set(scriptIds), + tabId, + }); + return token; +}; + +export const removeTabExecutions = (tabId: number) => { + executionMap.forEach((execution, token) => { + if (execution.tabId === tabId) { + executionMap.delete(token); + } + }); +}; + export default class GMApi { + message: MessageHander; script: ScriptDAO; @@ -80,18 +104,18 @@ export default class GMApi { this.message.setHandler( "gmApi", async (_action: string, data: MessageRequest, sender: MessageSender) => { - const api = PermissionVerify.apis.get(data.api); - if (!api) { - return Promise.reject(new Error("api is not found")); - } - const req = await this.parseRequest(data, sender); try { + const api = PermissionVerify.apis.get(data.api); + if (!api) { + return Promise.reject(new Error("api is not found")); + } + const req = await this.parseRequest(data, sender); await this.permissionVerify.verify(req, api); + return api.api.call(this, req); } catch (e) { this.logger.error("verify error", { api: data.api }, Logger.E(e)); return Promise.reject(e); } - return api.api.call(this, req); } ); this.message.setHandlerWithChannel( @@ -102,18 +126,18 @@ export default class GMApi { data: MessageRequest, sender: MessageSender ) => { - const api = PermissionVerify.apis.get(data.api); - if (!api) { - return connect.throw("api is not found"); - } - const req = await this.parseRequest(data, sender); try { + const api = PermissionVerify.apis.get(data.api); + if (!api) { + return connect.throw("api is not found"); + } + const req = await this.parseRequest(data, sender); await this.permissionVerify.verify(req, api); + return api.api.call(this, req, connect); } catch (e: any) { this.logger.error("verify error", { api: data.api }, Logger.E(e)); return connect.throw(e.message); } - return api.api.call(this, req, connect); } ); // 只有background页才监听web请求 @@ -150,9 +174,30 @@ export default class GMApi { const req: Request = data; req.script = script; req.sender = sender; + this.verifyExecution(req); return Promise.resolve(req); } + verifyExecution(request: Request) { + // 只适用于有 pageLoad 的 前台脚本(content), 不适用于没 pageLoad 的 后台脚本 (sandbox) + if (process.env.VI_TESTING === "true" && request.sender.targetTag === "testing") { + return; + } + if (request.sender.targetTag === "sandbox") { + return; + } + if (request.sender.targetTag !== "content") { + throw new Error("script execution must be from content or sandbox"); + } + if (!request.executionToken) { + throw new Error("script execution is not trusted"); + } + const execution = executionMap.get(request.executionToken); + if (!execution || !execution.scriptIds.has(request.scriptId)) { + throw new Error("script execution is not trusted"); + } + } + @PermissionVerify.API() GM_setValue(request: Request): Promise { if (!request.params || request.params.length !== 2) { diff --git a/src/runtime/background/runtime.ts b/src/runtime/background/runtime.ts index 4d22d52fa..8c6e55c11 100644 --- a/src/runtime/background/runtime.ts +++ b/src/runtime/background/runtime.ts @@ -29,7 +29,7 @@ import Manager from "@App/app/service/manager"; import Hook from "@App/app/service/hook"; import { i18nName } from "@App/locales/locales"; import { compileInjectScript, compileScriptCode } from "../content/utils"; -import GMApi, { type Request } from "./gm_api"; +import GMApi, { registerScriptExecution, removeTabExecutions, type Request } from "./gm_api"; import { genScriptMenu } from "./utils"; export type RuntimeEvent = "start" | "stop" | "watchRunStatus"; @@ -285,6 +285,7 @@ export default class Runtime extends Manager { }; chrome.tabs.onRemoved.addListener((tabId) => { runScript.delete(tabId); + removeTabExecutions(tabId); }); // 给popup页面获取运行脚本,与菜单 this.message.setHandler( @@ -431,10 +432,21 @@ export default class Runtime extends Manager { return; } - resolve({ scripts: filter }); + const executionToken = registerScriptExecution( + filter.map((script) => script.id), + sender.tabId! + ); + + const runResources = filter; + // const runResources = filter.map((script) => ({ + // ...script, + // executionToken, + // })); + + resolve({ scripts: runResources, executionToken }); // 注入脚本 - filter.forEach((script) => { + runResources.forEach((script) => { let runAt = "document_idle"; if (script.metadata["run-at"]) { [runAt] = script.metadata["run-at"]; diff --git a/src/runtime/content/content.ts b/src/runtime/content/content.ts index 856856a9e..191e630b1 100644 --- a/src/runtime/content/content.ts +++ b/src/runtime/content/content.ts @@ -3,6 +3,7 @@ import MessageContent from "@App/app/message/content"; import MessageInternal from "@App/app/message/internal"; import { MessageHander, MessageManager } from "@App/app/message/message"; import { ScriptRunResource } from "@App/app/repo/scripts"; +import { assignExecutionToken } from "./gm_api"; // content页的处理 export default class ContentRuntime { @@ -18,7 +19,8 @@ export default class ContentRuntime { this.internalMessage = internalMessage; } - start(resp: { scripts: ScriptRunResource[] }) { + start(resp: { scripts: ScriptRunResource[], executionToken?: string }) { + assignExecutionToken(`${resp.executionToken || ""}`); // 由content到background // 转发gmApi消息 this.contentMessage.setHandler("gmApi", (action, data) => { diff --git a/src/runtime/content/gm_api.ts b/src/runtime/content/gm_api.ts index 15ea65a1a..2a0949e25 100644 --- a/src/runtime/content/gm_api.ts +++ b/src/runtime/content/gm_api.ts @@ -11,6 +11,14 @@ import { parseUserConfig } from "@App/pkg/utils/yaml"; import { v4 as uuidv4 } from "uuid"; import { type ValueUpdateData } from "./exec_script"; +// content/page 环境的变数,不储存在任何object +let currentExecutionToken = ""; +export const assignExecutionToken = (executionToken: string) => { + // content/page 环境 pageLoad 时储存由 background 生成的 executionToken + if (currentExecutionToken) throw new Error("currentExecutionToken cannot be re-assigned"); + currentExecutionToken = executionToken; +}; + interface ApiParam { depend?: string[]; listener?: () => void; @@ -77,6 +85,7 @@ export class GM_Base { scriptId: this.scriptRes.id, params, runFlag: this.runFlag, + executionToken: currentExecutionToken, }); } @@ -90,6 +99,7 @@ export class GM_Base { scriptId: this.scriptRes.id, params, runFlag: this.runFlag, + executionToken: currentExecutionToken, }); return channel; } @@ -121,6 +131,7 @@ export class GM_Base { }); } } + } export default class GMApi extends GM_Base { diff --git a/src/runtime/content/inject.ts b/src/runtime/content/inject.ts index 565e34445..daf6abe6e 100644 --- a/src/runtime/content/inject.ts +++ b/src/runtime/content/inject.ts @@ -3,29 +3,24 @@ import { type ScriptRunResource } from "@App/app/repo/scripts"; import ExecScript, { type ValueUpdateData } from "./exec_script"; import { addStyleSheet, type ScriptFunc } from "./utils"; import { onInjectPageLoaded } from "./external"; +import { assignExecutionToken } from "./gm_api"; // 注入脚本的沙盒环境 export default class InjectRuntime { - scripts: ScriptRunResource[]; - - flag: string; message: MessageContent; execList: ExecScript[] = []; constructor( - message: MessageContent, - scripts: ScriptRunResource[], - flag: string + message: MessageContent ) { this.message = message; - this.scripts = scripts; - this.flag = flag; } - start() { - this.scripts.forEach((script) => { + start(resp: { scripts: ScriptRunResource[], executionToken?: string }) { + assignExecutionToken(`${resp.executionToken || ""}`); + resp.scripts.forEach((script) => { // @ts-ignore const scriptFunc = window[script.flag]; if (scriptFunc) { diff --git a/src/runtime/gm_api.test.ts b/src/runtime/gm_api.test.ts index a3f420937..be4aa4cf4 100644 --- a/src/runtime/gm_api.test.ts +++ b/src/runtime/gm_api.test.ts @@ -1,7 +1,7 @@ // gm api 单元测试 // 初始化runtime环境 import initTestEnv from "@App/pkg/utils/test_utils"; -import GMApi from "./background/gm_api"; +import GMApi, { registerScriptExecution } from "./background/gm_api"; import LoggerCore from "@App/app/logger/core"; import MessageCenter from "@App/app/message/center"; import { ScriptDAO, ScriptRunResource } from "@App/app/repo/scripts"; @@ -31,7 +31,7 @@ IoC.registerInstance(ValueManager, new ValueManager(center, center)); const backgroundApi = new GMApi(center, new PermissionVerify()); backgroundApi.start(); -const internal = new MessageInternal("background"); +const internal = new MessageInternal("testing"); const scriptRes = { id: 0, name: "test", @@ -91,6 +91,48 @@ beforeAll(async () => { }); }); +describe("GM execution trust", () => { + it("rejects content messages without a registered execution token", async () => { + await expect( + backgroundApi.parseRequest( + { + api: "GM_setValue", + scriptId: scriptRes.id, + params: ["test", "test"], + runFlag: "test", + }, + { + targetTag: "content", + tabId: 1, + url: window.location.href, + } + ) + ).rejects.toThrow("script execution is not trusted"); + }); + + it("accepts content messages with a matching execution token", async () => { + const executionToken = registerScriptExecution([scriptRes.id], 1); + await expect( + backgroundApi.parseRequest( + { + api: "GM_setValue", + scriptId: scriptRes.id, + params: ["test", "test"], + runFlag: "test", + executionToken, + }, + { + targetTag: "content", + tabId: 1, + url: window.location.href, + } + ) + ).resolves.toMatchObject({ + scriptId: scriptRes.id, + }); + }); +}); + describe("GM value", () => { it("get value", () => { contentApi.GM_setValue("test", "test"); diff --git a/src/types/main.d.ts b/src/types/main.d.ts index c792ad38e..b37cf186a 100644 --- a/src/types/main.d.ts +++ b/src/types/main.d.ts @@ -1,7 +1,5 @@ declare let sandbox: Window; -declare let ScriptFlag: string; - declare let browser: chrome; declare let cloneInto: ((detail: any, view: any) => any) | undefined; diff --git a/webpack.config.ts b/webpack.config.ts index 4befd41ba..5ad2ada22 100644 --- a/webpack.config.ts +++ b/webpack.config.ts @@ -1,6 +1,6 @@ /* eslint-disable import/no-extraneous-dependencies */ import path from "path"; -import { Configuration } from "webpack"; +import { Configuration, DefinePlugin } from "webpack"; import TerserPlugin from "terser-webpack-plugin"; import HtmlWebpackPlugin from "html-webpack-plugin"; import ESLintPlugin from "eslint-webpack-plugin"; @@ -131,6 +131,9 @@ const configCommon: Configuration = { }), new CleanWebpackPlugin(), new ProgressBarPlugin({}), + new DefinePlugin({ + "process.env.VI_TESTING": JSON.stringify(false), + }), new MonacoLocalesPlugin({ languages: ["en", "zh-cn"], defaultLanguage: "en",