diff --git a/console/src/pages/portal/Web/WebPage.tsx b/console/src/pages/portal/Web/WebPage.tsx index 9c52e8e..0353c3d 100644 --- a/console/src/pages/portal/Web/WebPage.tsx +++ b/console/src/pages/portal/Web/WebPage.tsx @@ -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/浏览器)→ 关闭覆盖层回到原页面; diff --git a/console/src/utils/interceptBlankLinkClicks.ts b/console/src/utils/interceptBlankLinkClicks.ts index b580842..86a6353 100644 --- a/console/src/utils/interceptBlankLinkClicks.ts +++ b/console/src/utils/interceptBlankLinkClicks.ts @@ -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); diff --git a/console/src/utils/openExternalLink.ts b/console/src/utils/openExternalLink.ts index a126d8c..a5c3d54 100644 --- a/console/src/utils/openExternalLink.ts +++ b/console/src/utils/openExternalLink.ts @@ -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)) { diff --git a/console/src/utils/urlTrust.ts b/console/src/utils/urlTrust.ts index 7dd9351..4d3dbc1 100644 --- a/console/src/utils/urlTrust.ts +++ b/console/src/utils/urlTrust.ts @@ -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 { +/** 解析配置值(逗号分隔的域名列表)→ 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 { 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 { + 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 { + 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 { - const extras = await loadTrustedHosts(); - return isTrustedUrl(url, extras); + const rules = await loadUrlRules(); + return classifyUrl(url, rules) !== "third-party"; }