Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion docs/references/architecture-execution.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,14 @@ Key points:

Normal userscripts run in the page. The SW builds a `RegisteredUserScript` from the script's `@match`/`@include`
patterns and registers the compiled payload (the `scripting` bundle) with `chrome.userScripts.register`, in the
`MAIN` or `USER_SCRIPT` world as required. At document time the content/inject pair
`MAIN` or `USER_SCRIPT` world as required. The browser injects where a URL hits `matches` **or** `includeGlobs`
(a union, not a filter), so `getApiMatchesAndGlobs` ([`url_matcher.ts`](../../src/pkg/utils/url_matcher.ts)) keeps
`matches` to the script's real match patterns and never widens it to `*://*/*` to make room for globs. A regex
`@include` that cannot be narrowed to a host-level glob is the exception: `getUserScriptRegister` marks it
`deliverOnPageLoad` and the script is not registered. Its injection code instead travels in the `pageLoad` reply
(`TScriptInfo.code`) to the content script, which mounts it from the `USER_SCRIPT` world, where an inserted
`<script>` is not subject to the page CSP. `@early-start` and `@unwrap` scripts, and Firefox, keep the site-wide
registration. 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.
Expand Down
82 changes: 82 additions & 0 deletions e2e/include-injection-scope.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { test, expect, startMockServer, type MockServer } from "./server-fixtures";
import { installScriptByCode } from "./utils";
import type { Page } from "@playwright/test";

// #1803:浏览器对 matches 与 includeGlobs 取并集,@include 脚本曾被注册成全站,代码挂到每个页面的 window 上。
// 这里在真实 Chrome 里验证三件事:glob 精确注册后仍在目标页(含 #hash 网址)执行;正则收不窄的脚本改由 pageLoad
// 下发代码后,在严格 CSP 的目标页照常执行;两者都不会把代码留在不相关的页面上。

function markerScript(name: string, rule: string, marker: string) {
return `// ==UserScript==
// @name ${name}
// @namespace issue-1803-e2e
// @version 1.0.0
// @include ${rule}
// @grant none
// ==/UserScript==
document.documentElement.dataset.${marker} = "ran";`;
}

const GLOB_SCRIPT = markerScript("Issue 1803 glob", "http://*siteb.test*/page", "scGlob");
// 没有 scheme 的局部正则抽不出网域,只能不预注册、随 pageLoad 下发
const REGEX_SCRIPT = markerScript("Issue 1803 regex", "/content-security-policy\\.test:\\d+\\/page/", "scRegex");
const CONTROL_SCRIPT = `// ==UserScript==
// @name Issue 1803 control
// @namespace issue-1803-e2e
// @version 1.0.0
// @match http://sitea.test/*
// @grant none
// ==/UserScript==
document.documentElement.dataset.scControl = "ran";`;

const waitForMarker = (page: Page, marker: string) =>
page.waitForFunction((key) => document.documentElement.dataset[key] === "ran", marker, { timeout: 10_000 });

test.describe("Issue 1803: @include 脚本只注入到匹配的页面", () => {
let server: MockServer;

test.beforeEach(async () => {
server = await startMockServer();
});

test.afterEach(async () => {
await server.close();
});

test("glob 与收不窄的正则 @include 都在各自的目标页执行,严格 CSP 也不例外", async ({ context, extensionId }) => {
await installScriptByCode(context, extensionId, GLOB_SCRIPT);
await installScriptByCode(context, extensionId, REGEX_SCRIPT);
const page = await context.newPage();
try {
// glob 不以 * 结尾,浏览器拿带 hash 的完整网址匹配,靠补上的 #* 变体命中
await page.goto(server.url("siteb.test", "/page#section"), { waitUntil: "domcontentloaded" });
await waitForMarker(page, "scGlob");

// 该主机下发 script-src 'none',代码由 content 环境插入页面
await page.goto(server.url("content-security-policy.test", "/page"), { waitUntil: "domcontentloaded" });
await waitForMarker(page, "scRegex");
} finally {
await page.close();
}
});

test("不匹配的页面上不留下任何脚本代码", async ({ context, extensionId }) => {
await installScriptByCode(context, extensionId, GLOB_SCRIPT);
await installScriptByCode(context, extensionId, REGEX_SCRIPT);
await installScriptByCode(context, extensionId, CONTROL_SCRIPT);
const page = await context.newPage();
try {
await page.goto(server.url("sitea.test", "/page"), { waitUntil: "domcontentloaded" });
// 对照脚本执行说明本页的 pageLoad 已经完成,随 pageLoad 下发的代码此时也已注入(如果有的话)
await waitForMarker(page, "scControl");

const leaked = await page.evaluate(() => ({
flags: Object.keys(window).filter((key) => key.startsWith("#-")),
markers: { ...document.documentElement.dataset },
}));
expect(leaked).toEqual({ flags: [], markers: { scControl: "ran" } });
} finally {
await page.close();
}
});
});
6 changes: 4 additions & 2 deletions src/app/repo/resource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,14 +83,16 @@ export type CompiledResource = {
uuid: string;
require: string[]; // 仅存储url,节省空间
matches: string[]; // primary
includeGlobs: string[]; // includeGlobs applied after matches
includeGlobs: string[]; // 浏览器与 matches 取并集,见 getApiMatchesAndGlobs
excludeMatches: string[];
excludeGlobs: string[];
allFrames: boolean;
world: string;
runAt: string;
scriptUrlPatterns: URLRuleEntry[];
originalUrlPatterns: URLRuleEntry[] | null;
// 不向浏览器预注册,注入代码由 pageLoad 按匹配结果下发(见 getUserScriptRegister)
deliverOnPageLoad: boolean;
};

