diff --git a/e2e/gm-api.spec.ts b/e2e/gm-api.spec.ts index 908038167..12abd740d 100644 --- a/e2e/gm-api.spec.ts +++ b/e2e/gm-api.spec.ts @@ -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 = { @@ -89,18 +88,54 @@ async function startGMApiMockServer(): Promise { 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( + 'ScriptCat E2E
ScriptCat E2E
' + ); }); await new Promise((resolve, reject) => { @@ -125,10 +160,24 @@ async function startGMApiMockServer(): Promise { }; } +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`); } /** @@ -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); @@ -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; @@ -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 } ); @@ -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 } ); @@ -271,7 +319,7 @@ test.describe("GM API", () => { context, extensionId, "inject_content_test.js", - `${TARGET_URL}?inject_content`, + `${gmApiMockServer.origin}/?inject_content`, 60_000 ); @@ -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}`); @@ -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 ); 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 5be6e38da..cbfe6964f 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 @@ -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 => ({ @@ -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(); + }); +}); 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 d10a7f903..ccb272a37 100644 --- a/src/app/service/service_worker/gm_api/gm_api.ts +++ b/src/app/service/service_worker/gm_api/gm_api.ts @@ -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; @@ -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)) { @@ -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); @@ -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, @@ -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"], diff --git a/src/app/service/service_worker/permission_verify.ts b/src/app/service/service_worker/permission_verify.ts index 0225441f7..34681d1d1 100644 --- a/src/app/service/service_worker/permission_verify.ts +++ b/src/app/service/service_worker/permission_verify.ts @@ -29,6 +29,8 @@ export interface ConfirmParam { wildcard?: boolean; // 权限内容 permissionContent?: string; + // 需要在确认页面通过用户手势请求的扩展站点访问权限 + extensionSiteAccessOrigins?: string[]; } export interface UserConfirm { diff --git a/src/locales/de-DE/translation.json b/src/locales/de-DE/translation.json index 825a9c988..bcf25d8d6 100644 --- a/src/locales/de-DE/translation.json +++ b/src/locales/de-DE/translation.json @@ -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", diff --git a/src/locales/en-US/translation.json b/src/locales/en-US/translation.json index 9173d45e0..a5a5b2ab5 100644 --- a/src/locales/en-US/translation.json +++ b/src/locales/en-US/translation.json @@ -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", diff --git a/src/locales/ja-JP/translation.json b/src/locales/ja-JP/translation.json index ec8a0f213..5724282e7 100644 --- a/src/locales/ja-JP/translation.json +++ b/src/locales/ja-JP/translation.json @@ -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": "リクエストドメイン", diff --git a/src/locales/ru-RU/translation.json b/src/locales/ru-RU/translation.json index 7f872aef3..3eb10e060 100644 --- a/src/locales/ru-RU/translation.json +++ b/src/locales/ru-RU/translation.json @@ -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": "Запрашиваемый домен", diff --git a/src/locales/vi-VN/translation.json b/src/locales/vi-VN/translation.json index 0e6c9e797..247218591 100644 --- a/src/locales/vi-VN/translation.json +++ b/src/locales/vi-VN/translation.json @@ -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", diff --git a/src/locales/zh-CN/translation.json b/src/locales/zh-CN/translation.json index 128975b18..eef65c4c4 100644 --- a/src/locales/zh-CN/translation.json +++ b/src/locales/zh-CN/translation.json @@ -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": "请求域名", diff --git a/src/locales/zh-TW/translation.json b/src/locales/zh-TW/translation.json index 8d457f11a..90288e6d8 100644 --- a/src/locales/zh-TW/translation.json +++ b/src/locales/zh-TW/translation.json @@ -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": "請求網域", diff --git a/src/pages/confirm/App.tsx b/src/pages/confirm/App.tsx index b440a33ad..ef6e45755 100644 --- a/src/pages/confirm/App.tsx +++ b/src/pages/confirm/App.tsx @@ -45,6 +45,21 @@ function App() { return async () => { if (!uuid) return; try { + if (allow && confirm?.extensionSiteAccessOrigins?.length) { + const granted = await chrome.permissions.request({ + origins: confirm.extensionSiteAccessOrigins, + }); + if (!granted) { + await permissionClient + .confirm(uuid, { + allow: false, + type: 0, + }) + .catch(() => {}); + window.close(); + return; + } + } await permissionClient.confirm(uuid, { allow, type, @@ -60,6 +75,7 @@ function App() { }; const metadata = useMemo(() => (confirm && confirm.metadata && Object.keys(confirm.metadata)) || [], [confirm]); + const isExtensionSiteAccessConfirm = confirm?.permission === "extension-site-access"; return (
@@ -71,73 +87,85 @@ function App() { ))} {confirm?.describe} -
- -
-
- - - - {likeNum > 2 && ( - - )} - - {likeNum > 2 && ( -
+ + ) : ( + <> +
+ - )} - -
-
- - - - {likeNum > 2 && ( - - )} - - {likeNum > 2 && ( - - )} - -
+
+
+ + + + {likeNum > 2 && ( + + )} + + {likeNum > 2 && ( + + )} + +
+
+ + + + {likeNum > 2 && ( + + )} + + {likeNum > 2 && ( + + )} + +
+ + )} );