Skip to content
Merged
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
77 changes: 63 additions & 14 deletions e2e/gm-api.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import type { AddressInfo } from "net";
import { test as base, expect, chromium, type BrowserContext } from "@playwright/test";
import { installScriptByCode } from "./utils";

const HTTPBUN_GET_URL = "https://httpbun.com/get";
const MOCK_CONNECT_HOST = "127.0.0.1";

type GMApiMockServer = {
Expand Down Expand Up @@ -89,18 +88,54 @@ async function startGMApiMockServer(): Promise<GMApiMockServer> {
return;
}

if (req.url === "/get") {
const url = new URL(req.url || "/", `http://${req.headers.host}`);

if (url.pathname === "/get") {
res.writeHead(200, { "Content-Type": "application/json" });
const args = Object.fromEntries(url.searchParams.entries());
res.end(
JSON.stringify({
url: `http://${req.headers.host}${req.url}`,
url: `http://${req.headers.host}${url.pathname}`,
args,
})
);
return;
}

res.writeHead(404, { "Content-Type": "text/plain" });
res.end("not found");
if (url.pathname === "/favicon.ico") {
res.writeHead(200, { "Content-Type": "image/png" });
res.end(
Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=",
"base64"
)
);
return;
}

const bytesMatch = url.pathname.match(/^\/bytes\/(\d+)$/);
if (bytesMatch) {
const size = Number(bytesMatch[1]);
res.writeHead(200, { "Content-Type": "application/octet-stream" });
res.end(Buffer.alloc(size, "a"));
return;
}

const delayMatch = url.pathname.match(/^\/delay\/(\d+)$/);
if (delayMatch) {
const delayMs = Number(delayMatch[1]) * 1000;
setTimeout(() => {
if (res.destroyed) return;
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ url: `http://${req.headers.host}${url.pathname}` }));
}, delayMs);
return;
}

res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end(
'<!doctype html><html><head><title>ScriptCat E2E</title></head><body><main class="container"><div class="masthead">ScriptCat E2E</div></main></body></html>'
);
});