export const ResourceNamespace = "76f45084-91b1-42c1-8be8-cbcc54b171f0";
Expand All @@ -110,7 +112,7 @@ export class ResourceDAO extends Repo<Resource> {
}

// CompiledResource结构或 matches 计算规则变更时,建议修改 CompiledResourceNamespace 以删除旧Cache
export const CompiledResourceNamespace = "39a5eaa9-1cd2-446b-82b8-dd7bd3719f52";
export const CompiledResourceNamespace = "8f0d6c1e-3b4a-4f55-9d2e-6a7c1b9e5f13";

export class CompiledResourceDAO extends Repo<CompiledResource> {
constructor() {
Expand Down
1 change: 1 addition & 0 deletions src/app/repo/scripts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ export type TScriptInfo = Override<
originalMetadata?: Partial<Record<string, string[]>>;
resource: PageScriptResource;
requireCssResource?: PageScriptResource;
// 只有不预注册(CompiledResource.deliverOnPageLoad)的脚本才非空:挂载 window[flag] 的注入代码,由 content 负责注入
code: "" | string;
sort?: number;
flag: string;
Expand Down
17 changes: 17 additions & 0 deletions src/app/service/content/script_runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,23 @@ export class ScriptRuntime {
const nodeId = msg.sendRelatedTarget(el);
return nodeId;
});

// 未预注册的脚本(见 getUserScriptRegister 的 deliverOnPageLoad),注入代码随 pageLoad 下发到这里挂载 window[flag],
// 执行环境照常通过 flag 接手。content 运行在 USER_SCRIPT world,其 CSP 放行内联脚本与 eval,
// 插入页面的 <script> 不受页面 CSP 限制
this.server.on("loadScriptCode", (data: { pageCodes: string[]; contentCodes: string[] }) => {
for (const code of data.contentCodes) {
new Function(code)();
}
for (const code of data.pageCodes) {
// createElementNS 确保 XML 文档里也能执行(#211)
const el = document.createElementNS("http://www.w3.org/1999/xhtml", "script");
el.setAttribute("type", "text/javascript");
el.textContent = code;
document.documentElement.appendChild(el);
el.remove();
}
});
}

init() {
Expand Down
19 changes: 18 additions & 1 deletion src/app/service/content/scripting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,9 +169,26 @@ export default class ScriptingRuntime {
}
this.activeStorageNames = new Map(Object.entries(pairs));

const contentClient = new Client(this.senderToContent, "content");
// 未预注册脚本的注入代码交给 content 环境注入:本环境是 ISOLATED world,插入的 <script> 会被页面 CSP 拦下
const pageCodes: string[] = [];
const contentCodes: string[] = [];
for (const [list, codes] of [
[injectScriptList, pageCodes],
[contentScriptList, contentCodes],
] as const) {
for (const script of list) {
if (!script.code) continue;
codes.push(script.code);
script.code = "";
}
}
if (pageCodes.length || contentCodes.length) {
contentClient.do("loadScriptCode", { pageCodes, contentCodes });
}

// 向页面 发送脚本列表及环境信息
if (contentScriptList.length) {
const contentClient = new Client(this.senderToContent, "content");
// 根据@inject-into content过滤脚本
contentClient.do("pageLoad", { scripts: contentScriptList, envInfo });
}
Expand Down
63 changes: 63 additions & 0 deletions src/app/service/service_worker/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,31 @@ describe.concurrent("RuntimeService - getPageScriptMatchingResultByUrl 脚本匹
expect(unregisterSpy).toHaveBeenCalledWith([script.uuid]);
});

