feat: URL 黑白名单接入应用端(黑名单静默拦截)
- urlTrust:loadUrlRules 读取 site.url_whitelist/blacklist;classifyUrl 返回 block/trusted/third-party(黑名单优先) - openExternalLink / openInAppWebView / 全局锚点拦截器:命中黑名单静默不打开 - WebPage 判定改用 classifyUrl;黑名单防御直接关闭
This commit is contained in:
@@ -62,19 +62,24 @@ export default function WebPage() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [location.search]);
|
||||
|
||||
// 判定白名单(配置异步加载)
|
||||
// 判定名单(配置异步加载):block(黑名单防御,直接关闭)/ trusted / third-party
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
if (!targetUrl) return;
|
||||
setTrust("unknown");
|
||||
void (async () => {
|
||||
const { loadTrustedHosts, isTrustedUrl } = await import("../../../utils/urlTrust");
|
||||
const extras = await loadTrustedHosts();
|
||||
const { classifyUrlAsync } = await import("../../../utils/urlTrust");
|
||||
const verdict = await classifyUrlAsync(targetUrl);
|
||||
if (!alive) return;
|
||||
setTrust(isTrustedUrl(targetUrl, extras) ? "trusted" : "third-party");
|
||||
if (verdict === "block") {
|
||||
// 防御:黑名单 URL 即使到达容器也不渲染,直接关闭(无提示)
|
||||
closeOverlay();
|
||||
return;
|
||||
}
|
||||
setTrust(verdict === "trusted" ? "trusted" : "third-party");
|
||||
})();
|
||||
return () => { alive = false; };
|
||||
}, [targetUrl]);
|
||||
}, [targetUrl, closeOverlay]);
|
||||
|
||||
// 退出:必须退出本网页容器本身,绝不做「返回上一级」。
|
||||
// 覆盖层模式(MainLayout/浏览器)→ 关闭覆盖层回到原页面;
|
||||
|
||||
@@ -68,7 +68,18 @@ export function interceptBlankLinkClicks(): () => void {
|
||||
if (!externalUrl) return;
|
||||
|
||||
event.preventDefault();
|
||||
openExternalLink(externalUrl);
|
||||
// 黑名单静默拦截:命中黑名单不允许打开,无任何提示
|
||||
void (async () => {
|
||||
const { isBlockedUrl } = await import("./urlTrust");
|
||||
let blocked = false;
|
||||
try {
|
||||
blocked = await isBlockedUrl(externalUrl);
|
||||
} catch {
|
||||
blocked = false;
|
||||
}
|
||||
if (blocked) return;
|
||||
openExternalLink(externalUrl);
|
||||
})();
|
||||
};
|
||||
|
||||
document.addEventListener("click", handleClick, true);
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
/**
|
||||
* Cross-runtime external link opener for browser, legacy pywebview, and Tauri.
|
||||
* It validates supported protocols and delegates desktop shells to native openers.
|
||||
*
|
||||
* 黑名单规则:命中黑名单的 URL 静默拦截(不允许打开,无任何提示)。
|
||||
*/
|
||||
import { invoke, isTauri } from "@tauri-apps/api/core";
|
||||
import { getPyWebViewApi } from "./pywebview";
|
||||
import { isOsPath } from "./navigationMode";
|
||||
import { useOsRoute } from "../os/osRouteStore";
|
||||
import { useWebViewStore } from "../os/webViewStore";
|
||||
import { isBlockedUrl } from "./urlTrust";
|
||||
|
||||
const URL_WITH_SCHEME_RE = /^[a-z][a-z\d+\-.]*:/i;
|
||||
const HTTP_PROTOCOLS = new Set(["http:", "https:"]);
|
||||
@@ -117,6 +120,8 @@ function detectExternalLinkRuntime(fullUrl: string): ExternalLinkRuntime {
|
||||
* 运行,底部按平台 URL 白名单标注「平台认证页面 / 第三方网页内容」;仅在
|
||||
* 非 OS 模式(纯浏览器部署,无桌面窗口系统)下退回系统浏览器新开。
|
||||
* mailto/tel 等协议保持原生系统行为。
|
||||
*
|
||||
* 黑名单优先:命中黑名单的 URL 静默拦截(直接不允许打开,无任何提示)。
|
||||
*/
|
||||
export function openExternalLink(
|
||||
url: string,
|
||||
@@ -132,6 +137,23 @@ export function openExternalLink(
|
||||
return;
|
||||
}
|
||||
|
||||
// 黑名单静默拦截:命中直接不允许打开,无任何提示
|
||||
void (async () => {
|
||||
try {
|
||||
if (await isBlockedUrl(fullUrl)) return;
|
||||
} catch {
|
||||
// 名单加载失败不阻断打开
|
||||
}
|
||||
openExternalLinkInternal(fullUrl, target, features, forceSystem);
|
||||
})();
|
||||
}
|
||||
|
||||
function openExternalLinkInternal(
|
||||
fullUrl: string,
|
||||
target: string,
|
||||
features: string,
|
||||
forceSystem: boolean,
|
||||
): void {
|
||||
// 应用内接管:桌面环境(Tauri / OS 模式)→ 打开应用内网页容器(沙箱运行)。
|
||||
// forceSystem=true 时(容器内「系统浏览器打开」)跳过接管。
|
||||
try {
|
||||
@@ -141,7 +163,7 @@ export function openExternalLink(
|
||||
const isDesktopShell = tauri || osPath;
|
||||
console.log(
|
||||
"[ext-debug] openExternalLink 判定",
|
||||
JSON.stringify({ url, fullUrl, proto, tauri, osPath, isDesktopShell, forceSystem }),
|
||||
JSON.stringify({ url: fullUrl, proto, tauri, osPath, isDesktopShell, forceSystem }),
|
||||
);
|
||||
if (!forceSystem && HTTP_PROTOCOLS.has(proto) && isDesktopShell) {
|
||||
openInAppWebView(fullUrl);
|
||||
@@ -175,6 +197,18 @@ export function openExternalLink(
|
||||
* OS 桌面(/os)→ 打开 core.web-view 窗口;MainLayout/浏览器 → 全屏覆盖层。
|
||||
*/
|
||||
export function openInAppWebView(fullUrl: string): void {
|
||||
// 防御:容器打开前也过一道黑名单(正常入口已在 openExternalLink 拦截)
|
||||
void (async () => {
|
||||
try {
|
||||
if (await isBlockedUrl(fullUrl)) return;
|
||||
} catch {
|
||||
// 名单加载失败不阻断打开
|
||||
}
|
||||
openInAppWebViewInternal(fullUrl);
|
||||
})();
|
||||
}
|
||||
|
||||
function openInAppWebViewInternal(fullUrl: string): void {
|
||||
try {
|
||||
const path = `/webpage?url=${encodeURIComponent(fullUrl)}`;
|
||||
if (isOsPath(window.location.pathname)) {
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
/**
|
||||
* urlTrust.ts — 平台 URL 白名单判定(应用内网页容器的认证标识)。
|
||||
* urlTrust.ts — 平台 URL 白名单 / 黑名单判定(应用内网页容器的认证标识)。
|
||||
*
|
||||
* 白名单来源:
|
||||
* 1. 后端站点配置 `site.url_whitelist`(逗号分隔的域名列表,admin 端可维护)
|
||||
* 名单来源:
|
||||
* 1. 后端站点配置 `site.url_whitelist` / `site.url_blacklist`
|
||||
* (逗号分隔的域名列表,admin 运营端可维护)
|
||||
* 2. 平台默认可信域名(opc 主站 / 资讯站 / 本地开发),始终生效
|
||||
*
|
||||
* 判定规则:比对 URL 的 host(忽略端口);同源(当前应用 origin)恒为可信。
|
||||
* 判定规则:
|
||||
* - 黑名单优先:命中黑名单 → block(不允许打开,静默拦截,无任何提示)
|
||||
* - 白名单:命中(默认域 + 配置)或同源 → trusted(底部显示「平台认证页面」)
|
||||
* - 其余 → third-party(底部提示注意甄别)
|
||||
* 比对 URL 的 hostname(忽略端口),支持子域后缀匹配。
|
||||
*/
|
||||
import { authApi } from "../api/modules/auth";
|
||||
|
||||
@@ -20,31 +25,58 @@ const DEFAULT_TRUSTED_HOSTS = new Set([
|
||||
"127.0.0.1",
|
||||
]);
|
||||
|
||||
let cachedExtras: string[] | null = null;
|
||||
export interface UrlRuleSet {
|
||||
allow: string[];
|
||||
block: string[];
|
||||
}
|
||||
|
||||
let cachedRules: UrlRuleSet | null = null;
|
||||
let cacheAt = 0;
|
||||
|
||||
/** 从站点配置读取 `site.url_whitelist`(逗号分隔域名),带 5 分钟缓存。 */
|
||||
export async function loadTrustedHosts(): Promise<string[]> {
|
||||
/** 解析配置值(逗号分隔的域名列表)→ host 数组(去协议、去端口、去路径)。 */
|
||||
function parseHostList(raw: string): string[] {
|
||||
return raw
|
||||
.split(/[,,\s]+/)
|
||||
.map((s) =>
|
||||
s
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/^https?:\/\//i, "")
|
||||
.split("/")[0]
|
||||
.split(":")[0],
|
||||
)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/** 从站点配置读取白名单 / 黑名单(带 5 分钟缓存)。 */
|
||||
export async function loadUrlRules(): Promise<UrlRuleSet> {
|
||||
const now = Date.now();
|
||||
if (cachedExtras && now - cacheAt < 5 * 60 * 1000) {
|
||||
return cachedExtras;
|
||||
if (cachedRules && now - cacheAt < 5 * 60 * 1000) {
|
||||
return cachedRules;
|
||||
}
|
||||
try {
|
||||
const items = await authApi.listSiteConfig();
|
||||
const cfg = (items || []).find((x) => x.key === "site.url_whitelist");
|
||||
const raw = cfg?.value || "";
|
||||
const hosts = raw
|
||||
.split(/[,,\s]+/)
|
||||
.map((s) => s.trim().toLowerCase().replace(/^https?:\/\//i, "").split("/")[0])
|
||||
.filter(Boolean);
|
||||
cachedExtras = hosts;
|
||||
const pick = (key: string) => {
|
||||
const cfg = (items || []).find((x) => x.key === key);
|
||||
return cfg?.value ? parseHostList(cfg.value) : [];
|
||||
};
|
||||
cachedRules = {
|
||||
allow: pick("site.url_whitelist"),
|
||||
block: pick("site.url_blacklist"),
|
||||
};
|
||||
cacheAt = now;
|
||||
return hosts;
|
||||
return cachedRules;
|
||||
} catch {
|
||||
return cachedExtras || [];
|
||||
return cachedRules || { allow: [], block: [] };
|
||||
}
|
||||
}
|
||||
|
||||
/** 兼容旧接口:仅返回白名单配置(默认域之外的部分)。 */
|
||||
export async function loadTrustedHosts(): Promise<string[]> {
|
||||
const rules = await loadUrlRules();
|
||||
return rules.allow;
|
||||
}
|
||||
|
||||
/** 提取 URL 的 host(含子域逐级,用于后缀匹配)。 */
|
||||
function hostLevels(host: string): string[] {
|
||||
const parts = host.split(".");
|
||||
@@ -55,22 +87,51 @@ function hostLevels(host: string): string[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 判断某 URL 是否在平台白名单内(同步:仅用默认域 + 同源;配置异步合并后调用 isTrustedUrlFor)。 */
|
||||
export function isTrustedUrl(url: string, extraHosts: string[] = []): boolean {
|
||||
/** 域名是否命中某个名单(精确 + 子域后缀匹配)。 */
|
||||
function hostMatches(host: string, list: string[]): boolean {
|
||||
const set = new Set(list.map((h) => h.toLowerCase()));
|
||||
return hostLevels(host).some((h) => set.has(h));
|
||||
}
|
||||
|
||||
/** 同步判定(须先 loadUrlRules 取得 rules)。
|
||||
* block(黑名单)/ trusted(平台认证)/ third-party(第三方,注意甄别)。 */
|
||||
export function classifyUrl(
|
||||
url: string,
|
||||
rules: UrlRuleSet | null = null,
|
||||
): "block" | "trusted" | "third-party" {
|
||||
try {
|
||||
const parsed = new URL(url, window.location.origin);
|
||||
const host = parsed.hostname.toLowerCase();
|
||||
if (host === window.location.hostname.toLowerCase()) return true; // 同源恒可信
|
||||
const trusted = new Set([...DEFAULT_TRUSTED_HOSTS, ...extraHosts.map((h) => h.toLowerCase())]);
|
||||
// 精确匹配 + 子域后缀匹配(a.opc.pinesound.cn 命中 pinesound.cn)
|
||||
return hostLevels(host).some((h) => trusted.has(h));
|
||||
const block = rules?.block || [];
|
||||
if (hostMatches(host, block)) return "block";
|
||||
if (host === window.location.hostname.toLowerCase()) return "trusted"; // 同源恒可信
|
||||
const allow = rules?.allow || [];
|
||||
if (hostMatches(host, [...DEFAULT_TRUSTED_HOSTS, ...allow])) return "trusted";
|
||||
return "third-party";
|
||||
} catch {
|
||||
return false;
|
||||
return "third-party";
|
||||
}
|
||||
}
|
||||
|
||||
/** 异步版:加载配置后判定。 */
|
||||
export async function classifyUrlAsync(url: string): Promise<"block" | "trusted" | "third-party"> {
|
||||
const rules = await loadUrlRules();
|
||||
return classifyUrl(url, rules);
|
||||
}
|
||||
|
||||
/** 是否命中黑名单(异步;命中即静默拦截)。 */
|
||||
export async function isBlockedUrl(url: string): Promise<boolean> {
|
||||
const rules = await loadUrlRules();
|
||||
return classifyUrl(url, rules) === "block";
|
||||
}
|
||||
|
||||
/** 同步判定某 URL 是否在平台白名单内(兼容旧调用)。 */
|
||||
export function isTrustedUrl(url: string, extraHosts: string[] = []): boolean {
|
||||
return classifyUrl(url, { allow: extraHosts, block: [] }) !== "third-party";
|
||||
}
|
||||
|
||||
/** 异步版:加载配置后判定(WebPage 顶部/底部标识用)。 */
|
||||
export async function isTrustedUrlAsync(url: string): Promise<boolean> {
|
||||
const extras = await loadTrustedHosts();
|
||||
return isTrustedUrl(url, extras);
|
||||
const rules = await loadUrlRules();
|
||||
return classifyUrl(url, rules) !== "third-party";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user