From b310661c11b147fe0f9f9667c4a5296f1332f7c2 Mon Sep 17 00:00:00 2001 From: Master_G Date: Sat, 13 Jun 2026 17:18:04 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20native=20GM=5Fdownload=20honors?= =?UTF-8?q?=20@connect=20(prompts=20for=20undeclared=20hosts)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GM_xmlhttpRequest gates cross-origin requests via @connect / blacklist / site-access, but native GM_download re-entered the request engine with a direct call that skipped the check. Route it through the same check. Unlike TM (which scopes @connect to xhr/fetch only), an undeclared host prompts the user instead of being refused, so existing download scripts keep working with consent. @connect-matched hosts, the blacklist, and browser/blob downloads are unaffected; GM_xmlhttpRequest is unchanged. --- .../service/service_worker/gm_api/gm_api.ts | 264 ++++++++++-------- 1 file changed, 142 insertions(+), 122 deletions(-) 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 ccb272a37..920b28574 100644 --- a/src/app/service/service_worker/gm_api/gm_api.ts +++ b/src/app/service/service_worker/gm_api/gm_api.ts @@ -761,138 +761,149 @@ export default class GMApi { return true; } - @PermissionVerify.API({ - confirm: async (request: GMApiRequest<[GMSend.XHRDetails?]>, sender: IGetSender, GMApiInstance: GMApi) => { - const msgConn = sender.getConnect(); - if (!msgConn) { - throw new Error("GM_xmlhttpRequest ERROR: msgConn is undefined"); - } - // conn 为 nested scope 内 local 存取 - let throwErrorFn: ((error: string) => Error) | null = ((conn: MessageConnect | null) => { - let errorOccur: string | null = null; - const doLoadEnd = () => { - conn?.sendMessage({ - action: "onloadend", - data: { - status: 0, - responseHeaders: "", - error: errorOccur, - readyState: 4, // ERROR. DONE. - }, - }); - conn?.disconnect(); // 断开连结 - conn = null; // 释放 - }; - return (error: string) => { - errorOccur = error; - conn?.sendMessage({ - action: "onerror", - data: { - status: 0, - responseHeaders: "", - error: errorOccur, - readyState: 4, // ERROR. DONE. - }, - }); - // throwErrorFn 不是由通讯管控 onloadend. 需要手动处理. 排程在下一个 microTask 避免影响 throw Error 流程 - Promise.resolve().then(doLoadEnd); - return new Error(errorOccur); - }; - })(msgConn); - const details = request.params[0]; - if (!details) { - throw throwErrorFn("param is failed"); - } - let url; - try { - url = new URL(details.url); - } catch { - const msg = `Refused to connect to "${details.url}": The url is invalid`; - throw throwErrorFn(msg); - } - if (GMApiInstance.gmExternalDependencies.isBlacklistNetwork(url)) { - 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; + // GM_xmlhttpRequest 与 native GM_download 共用的跨域校验(@connect / 黑名单 / 站点访问) + // softConnect=true(GM_download):未列入 @connect 的域名改为弹窗询问而非直接拒绝,避免破坏 TM 下载脚本 + private async verifyXhrConnect( + request: GMApiRequest<[GMSend.XHRDetails?]>, + sender: IGetSender, + softConnect = false + ): Promise { + const msgConn = sender.getConnect(); + if (!msgConn) { + throw new Error("GM_xmlhttpRequest ERROR: msgConn is undefined"); + } + // conn 为 nested scope 内 local 存取 + let throwErrorFn: ((error: string) => Error) | null = ((conn: MessageConnect | null) => { + let errorOccur: string | null = null; + const doLoadEnd = () => { + conn?.sendMessage({ + action: "onloadend", + data: { + status: 0, + responseHeaders: "", + error: errorOccur, + readyState: 4, // ERROR. DONE. + }, + }); + conn?.disconnect(); // 断开连结 + conn = null; // 释放 }; - const connectMatched = getConnectMatched(request.script.metadata.connect, url, sender); - if (connectMatched === ConnectMatch.ALL) { - // SC: 有 @connect * 就不询问 - return hasOriginPermission ? true : confirmExtensionSiteAccess(); - } else { - // 如果 @connect 有匹配到就放行 - if (connectMatched > 0) { - return hasOriginPermission ? true : confirmExtensionSiteAccess(); - } - // @connect 没有匹配,但有列明 @connect 的话,则自动拒绝 - if (request.script.metadata.connect?.find((e) => !!e)) { - // 查询数据库权限记录,如果之前用户允许过该域名,则放行,否则拒绝 - const ret = await GMApiInstance.permissionVerify.queryPermission(request, { - permission: "cors", - permissionValue: url.hostname, - wildcard: true, - }); - if (ret && ret.allow) { - 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); - } - // 其他情况:要询问用户 + return (error: string) => { + errorOccur = error; + conn?.sendMessage({ + action: "onerror", + data: { + status: 0, + responseHeaders: "", + error: errorOccur, + readyState: 4, // ERROR. DONE. + }, + }); + // throwErrorFn 不是由通讯管控 onloadend. 需要手动处理. 排程在下一个 microTask 避免影响 throw Error 流程 + Promise.resolve().then(doLoadEnd); + return new Error(errorOccur); + }; + })(msgConn); + const details = request.params[0]; + if (!details) { + throw throwErrorFn("param is failed"); + } + let url; + try { + url = new URL(details.url); + } catch { + const msg = `Refused to connect to "${details.url}": The url is invalid`; + throw throwErrorFn(msg); + } + if (this.gmExternalDependencies.isBlacklistNetwork(url)) { + 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 - - 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, - title: i18next.t("script_accessing_cross_origin_resource"), + permission: "extension-site-access", + permissionValue: originPattern, + title: i18next.t("extension_site_access_title"), metadata, - describe: i18next.t("confirm_operation_description"), - wildcard: true, - permissionContent: i18next.t("domain"), + 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 hasOriginPermission ? true : confirmExtensionSiteAccess(); + } else { + // 如果 @connect 有匹配到就放行 + if (connectMatched > 0) { + return hasOriginPermission ? true : confirmExtensionSiteAccess(); + } + // @connect 没有匹配,但有列明 @connect 的话,则自动拒绝 + if (request.script.metadata.connect?.find((e) => !!e)) { + // 查询数据库权限记录,如果之前用户允许过该域名,则放行,否则拒绝 + const ret = await this.permissionVerify.queryPermission(request, { + permission: "cors", + permissionValue: url.hostname, + wildcard: true, + }); + if (ret && ret.allow) { + return hasOriginPermission ? true : confirmExtensionSiteAccess(); + } + // softConnect(GM_download):不直接拒绝,落到下方用户确认,由用户决定是否放行 + if (!softConnect) { + const msg = `Refused to connect to "${details.url}": This domain is not a part of the @connect list`; + throw throwErrorFn(msg); + } + } + // 其他情况:要询问用户 + } + 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 + + const ret = await this.permissionVerify.queryPermission(request, { + permission: "cors", + permissionValue: url.hostname, + wildcard: true, + }); + if (ret?.allow && !hasOriginPermission) { + return confirmExtensionSiteAccess(); + } + return { + permission: "cors", + permissionValue: url.hostname, + title: i18next.t("script_accessing_cross_origin_resource"), + metadata, + describe: i18next.t("confirm_operation_description"), + wildcard: true, + permissionContent: i18next.t("domain"), + extensionSiteAccessOrigins, + } as ConfirmParam; + } + + @PermissionVerify.API({ + confirm: (request, sender, gmApi) => gmApi.verifyXhrConnect(request, sender), alias: ["GM.xmlHttpRequest"], }) async GM_xmlhttpRequest(request: GMApiRequest<[GMSend.XHRDetails]>, sender: IGetSender) { @@ -1296,7 +1307,16 @@ export default class GMApi { chrome.notifications.update(id, options); } - @PermissionVerify.API() + @PermissionVerify.API({ + // native 下载会发起真实跨域请求,需校验黑名单 / 站点访问 / @connect。 + // 与 TM 不同(TM 的 @connect 只作用于 xhr/fetch),未列入 @connect 的域名改为弹窗询问由用户决定,而非直接拒绝。 + confirm: async (request, sender, gmApi) => { + if (request.params[0]?.downloadMode !== "native") { + return true; + } + return gmApi.verifyXhrConnect(request, sender, true); + }, + }) async GM_download(request: GMApiRequest<[GMTypes.DownloadDetails]>, sender: IGetSender) { if (!sender.isType(GetSenderType.CONNECT)) { throw new Error("GM_download ERROR: sender is not MessageConnect");