feat: URL 黑白名单接入应用端(黑名单静默拦截)

- urlTrust:loadUrlRules 读取 site.url_whitelist/blacklist;classifyUrl 返回
  block/trusted/third-party(黑名单优先)
- openExternalLink / openInAppWebView / 全局锚点拦截器:命中黑名单静默不打开
- WebPage 判定改用 classifyUrl;黑名单防御直接关闭
This commit is contained in:
Pine
2026-09-07 22:19:36 +08:00
parent 2290c103db
commit 641d2f19ff
4 changed files with 145 additions and 34 deletions
+10 -5
View File
@@ -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/浏览器)→ 关闭覆盖层回到原页面;
+12 -1
View File
@@ -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);
+35 -1
View File
@@ -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)) {
+88 -27
View File
@@ -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";
}