it.concurrent(
"正则 @include 收不窄的脚本改随 pageLoad 下发:记入 CompiledResource 并注销旧注册,不再注册",
async () => {
const { runtime, mockScriptService } = createRuntimeTestContext();
(runtime as any).resource = {
getScriptResourceValueByType: vi.fn().mockResolvedValue({ require: {}, "require-css": {}, resource: {} }),
};
runtime.isUserScriptsAvailable = true;
const script = createMockScript({
metadata: { include: ["/live\\.bilibili\\.com/"] },
status: SCRIPT_STATUS_ENABLE,
});
mockScriptService.buildScriptRunResource.mockResolvedValue(createScriptRunResource(script));
const saveSpy = vi.spyOn(runtime.compiledResourceDAO, "save").mockResolvedValue(undefined as never);
const unregisterSpy = vi.spyOn(runtime, "unregistryPageScripts").mockResolvedValue(undefined);
const loadSpy = vi.spyOn(runtime, "loadPageScript").mockResolvedValue(undefined);

await runtime.updateResourceOnScriptChange(script);

expect(saveSpy).toHaveBeenCalledWith(expect.objectContaining({ uuid: script.uuid, deliverOnPageLoad: true }));
expect(unregisterSpy).toHaveBeenCalledWith([script.uuid]);
expect(loadSpy).not.toHaveBeenCalled();
}
);

