Skip to content
Open
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
264 changes: 142 additions & 122 deletions src/app/service/service_worker/gm_api/gm_api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean | ConfirmParam> {
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) {
Expand Down Expand Up @@ -1296,7 +1307,16 @@ export default class GMApi {
chrome.notifications.update(<string>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<string>]>, sender: IGetSender) {
if (!sender.isType(GetSenderType.CONNECT)) {
throw new Error("GM_download ERROR: sender is not MessageConnect");
Expand Down