await new Promise<void>((resolve, reject) => {
Expand All @@ -125,10 +160,24 @@ async function startGMApiMockServer(): Promise<GMApiMockServer> {
};
}

function patchTargetMatchCode(code: string, targetUrl: string): string {
const url = new URL(targetUrl);
const targetPattern = `${url.protocol}//${url.hostname}/*${url.search}`;
return code.replace(
/^\/\/\s*@match\s+.*\?(gm_api_sync|gm_api_async|inject_content|WINDOW_MESSAGE_TEST_SC|SANDBOX_TEST_SC)$/gm,
`// @match ${targetPattern}`
);
}

function patchGMApiTestCode(code: string, mockOrigin: string): string {
const mockHost = new URL(mockOrigin).host;
return code
.replace(/^\/\/\s*@connect\s+httpbun\.com$/gm, `// @connect ${MOCK_CONNECT_HOST}`)
.replace(new RegExp(HTTPBUN_GET_URL.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g"), `${mockOrigin}/get`);
.replace(/https:\/\/httpbun\.com\/get/g, `${mockOrigin}/get`)
.replace(/https:\/\/httpbun\.com\/bytes\/64/g, `${mockOrigin}/bytes/64`)
.replace(/https:\/\/httpbun\.com\/delay\/5/g, `${mockOrigin}/delay/5`)
.replace(/https:\/\/www\.tampermonkey\.net\/favicon\.ico/g, `${mockOrigin}/favicon.ico`)
.replace(/httpbun\.com\/get/g, `${mockHost}/get`);
}

/**
Expand Down Expand Up @@ -177,6 +226,7 @@ async function runTestScript(
): Promise<{ passed: number; failed: number; logs: string[] }> {
let code = fs.readFileSync(path.join(__dirname, `../example/tests/${scriptFile}`), "utf-8");
code = patchScriptCode(code);
code = patchTargetMatchCode(code, targetUrl);
code = options?.patchCode ? options.patchCode(code) : code;

await installScriptByCode(context, extensionId, code);
Expand Down Expand Up @@ -210,8 +260,6 @@ async function runTestScript(
return { passed, failed, logs };
}

const TARGET_URL = "https://content-security-policy.com/";

test.describe("GM API", () => {
let gmApiMockServer: GMApiMockServer;

Expand All @@ -235,7 +283,7 @@ test.describe("GM API", () => {
context,
extensionId,
"gm_api_sync_test.js",
`${TARGET_URL}?gm_api_sync`,
`${gmApiMockServer.origin}/?gm_api_sync`,
90_000,
{ patchCode }
);
Expand All @@ -253,7 +301,7 @@ test.describe("GM API", () => {
context,
extensionId,
"gm_api_async_test.js",
`${TARGET_URL}?gm_api_async`,
`${gmApiMockServer.origin}/?gm_api_async`,
90_000,
{ patchCode }
);
Expand All @@ -271,7 +319,7 @@ test.describe("GM API", () => {
context,
extensionId,
"inject_content_test.js",
`${TARGET_URL}?inject_content`,
`${gmApiMockServer.origin}/?inject_content`,
60_000
);

Expand All @@ -288,8 +336,9 @@ test.describe("GM API", () => {
context,
extensionId,
"window_message_test.js",
`${TARGET_URL}?WINDOW_MESSAGE_TEST_SC`,
8_000
`${gmApiMockServer.origin}/?WINDOW_MESSAGE_TEST_SC`,
8_000,
{ patchCode }
);

console.log(`[window_message_test] passed=${passed}, failed=${failed}`);
Expand All @@ -305,7 +354,7 @@ test.describe("GM API", () => {
context,
extensionId,
"sandbox_test.js",
`${TARGET_URL}?SANDBOX_TEST_SC`,
`${gmApiMockServer.origin}/?SANDBOX_TEST_SC`,
8_000
);

Expand Down
14 changes: 13 additions & 1 deletion src/app/service/service_worker/gm_api/gm_api.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, it, expect } from "vitest";
import { type IGetSender } from "@Packages/message/server";
import { type ExtMessageSender } from "@Packages/message/types";
import { ConnectMatch, getConnectMatched } from "./gm_api";
import { ConnectMatch, getConnectMatched, getExtensionSiteAccessOriginPattern } from "./gm_api";

// 小工具:建立假的 IGetSender
const makeSender = (url?: string): IGetSender => ({
Expand Down Expand Up @@ -98,3 +98,15 @@ describe.concurrent("isConnectMatched", () => {
expect(getConnectMatched(["Api.Example.com"], req, makeSender())).toBe(ConnectMatch.DOMAIN);
});
});

describe.concurrent("getExtensionSiteAccessOriginPattern", () => {
it.concurrent("应生成不带端口的扩展站点访问权限 pattern", () => {
expect(getExtensionSiteAccessOriginPattern(new URL("http://127.0.0.1:3000/get"))).toBe("http://127.0.0.1/*");
expect(getExtensionSiteAccessOriginPattern(new URL("https://example.com:8443/path"))).toBe("https://example.com/*");
});

it.concurrent("应忽略非 http/https 协议", () => {
expect(getExtensionSiteAccessOriginPattern(new URL("data:text/plain,hello"))).toBeUndefined();
expect(getExtensionSiteAccessOriginPattern(new URL("file:///tmp/test.txt"))).toBeUndefined();
});
});
50 changes: 47 additions & 3 deletions src/app/service/service_worker/gm_api/gm_api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,13 @@ export const getConnectMatched = (
return ConnectMatch.NONE;
};

export const getExtensionSiteAccessOriginPattern = (url: URL): string | undefined => {
if (url.protocol !== "http:" && url.protocol !== "https:") {
return undefined;
}
return `${url.protocol}//${url.hostname}/*`;
};

type NotificationData = {
uuid: string;
details: GMTypes.NotificationDetails;
Expand Down Expand Up @@ -807,14 +814,42 @@ export default class GMApi {
const msg = `Refused to connect to "${details.url}": URL is blacklisted`;
throw throwErrorFn(msg);
}
let hasOriginPermission = false;
const originPattern = getExtensionSiteAccessOriginPattern(url);
if (!originPattern) {
hasOriginPermission = true; // TBC
} else {
try {
hasOriginPermission = await chrome.permissions.contains({ origins: [originPattern] });
} catch (e) {
console.warn(e);
}
}
const extensionSiteAccessOrigins = hasOriginPermission ? undefined : [originPattern];
const confirmExtensionSiteAccess = (): ConfirmParam => {
const metadata: { [key: string]: string } = {};
metadata[i18next.t("script_name")] = i18nName(request.script);
metadata[i18next.t("request_domain")] = url.hostname;
metadata[i18next.t("request_url")] = details.url;
throwErrorFn = null; // 确保 GC 可以释放 conn
return {
permission: "extension-site-access",
permissionValue: originPattern,
title: i18next.t("extension_site_access_title"),
metadata,
describe: i18next.t("extension_site_access_description"),
permissionContent: i18next.t("extension_site_access_content"),
extensionSiteAccessOrigins,
} as ConfirmParam;
};
const connectMatched = getConnectMatched(request.script.metadata.connect, url, sender);
if (connectMatched === ConnectMatch.ALL) {
// SC: 有 @connect * 就不询问
return true;
return hasOriginPermission ? true : confirmExtensionSiteAccess();
} else {
// 如果 @connect 有匹配到就放行
if (connectMatched > 0) {
return true;
return hasOriginPermission ? true : confirmExtensionSiteAccess();
}
// @connect 没有匹配,但有列明 @connect 的话,则自动拒绝
if (request.script.metadata.connect?.find((e) => !!e)) {
Expand All @@ -825,7 +860,7 @@ export default class GMApi {
wildcard: true,
});
if (ret && ret.allow) {
return true;
return hasOriginPermission ? true : confirmExtensionSiteAccess();
}
const msg = `Refused to connect to "${details.url}": This domain is not a part of the @connect list`;
throw throwErrorFn(msg);
Expand All @@ -839,6 +874,14 @@ export default class GMApi {

throwErrorFn = null; // 确保 GC 可以释放 conn

const ret = await GMApiInstance.permissionVerify.queryPermission(request, {
permission: "cors",
permissionValue: url.hostname,
wildcard: true,
});
if (ret?.allow && !hasOriginPermission) {
return confirmExtensionSiteAccess();
}
return {
permission: "cors",
permissionValue: url.hostname,
Expand All @@ -847,6 +890,7 @@ export default class GMApi {
describe: i18next.t("confirm_operation_description"),
wildcard: true,
permissionContent: i18next.t("domain"),
extensionSiteAccessOrigins,
} as ConfirmParam;
},
alias: ["GM.xmlHttpRequest"],
Expand Down
2 changes: 2 additions & 0 deletions src/app/service/service_worker/permission_verify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ export interface ConfirmParam {
wildcard?: boolean;
// 权限内容
permissionContent?: string;
// 需要在确认页面通过用户手势请求的扩展站点访问权限
extensionSiteAccessOrigins?: string[];
}

export interface UserConfirm {
Expand Down
3 changes: 3 additions & 0 deletions src/locales/de-DE/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,9 @@
"enabled_background_scripts": "Aktivierte und laufende Hintergrundskripte",
"script_accessing_cross_origin_resource": "Skript versucht auf Cross-Origin-Ressourcen zuzugreifen",
"confirm_operation_description": "Bitte bestätigen Sie, ob Sie dem Skript erlauben, diese Operation durchzuführen. Das Skript kann auch ein @connect-Tag hinzufügen, um diese Option zu überspringen",
"extension_site_access_title": "ScriptCat benötigt Website-Zugriff",
"extension_site_access_description": "Gewähren Sie dem Browser Website-Zugriff auf diesen Ursprung, damit ScriptCat die Anfrage ausführen kann. Dadurch wird die Website-Zugriffseinstellung der Erweiterung geändert.",
"extension_site_access_content": "Website",
"domain": "Domain",
"script_name": "Skriptname",
"request_domain": "Anfrage-Domain",
Expand Down
3 changes: 3 additions & 0 deletions src/locales/en-US/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,9 @@
"enabled_background_scripts": "Enabled and Running Background Scripts",
"script_accessing_cross_origin_resource": "Script is attempting to access a cross-origin resource",
"confirm_operation_description": "Please confirm if you allow the script to perform this operation. The script can also add the @connect tag to bypass this option.",
"extension_site_access_title": "ScriptCat needs site access",
"extension_site_access_description": "Grant browser site access to this origin so ScriptCat can perform the request. This changes the extension's site access setting.",
"extension_site_access_content": "Site",
"domain": "Domain",
"script_name": "Script Name",
"request_domain": "Request Domain",
Expand Down
3 changes: 3 additions & 0 deletions src/locales/ja-JP/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,9 @@
"enabled_background_scripts": "有効および実行中のバックグラウンドスクリプト",
"script_accessing_cross_origin_resource": "スクリプトがクロスドメインリソースにアクセスしようとしています",
"confirm_operation_description": "スクリプトがこの操作を実行することを許可するかどうか確認してください。スクリプトは@connectタグを追加してこのオプションをスキップすることもできます",
"extension_site_access_title": "ScriptCat にサイトアクセス権限が必要です",
"extension_site_access_description": "ScriptCat がこのリクエストを実行できるように、このオリジンへのブラウザーのサイトアクセス権限を許可してください。これは拡張機能のサイトアクセス設定を変更します。",
"extension_site_access_content": "サイト",
"domain": "ドメイン",
"script_name": "スクリプト名",
"request_domain": "リクエストドメイン",
Expand Down
3 changes: 3 additions & 0 deletions src/locales/ru-RU/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,9 @@
"enabled_background_scripts": "Включенные и работающие фоновые скрипты",
"script_accessing_cross_origin_resource": "Скрипт пытается получить доступ к кросс-доменному ресурсу",
"confirm_operation_description": "Пожалуйста, подтвердите, разрешаете ли вы скрипту выполнить эту операцию. Скрипт также может добавить тег @connect, чтобы пропустить эту опцию",
"extension_site_access_title": "ScriptCat требуется доступ к сайту",
"extension_site_access_description": "Разрешите браузеру доступ к сайту для этого источника, чтобы ScriptCat мог выполнить запрос. Это изменит настройку доступа к сайтам для расширения.",
"extension_site_access_content": "Сайт",
"domain": "Домен",
"script_name": "Название скрипта",
"request_domain": "Запрашиваемый домен",
Expand Down
3 changes: 3 additions & 0 deletions src/locales/vi-VN/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,9 @@
"enabled_background_scripts": "Script nền đã bật và đang chạy",
"script_accessing_cross_origin_resource": "Script đang cố gắng truy cập tài nguyên cross-origin",
"confirm_operation_description": "Vui lòng xác nhận xem bạn có cho phép script thực hiện thao tác này không. Script cũng có thể thêm thẻ @connect để bỏ qua tùy chọn này.",
"extension_site_access_title": "ScriptCat cần quyền truy cập trang web",
"extension_site_access_description": "Cấp quyền truy cập trang web của trình duyệt cho nguồn này để ScriptCat có thể thực hiện yêu cầu. Thao tác này thay đổi cài đặt truy cập trang web của tiện ích.",
"extension_site_access_content": "Trang web",
"domain": "Miền",
"script_name": "Tên script",
"request_domain": "Miền yêu cầu",
Expand Down
3 changes: 3 additions & 0 deletions src/locales/zh-CN/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,9 @@
"enabled_background_scripts": "开启和运行的后台脚本",
"script_accessing_cross_origin_resource": "脚本正在试图访问跨域资源",
"confirm_operation_description": "请您确认是否允许脚本进行此操作,脚本也可增加@connect标签跳过此选项",
"extension_site_access_title": "ScriptCat 需要站点访问权限",
"extension_site_access_description": "请授予浏览器对此来源的站点访问权限,让 ScriptCat 可以完成本次请求。这会修改扩展的站点访问设置。",
"extension_site_access_content": "站点",
"domain": "域名",
"script_name": "脚本名称",
"request_domain": "请求域名",
Expand Down
3 changes: 3 additions & 0 deletions src/locales/zh-TW/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,9 @@
"enabled_background_scripts": "開啟和執行的背景腳本",
"script_accessing_cross_origin_resource": "腳本正在嘗試存取跨網域資源",
"confirm_operation_description": "請您確認是否允許腳本進行此操作,腳本也可增加@connect標籤跳過此選項",
"extension_site_access_title": "ScriptCat 需要網站存取權限",
"extension_site_access_description": "請授予瀏覽器對此來源的網站存取權限,讓 ScriptCat 可以完成本次請求。這會修改擴充功能的網站存取設定。",
"extension_site_access_content": "網站",
"domain": "網域",
"script_name": "腳本名稱",
"request_domain": "請求網域",
Expand Down
Loading
Loading