describe.concurrent("includeDisabled 选项", () => {
it.concurrent("当 includeDisabled=false 时不返回禁用脚本;当 includeDisabled=true 时返回禁用脚本", async () => {
// Arrange
Expand Down Expand Up @@ -523,6 +548,7 @@ describe.concurrent("RuntimeService - getPageScriptMatchingResultByUrl 脚本匹
runAt: "document-idle",
scriptUrlPatterns: patterns.scriptUrlPatterns,
originalUrlPatterns: null,
deliverOnPageLoad: false,
};

const mockCompiledResourceDAO = {
Expand Down Expand Up @@ -983,6 +1009,7 @@ describe("getScriptsForTab 附加边界场景", () => {
runAt: "document-idle",
scriptUrlPatterns: patterns.scriptUrlPatterns,
originalUrlPatterns: null,
deliverOnPageLoad: false,
};

const mockCompiledResourceDAO = {
Expand Down Expand Up @@ -1140,6 +1167,41 @@ describe("getScriptsForTab 附加边界场景", () => {
expect(buildSpy).toHaveBeenCalledTimes(1);
expect(result).not.toBeNull();
});

it("未预注册(deliverOnPageLoad)的脚本随 pageLoad 下发挂载 window[flag] 的注入代码", async () => {
const { runtime, scriptRes, compiledResource } = createFullContext();
compiledResource.deliverOnPageLoad = true;
await runtime.applyScriptMatchInfo(scriptRes);

const result = await runtime.getScriptsForTab({ url: pageUrl, tabId: undefined, frameId: undefined });

const [info] = result!.injectScriptList;
expect(info.code.startsWith(`window['${info.flag}'] = function(){`)).toBe(true);
expect(info.code).toContain("// test");
});

it("已预注册的脚本 pageLoad 不携带代码", async () => {
const { runtime, scriptRes } = createFullContext();
await runtime.applyScriptMatchInfo(scriptRes);

const result = await runtime.getScriptsForTab({ url: pageUrl, tabId: undefined, frameId: undefined });

expect(result!.injectScriptList[0].code).toBe("");
});

it.each([
[false, 1],
[true, 0],
])("批量注册时 deliverOnPageLoad=%s 的脚本注册条数为 %i", async (deliverOnPageLoad, count) => {
const { runtime, script, compiledResource, mockScriptDAO } = createFullContext();
compiledResource.deliverOnPageLoad = deliverOnPageLoad;
(mockScriptDAO as any).all = vi.fn().mockResolvedValue([script]);
vi.spyOn(runtime, "restoreJSCodeFromCompiledResource").mockResolvedValue("window['flag'] = function(){}");

const list = await runtime.getParticularScriptList({ excludeMatches: [], excludeGlobs: [] });

expect(list).toHaveLength(count);
});
});

describe("pageLoad 按消息发送方标签页区分隐身上下文", () => {
Expand Down Expand Up @@ -1422,6 +1484,7 @@ describe("restoreJSCodeFromCompiledResource 还原代码时的生效 metadata",
runAt: "document_idle",
scriptUrlPatterns: scriptURLPatternResults(scriptRes)!.scriptUrlPatterns,
originalUrlPatterns: null,
deliverOnPageLoad: false,
};
mockScriptService.buildScriptRunResource.mockResolvedValue(scriptRes);
(runtime as any).script = {
Expand Down
23 changes: 17 additions & 6 deletions src/app/service/service_worker/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ type TPageLoadScriptCache = {
userConfig: UserConfig | undefined;
resourceByType: TRuntimeResourceByType;
localResources: TLocalResourceCache[];
deliverOnPageLoad: boolean;
};

const runtimeGlobal = {
Expand Down Expand Up @@ -471,6 +472,11 @@ export class RuntimeService {
await this.unregistryPageScripts([script.uuid]);
return;
}
if (ret.compiledResource.deliverOnPageLoad) {
// 规则改成收不窄的正则后,之前的预注册必须撤掉,否则代码仍挂在所有页面
await this.unregistryPageScripts([script.uuid]);
return;
}
const { apiScript } = ret;
await this.loadPageScript(script, apiScript!);
}
Expand Down Expand Up @@ -869,11 +875,10 @@ export class RuntimeService {
const scriptMatchInfo = await this.applyScriptMatchInfo(scriptRes);
if (!scriptMatchInfo) return undefined;
// 生效规则一条 inclusion 都不剩(用户把当前站点从匹配中移除后可能如此)时不能注册:
// getApiMatchesAndGlobs 对没有 match pattern 的规则集会退回 *://*/*,注册出去等于全站运行。
// 这种规则集转换不出任何 matches,提前返回,免得后面把它当成「只匹配不支持的 scheme」记警告。
if (!scriptMatchInfo.scriptUrlPatterns.some((rule) => rule.ruleType & RuleTypeBit.INCLUSION)) return undefined;

const res = getUserScriptRegister(scriptMatchInfo);
const registerScript = res.registerScript;
const { registerScript, deliverOnPageLoad } = getUserScriptRegister(scriptMatchInfo);

let jsCode = "";
if (withCode) {
Expand Down Expand Up @@ -906,6 +911,7 @@ export class RuntimeService {
runAt: registerScript.runAt || "",
scriptUrlPatterns: scriptUrlPatterns,
originalUrlPatterns: scriptUrlPatterns === originalUrlPatterns ? null : originalUrlPatterns,
deliverOnPageLoad,
} as CompiledResource;

this.compiledResourceDAO.save(result);
Expand Down Expand Up @@ -976,10 +982,10 @@ export class RuntimeService {
if (!ret) return undefined;
result = ret.compiledResource;
resultCode = ret.jsCode;
} else {
} else if (!result.deliverOnPageLoad) {
resultCode = await this.restoreJSCodeFromCompiledResource(script, result);
}
if (!resultCode) return undefined;
if (result.deliverOnPageLoad || !resultCode) return undefined;
const registerScript = {
id: result.uuid,
js: [{ code: resultCode }],
Expand Down Expand Up @@ -1450,6 +1456,7 @@ export class RuntimeService {
userConfig: codeInfo.userConfig,
resourceByType: this.cloneRuntimeResourceByType(resourceByType),
localResources: this.getLocalResourceCacheList(resourceByType),
deliverOnPageLoad: compiledResource.deliverOnPageLoad,
};
}

Expand Down Expand Up @@ -1676,7 +1683,11 @@ export class RuntimeService {
const contentScriptList: TScriptInfo[] = [];
for (const script of enableScriptList) {
const list = isInjectIntoContent(script.metadata) ? contentScriptList : injectScriptList;
list.push(trimScriptInfo({ ...script }));
const info = trimScriptInfo({ ...script });
if (this.pageLoadCaches.get(script.uuid)?.deliverOnPageLoad) {
info.code = compileInjectionCode(script, script.code, script.scriptUrlPatterns);
}
list.push(info);
}

// 发布给 inject 和 content 的信息
Expand Down
Loading
Loading