diff --git a/rspack.config.ts b/rspack.config.ts index f9e8ae578..35d02137b 100644 --- a/rspack.config.ts +++ b/rspack.config.ts @@ -138,6 +138,8 @@ 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/service/content/exec_script.ts b/src/app/service/content/exec_script.ts index 6c5b93730..7f30bf4e7 100644 --- a/src/app/service/content/exec_script.ts +++ b/src/app/service/content/exec_script.ts @@ -9,6 +9,8 @@ import { evaluateGMInfo } from "./gm_api/gm_info"; import type { IGM_Base } from "./gm_api/gm_api"; import type { TScriptInfo } from "@App/app/repo/scripts"; +const fnStrIntegrity = process.env.SC_RANDOM_FNKEY!; + // 执行脚本,控制脚本执行与停止 export default class ExecScript { scriptRes: TScriptInfo; @@ -88,7 +90,7 @@ 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 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/utils.test.ts b/src/app/service/content/utils.test.ts index 837728eec..8774077e1 100644 --- a/src/app/service/content/utils.test.ts +++ b/src/app/service/content/utils.test.ts @@ -3,6 +3,7 @@ import { compileScriptCode, compileScript, compileInjectScript, + compilePreInjectScript, compileScriptletCode, isScriptletUnwrap, addStyle, @@ -13,6 +14,24 @@ import type { SCMetadata, ScriptLoadInfo, ScriptRunResource } from "@App/app/rep 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 +79,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("应该处理自定义脚本代码参数", () => { @@ -495,7 +515,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 +531,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 +546,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 +555,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(); }); }); @@ -565,7 +591,9 @@ describe("utils", () => { const result = compileInjectScript(script, scriptCode); - expect(result).toBe(`window['inject-test-flag'] = function(){console.log('injected');}`); + expect(result).toBe( + `window['inject-test-flag'] = ((k, y, fn) => ((t, u, ...args) => { if (t === k) { u[y] = fn; return u[y](...((delete u[y]), args)) } }))('${fnStrIntegrity}', '${znRand}' + Math.random(), function(){console.log('injected');});` + ); }); it.concurrent("应该包含自动删除挂载函数的代码", () => { @@ -577,7 +605,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');}` + `window['inject-test-flag'] = ((k, y, fn) => ((t, u, ...args) => { if (t === k) { u[y] = fn; return u[y](...((delete u[y]), args)) } }))('${fnStrIntegrity}', '${znRand}' + Math.random(), function(){try{delete window['inject-test-flag']}catch(e){}console.log('with auto delete');});` ); }); @@ -588,7 +616,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( + `window['inject-test-flag'] = ((k, y, fn) => ((t, u, ...args) => { if (t === k) { u[y] = fn; return u[y](...((delete u[y]), args)) } }))('${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]).toBe(generated); }); it.concurrent("应该处理复杂的脚本代码", () => { @@ -617,6 +702,52 @@ describe("utils", () => { }); }); + 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; + 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(); + }); + }); + describe("addStyle", () => { afterEach(() => { // 清理 DOM diff --git a/src/app/service/content/utils.ts b/src/app/service/content/utils.ts index 64181c489..abfc8276f 100644 --- a/src/app/service/content/utils.ts +++ b/src/app/service/content/utils.ts @@ -8,6 +8,9 @@ import { embeddedPatternCheckerString, type EmbeddedURLRuleEntry, type URLRuleEn import { parseResourceDeclaration } from "@App/pkg/utils/resource"; import { getGrantCandidates } from "./gm_api/grant"; +const lnStrIntegrity = process.env.SC_RANDOM_FNKEY; +const znRand = process.env.SC_ZN_RAND; + export type CompileScriptCodeResource = { name: string; code: string; @@ -151,9 +154,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 +164,26 @@ export function compileScriptCodeByResource(resource: CompileScriptCodeResource) return `${codeBody}${sourceMapTo(`${resource.name}.user.js`)}\n`; } +const codeFunction = (code: string) => { + // no usage of .call, .apply, or .bind + // scoped variables -> not observable + // u[y] -> no .call(u) + return `((k, y, fn) => ((t, u, ...args) => { if (t === k) { u[y] = fn; return u[y](...((delete u[y]), args)) } }))('${lnStrIntegrity}', '${znRand}' + Math.random(), function(){${code}})`; +}; + +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 +206,7 @@ export function compileInjectScriptByFlag( autoDeleteMountFunction: boolean = false ): string { const autoDeleteMountCode = autoDeleteMountFunction ? `try{delete window['${flag}']}catch(e){}` : ""; - return `window['${flag}'] = function(){${autoDeleteMountCode}${scriptCode}}`; + return `window['${flag}'] = ${codeFunction(`${autoDeleteMountCode}${scriptCode}`)};`; } /** @@ -252,7 +272,7 @@ export function compilePreInjectScript( const autoDeleteMountCode = autoDeleteMountFunction ? `try{delete window['${flag}']}catch(e){}` : ""; const evScriptLoad = `${eventNamePrefix}${DefinedFlags.scriptLoadComplete}`; const evEnvLoad = `${eventNamePrefix}${DefinedFlags.envLoadComplete}`; - return `window['${flag}'] = function(){${autoDeleteMountCode}${scriptCode}}; + return `window['${flag}'] = ${codeFunction(`${autoDeleteMountCode}${scriptCode}`)}; { let o = { cancelable: true, detail: { scriptFlag: '${flag}', scriptInfo: (${scriptInfoJSON}) } }, c = typeof cloneInto === "function" ? cloneInto(o, performance) : o, diff --git a/src/app/service/service_worker/utils.test.ts b/src/app/service/service_worker/utils.test.ts index 94c31b627..366441f66 100644 --- a/src/app/service/service_worker/utils.test.ts +++ b/src/app/service/service_worker/utils.test.ts @@ -354,7 +354,7 @@ describe.concurrent("compileInjectionCode", () => { // 包含沙箱封装 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(){"); // 使用 compileInjectScript 包裹(window[flag] = function(){...}) expect(result).toContain("window['#-test-uuid']"); }); diff --git a/vitest.config.ts b/vitest.config.ts index cb248d264..0ef021b56 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", }, };