// ==UserScript== // @name GSecurity AutoNavGuard (Via) // @namespace zyf/gs-autonav-via // @version 2.5.3 // @description 自动跳转防护(询问版·兜底):拦截网页JS发起的跨站/可疑协议自动跳转,与 Via『重定向到非同源界面前先询问』双保险。平时零界面,脚本发起的跨站跳转一律弹窗询问;覆盖 form.submit()/requestSubmit() 直调 // @author zyf // @license MIT // @match *://*/* // @run-at document-start // @grant none // ==/UserScript== // // ──────────────────────────────────────────────────────────── // v2.5.3 追加:① .click() 与 map/area 伪点击兜底;② document.write/ // writeln、innerHTML/outerHTML/insertAdjacentHTML 写入 meta refresh 的字符串 // 前置检查;③ 用户确认 about:blank 后,对空白窗口 document.write/writeln // 中的 meta refresh 继续守卫。 // // v2.5.2 追加:meta refresh 属性后写入兜底:对先写 http-equiv=refresh、 // 后写 content=url 的脚本创建 meta,先把 refresh 状态存在脚本内部 shadow 中, // 不立即落到真实 DOM,等 content 出现后再裁决,减少 Via WebView 提前安排刷新。 // // v2.5.1 追加:meta refresh 前置拦截:hook appendChild/insertBefore/setAttribute // 与 HTMLMetaElement.content/httpEquiv 属性写入,减少 Via WebView 中 MutationObserver // 回调来不及、浏览器已先跳转的问题。 // // v2.5.0 追加:① 高风险脚本协议默认静默阻止(javascript:/intent:/data:/file: // /filesystem:),减少广告协议弹窗;② 询问弹窗优先挂到 Shadow DOM,降低页面 // CSS 干扰;③ 启动诊断输出各 hook 安装状态;④ 补手机 Via 探测页。 // // v2.4.0 追加:① 协议策略从"非导航协议直接放行"改为显式矩阵: // 用户真实点击仍放行;脚本触发 javascript:/mailto:/tel:/sms:/data:/blob:/intent: // /file:/filesystem: 时询问(javascript:/intent: 按高风险标注);② form submit 事件确认后 // 改用 requestSubmit()/submit() 原语义恢复,保留 POST、target、enctype、submitter // 等浏览器提交行为;③ form.requestSubmit() 直调补 hook;④ window.open 空白窗口先询问, // 减少 about:blank 后写入广告/跳转代码的绕过;⑤ 脚本伪造链接点击确认后 // 改为重新执行 a.click(),尽量保留 target/download/referrerPolicy;⑥ 弹窗改为 // dialog 语义、Esc 阻止、焦点默认落在"不跳转"并关闭后恢复;⑦ 目标展示改用 // URL 解析,显示真实 hostname/端口/完整地址,避免 user@host 伪装;⑧ meta refresh // 观察在 documentElement 尚未就绪时重试,MutationObserver 缺失时短时轮询兜底。 // // v2.3.1 追加:① .click() 二次询问的 bypass 从 DOM 元素属性改为脚本内部 WeakSet // (状态不暴露给网页;无 WeakSet 的老 WebView 退回元素标记); // ② form.submit() 改用 form.action 语义(与浏览器一致:无 action 时默认当前文档 URL,同源放行); // ③ 队列丢弃增加 droppedCount 统计(区分"一次广告"与"疯狂尝试几十次") // // v2.3.0 追加:① 修复 .click() 确认后二次询问——用户放行后 origAC.call(a) 会同步派发 // isTrusted=false 的 click 事件,全局捕获器会再拦一次;现用元素级 bypass 标记跳过; // ② 移除 beforeunload hook(离开提醒骚扰防护交回 GSecurity 主脚本,本脚本只做导航防护); // ③ chainAttack 更名 rapidRedirectAttempts(语义:快速跳转尝试,非已完成导航链); // ④ askQueue 状态扁平化(url/via/risk/reasons/resume);⑤ 补充 Location 各写法集成测试 // // v2.2.0 追加:① HTMLFormElement.prototype.submit 直调 hook(原生 submit() 不触发 submit 事件); // ② sameOrigin 改用 new URL() 浏览器语义(修复 //evil.com 被误判为相对 URL 的漏洞); // ③ isTrusted 严格 === true(false/undefined 一律按脚本导航处理); // ④ 询问队列上限 MAX_ASK_QUEUE=2,防恶意脚本弹窗轰炸; // ⑤ 特征库改 hostname 精确/子域匹配(query 里的域名不再误报); // ⑥ 弹窗内容全部 HTML escape + 显示当前域名;⑦ meta refresh url= 引号剥离 // // v2.1.0 设计(兜底版,与 Via『重定向到非同源界面前先询问』双保险): // // 1. 只做"脚本发起的跨站跳转"拦截,不含去广告、不含黑名单(用户有自己的规则) // 2. 平时无任何悬浮窗/徽标/面板,零界面 // 3. 判断口径与 Via 内核一致: // - 用户真实点击链接(浏览器可信事件 isTrusted)→ 用户导航,直接放行 // - 脚本发起的跨站跳转(location API / window.open / meta / form / a.click) // → 一律弹窗询问,不再因"刚点过屏幕"而放行(v2.0 漏洞:小说站 // "点击任意处→立即自动跳转"被手势窗口放过) // 4. 弹窗展示:要不要跳转?推不推荐?(⚠可疑 / 🔴高风险)什么类型的理由? // 5. 每次都问,不记忆、不加黑名单;同 URL 500ms 去重防刷屏 // 6. 内置精简"短链/跳转服务"特征库,仅用于推荐度标注,不拦截 // // 判断流程: // // 导航请求 ── 用户真实点击链接(isTrusted)─────→ 直接放行 // ├─ 脚本触发 about: ────────────────→ 直接放行 // ├─ 脚本触发高风险协议 ──────────────→ 默认静默阻止 // ├─ 脚本触发同源?(翻页/章节)───────→ 直接放行 // └─ 脚本发起跨站跳转 ───────────────→ 弹窗询问 // ├─ 特征域名 → 🔴 高风险 // └─ 短时间多次跳转 → 🔴 高风险 // // JS 层能力边界(Via 原生功能可覆盖,本脚本只作兜底): // - 现代 Chromium/WebView 中 location.href / assign / replace 常是不可重定义的 // unforgeable 实例属性;本脚本会尽力 hook,但失败时必须依赖 Via 原生 // 『重定向到非同源界面前先询问』 // - window.location = url 直接赋值不可重定义(unforgeable) // - 异源 iframe / 子框架内部的跳转无法钩取 // 这两类请依赖 Via『重定向到非同源界面前先询问』(内核级拦截) // ──────────────────────────────────────────────────────────── ;(function () { "use strict"; // Node 环境下加载(单元测试用),不执行任何 DOM 操作 var IS_NODE = typeof module !== "undefined" && module.exports && typeof window === "undefined"; // ============================================================ // CONFIG // ============================================================ var CONFIG = { CHAIN_WINDOW: 3000, // ms:跳转链检测时间窗 CHAIN_LIMIT: 2, // 时间窗内触发询问达到该次数 → 判定为跳转链,升级高风险 ASK_DEBOUNCE_MS: 500, // ms:相同目标地址去重窗口(防恶意脚本循环弹窗) MAX_ASK_QUEUE: 2, // 询问队列上限(含正在显示的 1 个,即最多 1 个排队),超出丢弃防轰炸 REJECT_COOLDOWN_MS: 30000, // ms:用户点过【不跳转】的地址,该时长内再触发直接静默阻止 BLOCK_HIGH_RISK_PROTOCOLS: true, // true:脚本触发 javascript:/intent:/data:/file: 等直接阻止,不弹窗 SILENT_BLOCK_PROTOCOLS: /^(javascript|intent|data|file|filesystem)$/i, PASS_PROTOCOLS: /^$/i, ASK_PROTOCOLS: /^(javascript|mailto|tel|sms|data|blob|intent|file|filesystem)$/i // 脚本触发时需要询问的可疑协议 }; // ----- 精简特征库(仅用于推荐度标注"高概率跳转链",不拦截)----- var FEATURE_DOMAINS = [ // 短链 / 强制跳转服务 "adf.ly", "adfly.com", "bc.vc", "shorte.st", "ouo.io", "linkvertise.com", "sub2unlock.com", "sub2get.com", "megalink.pro", "fas.li", "destyy.com", "earnlink.io", "link1s.com", "shink.in", "cutwin.com", "exe.io", "megaurl.in", "clicksfly.com", "srnk.co", "clk.ink", "linkbucks.com", "short.am", // 常见强制弹窗 / 跳转网络 "popads.net", "popcash.net", "propellerads.com", "popunder.biz", "exoclick.com", "clickadilla.com", "popmyads.com", "trafficjunky.com", "juicyads.com", "plugrush.com", "mgid.com", "revcontent.com" ]; // ============================================================ // 纯逻辑区(无 DOM 依赖,可在 Node 中单测) // ============================================================ /** 目标地址的 hostname 是否命中特征库(精确/子域匹配,query 参数里的域名不误报) */ function isSuspiciousDomain(url) { var host = ""; try { host = new URL(String(url || "").trim()).hostname.toLowerCase(); } catch (e) { return false; } if (!host) return false; for (var i = 0; i < FEATURE_DOMAINS.length; i++) { var d = FEATURE_DOMAINS[i]; if (host === d || host.slice(-(d.length + 1)) === "." + d) return true; } return false; } /** * 判断 url 与 baseHref 是否同源(浏览器 URL 解析语义)。 * /page/2、./c、?x、#a → 同源;https://other.com、//other.com、http://other.com、不同端口 → 跨站。 * URL 构造失败时按"跨站"处理(宁可多问不漏)。 */ function sameOrigin(url, baseHref) { var target = null, current = null; try { target = new URL(String(url || "").trim(), String(baseHref || "")); current = new URL(String(baseHref || "")); } catch (e) { return false; } return target.origin === current.origin; } function parseUrl(url, baseHref) { try { return new URL(String(url || "").trim(), String(baseHref || location.href || "")); } catch (e) { return null; } } function protocolOf(url, baseHref) { var u = parseUrl(url, baseHref); if (u && u.protocol) return u.protocol.replace(/:$/, "").toLowerCase(); var m = /^\s*([a-zA-Z][a-zA-Z0-9+.-]*):/.exec(String(url || "")); return m ? m[1].toLowerCase() : ""; } function protocolDecision(protocol) { var p = String(protocol || "").replace(/:$/, "").toLowerCase(); if (!p) return "normal"; if (CONFIG.PASS_PROTOCOLS.test(p)) return "allow"; if (p === "about") return "allow"; if (CONFIG.BLOCK_HIGH_RISK_PROTOCOLS && CONFIG.SILENT_BLOCK_PROTOCOLS.test(p)) return "block"; if (CONFIG.ASK_PROTOCOLS.test(p)) return "ask"; return "normal"; } function extractRefreshUrl(content) { var m = String(content || "").match(/url\s*=\s*(?:["']?)([^"'\s;]+)/i); return m ? m[1].trim() : ""; } function findMetaRefreshUrlInHtml(html) { var raw = String(html || ""); if (!raw || raw.indexOf("<") === -1) return ""; if (!/]*>/ig); if (!tags) return ""; for (var i = 0; i < tags.length; i++) { var tag = tags[i]; if (!/http-equiv\s*=\s*(?:["']?\s*)?refresh\b/i.test(tag)) continue; var cm = /\bcontent\s*=\s*(["'])([\s\S]*?)\1/i.exec(tag); var content = cm ? cm[2] : ""; if (!content) { cm = /\bcontent\s*=\s*([^\s>]+)/i.exec(tag); content = cm ? cm[1] : ""; } var url = extractRefreshUrl(content); if (url) return url; } return ""; } // 快速跳转尝试检测(rapid redirect attempts):时间窗内"需要询问的跳转请求" // 累计达到 CHAIN_LIMIT → true(升级高风险)。统计的是询问尝试次数,不是已完成的 // 真实导航链;对恶意脚本更保守(更快触发高风险)。 var redirectLog = []; function rapidRedirectAttempts(now, windowMs, limit) { var n = now || Date.now(); var w = windowMs || CONFIG.CHAIN_WINDOW; var l = limit || CONFIG.CHAIN_LIMIT; var kept = []; for (var i = 0; i < redirectLog.length; i++) { if (n - redirectLog[i] < w) kept.push(redirectLog[i]); } kept.push(n); redirectLog = kept; return kept.length >= l; } function resetRedirects() { redirectLog = []; } /** 触发途径 → 中文说明 */ function viaLabel(via) { var map = { "location.href": "脚本修改页面地址", "location.assign": "脚本调用 assign 跳转", "location.replace": "脚本调用 replace 跳转", "window.open": "脚本打开新窗口", "window.open.blank": "脚本打开空白窗口", "blank-window.location": "空白窗口被改地址", "blank-window.href": "空白窗口被改地址", "blank-window.assign": "空白窗口被改地址", "blank-window.replace": "空白窗口被改地址", "blank-window.document.write": "空白窗口写入 HTML 内容", "blank-window.document.writeln": "空白窗口写入 HTML 内容", ".click": "脚本触发链接点击", ".click": "脚本触发图像映射链接点击", "link click": "链接点击", "form submit": "表单提交", "form.submit": "脚本强制提交表单", "form.requestSubmit": "脚本请求提交表单", "meta refresh": "页面自动刷新", "document.write": "脚本写入 HTML 内容", "document.writeln": "脚本写入 HTML 内容", "Element.innerHTML": "脚本写入 HTML 内容", "Element.outerHTML": "脚本替换 HTML 内容", "Element.insertAdjacentHTML": "脚本插入 HTML 内容" }; return map[via] || via; } function describeUrl(url, baseHref) { var raw = String(url === undefined || url === null ? "" : url).trim(); var u = parseUrl(raw, baseHref || (typeof location !== "undefined" ? location.href : "")); if (!u) { return { ok: false, full: raw, scheme: protocolOf(raw, baseHref), hostname: "无法解析", port: "", username: "", password: "" }; } return { ok: true, full: u.href, scheme: u.protocol.replace(/:$/, ""), hostname: u.hostname || "(无主机名)", port: u.port || "", username: u.username || "", password: u.password || "" }; } /** HTML 转义(渲染网页提供的数据时必须使用,防护脚本 UI 不信任页面输入) */ function escapeHtml(s) { return String(s === undefined || s === null ? "" : s) .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } /** * 核心裁决:评估一次导航请求(脚本发起的跨站跳转)。 * @param url 目标地址 * @param via 触发途径(日志/提示用) * @param state 可选注入 { href, now, suspicious, chain },便于测试 * @returns { action: "allow"|"ask"|"block", risk: "low"|"medium"|"high", reasons: [String] } * * 注意:用户真实点击链接(isTrusted)由调用方在 click 分支直接放行, * 不进入本函数;本函数只裁决"脚本发起的跳转",一律不看手势。 */ function evaluateNavigation(url, via, state) { var st = state || {}; var s = String(url || "").trim(); var out = { action: "allow", risk: "low", reasons: [] }; if (!s) return out; var href = st.href !== undefined ? st.href : location.href; var now = st.now !== undefined ? st.now : Date.now(); var protocol = protocolOf(s, href); var pDecision = protocolDecision(protocol); if (pDecision === "allow") return out; if (pDecision !== "ask" && pDecision !== "block") { // 同源放行(阅读站翻页/章节全在同源内) if (sameOrigin(s, href)) return out; } // 其余:脚本发起的跨站跳转 → 询问 out.action = pDecision === "block" ? "block" : "ask"; out.risk = "medium"; out.reasons.push("页面在无操作的情况下自动跳转(" + viaLabel(via) + ")"); if (pDecision === "ask" || pDecision === "block") { out.reasons.push("目标使用 " + protocol + ": 协议,脚本触发时可能绕过普通网页跳转判断"); if (protocol === "javascript") { out.risk = "high"; out.reasons.push("javascript: 会在当前页面执行脚本,可能继续触发不可 hook 的 location 跳转"); } else if (protocol === "intent") { out.risk = "high"; out.reasons.push("intent: 可能唤起外部应用或应用商店,高风险"); } else if (protocol === "mailto" || protocol === "tel" || protocol === "sms") { out.risk = "high"; out.reasons.push(protocol + ": 可能唤起外部应用,不应由网页脚本自动触发"); } else if (protocol === "data" || protocol === "file" || protocol === "filesystem") { out.risk = "high"; out.reasons.push(protocol + ": 可承载独立页面或本地/沙盒资源,不建议由网页脚本自动打开"); } if (pDecision === "block") { out.reasons.push("已按配置静默阻止该高风险协议"); } } else { out.reasons.push("目标域名与当前网站不是同一站点"); } var desc = describeUrl(s, href); if (desc.username || desc.password) { out.risk = "high"; out.reasons.push("目标 URL 含有 @ 前缀伪装,真实主机名请以弹窗中的 hostname 为准"); } if (st.suspicious !== undefined ? st.suspicious : isSuspiciousDomain(s)) { out.risk = "high"; out.reasons.push("目标为常见短链/跳转服务域名,高概率跳转链"); } if (st.chain !== undefined ? st.chain : rapidRedirectAttempts(now)) { out.risk = "high"; out.reasons.push("短时间内多次自动跳转,疑似跳转链"); } return out; } /** 兼容包装:true = 需要询问/拦截 */ function shouldBlockNavigation(url, via, state) { return evaluateNavigation(url, via, state).action !== "allow"; } // ============================================================ // 存储 / 状态 // ============================================================ var nativeHrefSetter = null; // 原始 location.href setter(用户确认后直接导航用) var askQueue = []; // 待询问队列(扁平状态:url/via/risk/reasons/resume) var blockedCount = 0; // 静默阻止计数 var askEl = null; // 询问弹层元素 var askHost = null; // Shadow DOM 宿主(可用时) var askRoot = null; // ShadowRoot 或 document var askCssInstalled = false; var lastAskUrl = ""; // 去重用 var lastAskAt = 0; var droppedCount = 0; // 因队列满被丢弃的跳转请求数(可观测性) var rejectedUrls = {}; // url -> 被用户点过【不跳转】的时刻(冷却期内不再询问) var hookStatus = []; function recordHook(name, ok, detail) { hookStatus.push({ name: name, ok: !!ok, detail: String(detail || "") }); } function logDiagnostics() { var ok = 0; for (var i = 0; i < hookStatus.length; i++) if (hookStatus[i].ok) ok++; console.log("[AutoNavGuard] hook 诊断 " + ok + "/" + hookStatus.length + " 可用", hookStatus); } // 二次询问 bypass:用户确认放行 a.click() 后,origAC 同步派发的 click 事件应被全局捕获器跳过。 // 状态保存在脚本内部 WeakSet,不污染网页 DOM 对象(页面脚本不可见); // 无 WeakSet 的老 WebView 退回元素标记(_angBypass)。 var bypassClicks = typeof WeakSet !== "undefined" ? new WeakSet() : null; var bypassForms = typeof WeakSet !== "undefined" ? new WeakSet() : null; function bypassAdd(el) { if (bypassClicks) { bypassClicks.add(el); return; } try { el._angBypass = true; } catch (e) {} } function bypassRemove(el) { if (bypassClicks) { bypassClicks.delete(el); return; } try { el._angBypass = false; } catch (e) {} } function bypassHas(el) { if (bypassClicks) { try { return bypassClicks.has(el); } catch (e) { return false; } } try { return !!el._angBypass; } catch (e) { return false; } } function formBypassAdd(el) { if (bypassForms) { bypassForms.add(el); return; } try { el._angFormBypass = true; } catch (e) {} } function formBypassRemove(el) { if (bypassForms) { bypassForms.delete(el); return; } try { el._angFormBypass = false; } catch (e) {} } function formBypassHas(el) { if (bypassForms) { try { return bypassForms.has(el); } catch (e) { return false; } } try { return !!el._angFormBypass; } catch (e) { return false; } } // Node 单测导出(浏览器中 module 未定义,不影响运行) if (IS_NODE) { module.exports = { CONFIG: CONFIG, FEATURE_DOMAINS: FEATURE_DOMAINS, isSuspiciousDomain: isSuspiciousDomain, sameOrigin: sameOrigin, evaluateNavigation: evaluateNavigation, shouldBlockNavigation: shouldBlockNavigation, rapidRedirectAttempts: rapidRedirectAttempts, resetRedirects: resetRedirects, chainAttack: rapidRedirectAttempts, // 旧名别名 resetChain: resetRedirects, // 旧名别名 viaLabel: viaLabel, escapeHtml: escapeHtml, protocolOf: protocolOf, protocolDecision: protocolDecision, describeUrl: describeUrl }; return; } // ============================================================ // 询问入口:ask → 入队 + 弹窗;allow → 放行(返回 false) // ============================================================ function guardNavigation(url, via, resume) { var d = evaluateNavigation(url, via); if (d.action === "allow") return false; if (d.action === "block") { blockedCount++; console.log("[AutoNavGuard] 静默阻止 #" + blockedCount + " [" + via + "] → " + String(url).substring(0, 120), d.reasons); return true; } askUser(url, via, d, resume); return true; } function askUser(url, via, decision, resume) { var now = Date.now(); var u = String(url); // 拒绝冷却:被用户点过【不跳转】的地址,冷却期内直接静默阻止,不再弹窗 if (rejectedUrls[u] && now - rejectedUrls[u] < CONFIG.REJECT_COOLDOWN_MS) { blockedCount++; console.log("[AutoNavGuard] 冷却期内自动阻止已拒绝地址: " + u.substring(0, 120)); return; } // 去重:相同目标地址短时间内重复触发(恶意脚本循环跳转)不重复弹 if (u === lastAskUrl && now - lastAskAt < CONFIG.ASK_DEBOUNCE_MS) return; lastAskUrl = u; lastAskAt = now; // 队列上限:防止恶意脚本连续产生大量跳转请求,把用户拖进"逐个审批"轰炸 if (askQueue.length >= CONFIG.MAX_ASK_QUEUE) { droppedCount++; console.log("[AutoNavGuard] 询问队列已满,丢弃 #" + droppedCount + ": " + u.substring(0, 120)); return; } askQueue.push({ url: u, via: via, risk: decision.risk, reasons: decision.reasons, resume: resume }); console.log("[AutoNavGuard] 询问跳转 [" + via + "] → " + u.substring(0, 120)); showNextAsk(); } function installHtmlWriteHooks(target, label) { if (!target) return false; var installed = false; function patch(method, via) { var orig = target[method]; if (typeof orig !== "function" || orig._angPatched) return false; try { target[method] = function () { var args = []; var html = ""; for (var i = 0; i < arguments.length; i++) { var part = arguments[i]; args.push(part); html += String(part === undefined || part === null ? "" : part); } var url = findMetaRefreshUrlInHtml(html); if (url) { var self = this; if (guardNavigation(url, via, function () { return orig.apply(self, args); })) return; } return orig.apply(this, arguments); }; target[method]._angPatched = true; installed = true; } catch (e) {} return installed; } patch("write", label + ".write"); patch("writeln", label + ".writeln"); return installed; } function installHtmlStringSinkHooks() { var installed = false; try { var elProto = window.Element && window.Element.prototype; var origInsertAdjacentHTML = elProto && elProto.insertAdjacentHTML; if (typeof origInsertAdjacentHTML === "function" && !origInsertAdjacentHTML._angPatched) { elProto.insertAdjacentHTML = function (position, html) { var url = findMetaRefreshUrlInHtml(html); if (url) { var self = this; var args = [position, html]; if (guardNavigation(url, "Element.insertAdjacentHTML", function () { return origInsertAdjacentHTML.apply(self, args); })) return; } return origInsertAdjacentHTML.apply(this, arguments); }; elProto.insertAdjacentHTML._angPatched = true; installed = true; } } catch (e) {} function patchSetter(proto, prop, via) { if (!proto) return false; var d = null; try { d = Object.getOwnPropertyDescriptor(proto, prop); } catch (e) {} if (!d || !d.set || d.set._angPatched) return false; try { var setter = d.set; var getter = d.get; var patched = function (html) { var url = findMetaRefreshUrlInHtml(html); if (url) { var self = this; if (guardNavigation(url, via, function () { return setter.call(self, html); })) return; } return setter.call(this, html); }; patched._angPatched = true; Object.defineProperty(proto, prop, { get: getter, set: patched, configurable: true, enumerable: d.enumerable }); return true; } catch (e2) { return false; } } try { var protos = []; if (window.Element && window.Element.prototype) protos.push(window.Element.prototype); if (window.HTMLElement && window.HTMLElement.prototype && window.HTMLElement.prototype !== (window.Element && window.Element.prototype)) { protos.push(window.HTMLElement.prototype); } for (var i = 0; i < protos.length; i++) { if (patchSetter(protos[i], "innerHTML", "Element.innerHTML")) installed = true; if (patchSetter(protos[i], "outerHTML", "Element.outerHTML")) installed = true; } } catch (e3) {} return installed; } /** 用户确认后直接导航(绕过守卫) */ function navigateDirect(url) { var u = String(url || ""); if (nativeHrefSetter) { try { nativeHrefSetter.call(location, u); return; } catch (e) {} } try { location.assign(u); } catch (e) { try { location.href = u; } catch (e2) {} } } // ============================================================ // MODULE A: 导航 API 拦截(一律 resume 化:确认后执行原动作) // ============================================================ function installLocationHooks() { // --- location.href setter(document.location 同对象,一并覆盖)--- try { var ld = Object.getOwnPropertyDescriptor(window.location, "href"); if (ld && ld.set) { nativeHrefSetter = ld.set; Object.defineProperty(window.location, "href", { get: ld.get, set: function (v) { var self = this; if (guardNavigation(String(v), "location.href", function () { return ld.set.call(self, String(v)); })) return; return ld.set.call(this, v); }, configurable: true, enumerable: true }); recordHook("location.href", true, "setter redefined"); } else { recordHook("location.href", false, "no href setter descriptor"); } } catch (e) { recordHook("location.href", false, e.name || e); } // --- location.assign / location.replace --- try { var proto = window.Location ? window.Location.prototype : location; var origAssign = proto.assign; proto.assign = function (u) { var self = this; if (guardNavigation(String(u), "location.assign", function () { return origAssign.call(self, String(u)); })) return; return origAssign.call(this, u); }; var origReplace = proto.replace; proto.replace = function (u) { var self = this; if (guardNavigation(String(u), "location.replace", function () { return origReplace.call(self, String(u)); })) return; return origReplace.call(this, u); }; recordHook("Location.prototype.assign/replace", true, "prototype patched"); } catch (e) { recordHook("Location.prototype.assign/replace", false, e.name || e); } // 实例级兜底:某些 WebView 原型对象不可写 try { if (typeof location.assign === "function" && location.assign.toString().indexOf("[native code]") !== -1) { var _self = location; location.assign = function (u) { if (guardNavigation(String(u), "location.assign", function () { try { _self.href = String(u); } catch (e) {} })) return; _self.href = String(u); }; location.replace = function (u) { if (guardNavigation(String(u), "location.replace", function () { try { _self.href = String(u); } catch (e) {} })) return; _self.href = String(u); }; recordHook("location.assign/replace instance fallback", true, "instance patched"); } else { recordHook("location.assign/replace instance fallback", false, "not native or already patched"); } } catch (e) { recordHook("location.assign/replace instance fallback", false, e.name || e); } } /** 对 window.open 返回的窗口做守卫(处理 about:blank 先开窗后改地址的技巧) */ function guardOpenedWindow(w) { if (!w) return w; try { var origLoc = w.location; // 拦截 w.location = "https://..." 直接赋值 Object.defineProperty(w, "location", { get: function () { return origLoc; }, set: function (v) { if (guardNavigation(String(v), "blank-window.location", function () { try { origLoc.replace(String(v)); } catch (e) {} })) return; try { origLoc.replace(String(v)); } catch (e) {} }, configurable: true }); recordHook("blank-window.location", true, "window location property guarded"); } catch (e) { recordHook("blank-window.location", false, e.name || e); } try { // about:blank 继承 opener 的 origin,同源可访问 → 拦截 w.location.href = ... var d2 = Object.getOwnPropertyDescriptor(w.location, "href"); if (d2 && d2.set) { Object.defineProperty(w.location, "href", { get: d2.get, set: function (v) { var self = this; if (guardNavigation(String(v), "blank-window.href", function () { return d2.set.call(self, String(v)); })) return; return d2.set.call(this, v); }, configurable: true }); } recordHook("blank-window.href", true, "href setter guarded"); } catch (e) { recordHook("blank-window.href", false, e.name || e); } try { // about:blank realm 的 Location.prototype(与当前页不同 realm,覆盖只影响该窗口) var lp = Object.getPrototypeOf(w.location); var oa = lp.assign, orr = lp.replace; if (typeof oa === "function") lp.assign = function (u) { var self = this; if (guardNavigation(String(u), "blank-window.assign", function () { return oa.call(self, String(u)); })) return; return oa.call(this, u); }; if (typeof orr === "function") lp.replace = function (u) { var self = this; if (guardNavigation(String(u), "blank-window.replace", function () { return orr.call(self, String(u)); })) return; return orr.call(this, u); }; recordHook("blank-window.assign/replace", true, "blank realm prototype patched"); } catch (e) { recordHook("blank-window.assign/replace", false, e.name || e); } try { recordHook("blank-window.document.write", installHtmlWriteHooks(w.document, "blank-window.document"), "document.write/writeln guarded"); } catch (eDoc) { recordHook("blank-window.document.write", false, eDoc.name || eDoc); } return w; } function isBlankWindowUrl(url) { var raw = String(url || "").trim(); if (!raw) return true; try { var u = new URL(raw, location.href); return u.protocol.toLowerCase() === "about:" && String(u.pathname || "").toLowerCase() === "blank"; } catch (e) { return /^about:blank(?:[?#].*)?$/i.test(raw); } } function fakeClosedWindow() { var closed = false; var fakeLocation = { href: "about:blank", replace: function (u) { this.href = String(u || "about:blank"); }, assign: function (u) { this.href = String(u || "about:blank"); } }; var fakeDocument = { write: function () {}, writeln: function () {}, open: function () { return this; }, close: function () {} }; return { get closed() { return closed; }, close: function () { closed = true; }, focus: function () {}, blur: function () {}, postMessage: function () {}, stop: function () {}, get location() { return fakeLocation; }, set location(v) { fakeLocation.href = String(v || "about:blank"); }, get document() { return fakeDocument; } }; } function installOpenHooks() { var origOpen = window.open; window.open = function (url, target, features) { var s = String(url || ""); // 空白窗口也先询问:广告常用 about:blank 后 document.write/meta/location 二段跳 if (isBlankWindowUrl(s)) { var blankUrl = String(url || "").trim() || "about:blank"; askUser(blankUrl, "window.open.blank", { risk: "medium", reasons: [ "页面脚本尝试打开空白窗口", "空白窗口可被写入广告、meta refresh 或后续跳转代码" ] }, function () { return guardOpenedWindow(origOpen.call(window, s, target, features)); }); return fakeClosedWindow(); } if (guardNavigation(s, "window.open", function () { return origOpen.call(window, s, target, features); })) { // 返回一个无害假窗口,防止脚本在等待用户确认期间继续操作崩溃 return fakeClosedWindow(); } return origOpen.call(window, s, target, features); }; recordHook("window.open", true, "function patched"); } function installClickHooks() { // --- .click() 脚本触发 --- try { var origAC = HTMLAnchorElement.prototype.click; HTMLAnchorElement.prototype.click = function () { var self = this; var href = this.getAttribute("href") || this.href || ""; if (bypassHas(self)) return origAC.call(self); if (guardNavigation(href, ".click", function () { // 用户已确认:直接执行原始 click。origAC 会同步派发 isTrusted=false 的 // click 事件,全局捕获器会再拦一次 → 在事件派发期间标记 bypass bypassAdd(self); try { return origAC.call(self); } finally { bypassRemove(self); } })) return; return origAC.call(this); }; recordHook("HTMLAnchorElement.click", true, "prototype patched"); } catch (e) { recordHook("HTMLAnchorElement.click", false, e.name || e); } // --- .click() 脚本触发 --- try { var origAreaClick = HTMLAreaElement && HTMLAreaElement.prototype && HTMLAreaElement.prototype.click; if (typeof origAreaClick === "function") { HTMLAreaElement.prototype.click = function () { var self = this; var href = this.getAttribute("href") || this.href || ""; if (bypassHas(self)) return origAreaClick.call(self); if (guardNavigation(href, ".click", function () { bypassAdd(self); try { return origAreaClick.call(self); } finally { bypassRemove(self); } })) return; return origAreaClick.call(this); }; recordHook("HTMLAreaElement.click", true, "prototype patched"); } else { recordHook("HTMLAreaElement.click", false, "not available"); } } catch (eArea) { recordHook("HTMLAreaElement.click", false, eArea.name || eArea); } // --- 全局 click 捕获:用 isTrusted 区分用户导航与脚本伪造 --- document.addEventListener("click", function (e) { var t = e.target; var a = t && t.closest ? t.closest("a,area") : null; if (!a && t && t.nodeName === "A") a = t; if (!a && t && t.nodeName === "AREA") a = t; if (!a) return; // 我们自己放行后 origAC 派发的 click(bypass 标记中)→ 跳过,避免二次询问 if (bypassHas(a)) return; // 浏览器产生的可信点击事件(isTrusted === true)→ 用户主动导航,直接放行; // false / undefined(老 WebView 缺属性)→ 按脚本导航处理,宁可多问不漏 if (e.isTrusted === true) return; // 脚本伪造的 click(isTrusted === false)→ 视为重定向,询问 var href = a.getAttribute("href") || a.href || ""; if (guardNavigation(href, "link click", function () { bypassAdd(a); try { if (typeof a.click === "function") return a.click(); return navigateDirect(href); } finally { bypassRemove(a); } })) { e.preventDefault(); e.stopPropagation(); if (e.stopImmediatePropagation) e.stopImmediatePropagation(); } }, true); recordHook("document click capture", true, "listener installed"); } function installFormHooks() { var origSubmit = null; var origRequestSubmit = null; function effectiveFormAction(form, submitter) { var action = ""; try { if (submitter && submitter.formAction) action = String(submitter.formAction || ""); } catch (e) {} if (!action) { try { action = String(form.action || ""); } catch (e2) {} } if (!action && submitter && submitter.getAttribute) { try { action = submitter.getAttribute("formaction") || ""; } catch (e3) {} } if (!action && form && form.getAttribute) { try { action = form.getAttribute("action") || ""; } catch (e4) {} } return action || (location && location.href) || ""; } function resumeFormSubmit(form, submitter) { formBypassAdd(form); try { if (origRequestSubmit && typeof form.requestSubmit === "function") { if (submitter) return origRequestSubmit.call(form, submitter); return origRequestSubmit.call(form); } if (origSubmit) return origSubmit.call(form); return navigateDirect(effectiveFormAction(form, submitter)); } finally { formBypassRemove(form); } } // --- 事件监听:覆盖用户点击提交 / 脚本 dispatch submit 事件 --- document.addEventListener("submit", function (e) { var f = e.target; if (!f || !f.getAttribute) return; if (formBypassHas(f)) return; var submitter = e.submitter || null; var action = effectiveFormAction(f, submitter); if (guardNavigation(action, "form submit", function () { return resumeFormSubmit(f, submitter); })) { e.preventDefault(); if (e.stopPropagation) e.stopPropagation(); if (e.stopImmediatePropagation) e.stopImmediatePropagation(); } }, true); recordHook("document submit capture", true, "listener installed"); // --- form.submit() 直调:原生 submit() 不触发 submit 事件,必须直接 hook --- try { origSubmit = HTMLFormElement.prototype.submit; HTMLFormElement.prototype.submit = function () { var self = this; if (formBypassHas(self)) return origSubmit.call(self); // form.action 与浏览器提交语义一致:相对路径会解析为绝对 URL; // 无 action 时返回当前文档 URL(同源 → 放行);stub/老环境 fallback 到 getAttribute var action = effectiveFormAction(self, null); if (guardNavigation(action, "form.submit", function () { return origSubmit.call(self); })) return; return origSubmit.call(this); }; recordHook("HTMLFormElement.submit", true, "prototype patched"); } catch (e) { recordHook("HTMLFormElement.submit", false, e.name || e); } // --- form.requestSubmit() 直调:会触发 submit 事件,但先 hook 可保留 submitter 语义 --- try { origRequestSubmit = HTMLFormElement.prototype.requestSubmit; if (typeof origRequestSubmit === "function") { HTMLFormElement.prototype.requestSubmit = function (submitter) { var self = this; if (formBypassHas(self)) { if (submitter) return origRequestSubmit.call(self, submitter); return origRequestSubmit.call(self); } var action = effectiveFormAction(self, submitter || null); if (guardNavigation(action, "form.requestSubmit", function () { return resumeFormSubmit(self, submitter || null); })) return; if (submitter) return origRequestSubmit.call(this, submitter); return origRequestSubmit.call(this); }; recordHook("HTMLFormElement.requestSubmit", true, "prototype patched"); } else { recordHook("HTMLFormElement.requestSubmit", false, "not available"); } } catch (e2) { recordHook("HTMLFormElement.requestSubmit", false, e2.name || e2); } } function installMetaRefresh() { var fallbackScanCount = 0; var metaBypass = typeof WeakSet !== "undefined" ? new WeakSet() : null; var metaBlocked = typeof WeakSet !== "undefined" ? new WeakSet() : null; var metaShadow = typeof WeakMap !== "undefined" ? new WeakMap() : null; function markMetaBypass(node) { if (metaBypass) { try { metaBypass.add(node); } catch (e) {} } else { try { node._angMetaBypass = true; } catch (e2) {} } } function unmarkMetaBypass(node) { if (metaBypass) { try { metaBypass.delete(node); } catch (e) {} } else { try { node._angMetaBypass = false; } catch (e2) {} } } function hasMetaBypass(node) { if (metaBypass) { try { return metaBypass.has(node); } catch (e) { return false; } } try { return !!node._angMetaBypass; } catch (e2) { return false; } } function markMetaBlocked(node) { if (metaBlocked) { try { metaBlocked.add(node); } catch (e) {} } else { try { node._angMetaBlocked = true; } catch (e2) {} } } function hasMetaBlocked(node) { if (metaBlocked) { try { return metaBlocked.has(node); } catch (e) { return false; } } try { return !!node._angMetaBlocked; } catch (e2) { return false; } } function nativeRemove(node) { markMetaBypass(node); try { if (node.parentNode && node.parentNode.removeChild) return node.parentNode.removeChild(node); } catch (e) {} try { if (node.remove) return node.remove(); } catch (e2) {} } function metaState(node, create) { if (!node) return null; if (metaShadow) { try { var st = metaShadow.get(node); if (!st && create) { st = {}; metaShadow.set(node, st); } return st || null; } catch (e) { return null; } } try { if (!node._angMetaShadow && create) node._angMetaShadow = {}; return node._angMetaShadow || null; } catch (e2) { return null; } } function getMetaShadow(node, key) { var st = metaState(node, false); if (!st || !Object.prototype.hasOwnProperty.call(st, key)) return undefined; return st[key]; } function setMetaShadow(node, key, value) { var st = metaState(node, true); if (st) st[key] = value === undefined || value === null ? "" : String(value); } function clearMetaShadow(node, key) { var st = metaState(node, false); if (st && Object.prototype.hasOwnProperty.call(st, key)) { try { delete st[key]; } catch (e) { st[key] = undefined; } } } function nativeAttr(node, name) { try { var attr = node.getAttribute && node.getAttribute(name); if (attr !== null && attr !== undefined) return String(attr); } catch (e) {} return undefined; } function isRefreshHttpEquiv(value) { return String(value || "").toLowerCase() === "refresh"; } function getMetaHttpEquiv(node) { var shadow = getMetaShadow(node, "httpEquiv"); if (shadow !== undefined) return shadow; var attr = nativeAttr(node, "http-equiv"); if (attr !== undefined) return attr; attr = nativeAttr(node, "httpEquiv"); if (attr !== undefined) return attr; try { return String(node.httpEquiv || ""); } catch (e) {} return ""; } function getMetaContent(node) { var shadow = getMetaShadow(node, "content"); if (shadow !== undefined) return shadow; var attr = nativeAttr(node, "content"); if (attr !== undefined) return attr; try { return String(node.content || ""); } catch (e2) {} return ""; } function setNativeMetaAttr(node, name, value) { markMetaBypass(node); try { if (node.setAttribute) node.setAttribute(name, String(value || "")); } catch (e) {} finally { unmarkMetaBypass(node); } } function commitMetaShadow(node) { var he = getMetaShadow(node, "httpEquiv"); var content = getMetaShadow(node, "content"); if (he !== undefined) { setNativeMetaAttr(node, "http-equiv", he); clearMetaShadow(node, "httpEquiv"); } if (content !== undefined) { setNativeMetaAttr(node, "content", content); clearMetaShadow(node, "content"); } } function wouldBlockMetaRefresh(node, nextHttpEquiv, nextContent) { if (!node || node.nodeName !== "META" || hasMetaBypass(node)) return false; var he = nextHttpEquiv; if (he === undefined || he === null) { he = getMetaHttpEquiv(node); } if (!isRefreshHttpEquiv(he)) return false; var content = nextContent; if (content === undefined || content === null) content = getMetaContent(node); var url = extractRefreshUrl(content); if (!url) return false; if (guardNavigation(url, "meta refresh", function () { navigateDirect(url); })) { markMetaBlocked(node); nativeRemove(node); return true; } return false; } function checkMeta(node) { if (!node || node.nodeName !== "META") return false; if (hasMetaBypass(node)) return false; return wouldBlockMetaRefresh(node); } function precheckMetaNode(node) { if (!node) return false; if (node.nodeName === "META") return checkMeta(node); var blocked = false; if (node.querySelectorAll) { try { var inner = node.querySelectorAll("meta"); for (var i = 0; i < inner.length; i++) { if (checkMeta(inner[i])) blocked = true; } } catch (e) {} } return blocked; } function guardCreatedMeta(node) { if (!node || node.nodeName !== "META" || node._angMetaGuarded) return node; try { node._angMetaGuarded = true; } catch (e) {} try { Object.defineProperty(node, "content", { get: function () { var shadow = getMetaShadow(this, "content"); if (shadow !== undefined) return shadow; var attr = nativeAttr(this, "content"); return attr !== undefined ? attr : ""; }, set: function (v) { if (wouldBlockMetaRefresh(this, undefined, v)) return; setMetaShadow(this, "content", v); commitMetaShadow(this); }, configurable: true }); Object.defineProperty(node, "httpEquiv", { get: function () { var shadow = getMetaShadow(this, "httpEquiv"); if (shadow !== undefined) return shadow; var attr = nativeAttr(this, "http-equiv"); if (attr !== undefined) return attr; attr = nativeAttr(this, "httpEquiv"); return attr !== undefined ? attr : ""; }, set: function (v) { if (wouldBlockMetaRefresh(this, v, undefined)) return; setMetaShadow(this, "httpEquiv", v); if (isRefreshHttpEquiv(v) && !extractRefreshUrl(getMetaContent(this))) return; commitMetaShadow(this); }, configurable: true }); } catch (e2) {} return node; } function installMetaDomHooks() { var nodeProto = window.Node && window.Node.prototype; if (!nodeProto) { recordHook("meta refresh prehook", false, "Node.prototype unavailable"); return; } try { var docProto = window.Document && window.Document.prototype; var createPatched = false; var origCreate = docProto && docProto.createElement; if (typeof origCreate === "function") { docProto.createElement = function (name, options) { var node = origCreate.call(this, name, options); if (String(name || "").toLowerCase() === "meta") guardCreatedMeta(node); return node; }; createPatched = true; } var origCreateNS = docProto && docProto.createElementNS; if (typeof origCreateNS === "function") { docProto.createElementNS = function (ns, name, options) { var node = origCreateNS.call(this, ns, name, options); if (String(name || "").toLowerCase() === "meta") guardCreatedMeta(node); return node; }; createPatched = true; } if (!createPatched && document && typeof document.createElement === "function") { var docCreate = document.createElement; document.createElement = function (name, options) { var node = docCreate.call(document, name, options); if (String(name || "").toLowerCase() === "meta") guardCreatedMeta(node); return node; }; createPatched = true; } if (document && typeof document.createElementNS === "function" && (!docProto || document.createElementNS !== docProto.createElementNS)) { var docCreateNS = document.createElementNS; document.createElementNS = function (ns, name, options) { var node = docCreateNS.call(document, ns, name, options); if (String(name || "").toLowerCase() === "meta") guardCreatedMeta(node); return node; }; createPatched = true; } recordHook("meta refresh prehook createElement", createPatched, "Document/document createElement patched"); } catch (e0) { recordHook("meta refresh prehook createElement", false, e0.name || e0); } try { var origAppend = nodeProto.appendChild; if (typeof origAppend === "function") { nodeProto.appendChild = function (node) { precheckMetaNode(node); if (node && node.nodeName === "META" && hasMetaBlocked(node)) return node; return origAppend.call(this, node); }; } var origInsert = nodeProto.insertBefore; if (typeof origInsert === "function") { nodeProto.insertBefore = function (node, ref) { precheckMetaNode(node); if (node && node.nodeName === "META" && hasMetaBlocked(node)) return node; return origInsert.call(this, node, ref); }; } recordHook("meta refresh prehook DOM insert", true, "appendChild/insertBefore patched"); } catch (e) { recordHook("meta refresh prehook DOM insert", false, e.name || e); } try { var elProto = window.Element && window.Element.prototype; var origSetAttr = elProto && elProto.setAttribute; if (typeof origSetAttr === "function") { elProto.setAttribute = function (name, value) { if (this && this.nodeName === "META" && !hasMetaBypass(this)) { var n = String(name || "").toLowerCase(); if (n === "http-equiv" || n === "httpequiv") { if (wouldBlockMetaRefresh(this, value, undefined)) return; setMetaShadow(this, "httpEquiv", value); if (isRefreshHttpEquiv(value) && !extractRefreshUrl(getMetaContent(this))) return; commitMetaShadow(this); return; } else if (n === "content") { if (wouldBlockMetaRefresh(this, undefined, value)) return; setMetaShadow(this, "content", value); commitMetaShadow(this); return; } } var ret = origSetAttr.call(this, name, value); return ret; }; recordHook("meta refresh prehook setAttribute", true, "Element.setAttribute patched"); } else { recordHook("meta refresh prehook setAttribute", false, "Element.setAttribute unavailable"); } } catch (e2) { recordHook("meta refresh prehook setAttribute", false, e2.name || e2); } try { var metaProto = window.HTMLMetaElement && window.HTMLMetaElement.prototype; if (!metaProto) { recordHook("meta refresh prehook properties", false, "HTMLMetaElement unavailable"); return; } var cd = Object.getOwnPropertyDescriptor(metaProto, "content"); if (cd && cd.set) { Object.defineProperty(metaProto, "content", { get: function () { var shadow = getMetaShadow(this, "content"); if (shadow !== undefined) return shadow; return cd.get ? cd.get.call(this) : ""; }, set: function (v) { if (wouldBlockMetaRefresh(this, undefined, v)) return; setMetaShadow(this, "content", v); commitMetaShadow(this); }, configurable: true, enumerable: cd.enumerable }); } var hd = Object.getOwnPropertyDescriptor(metaProto, "httpEquiv"); if (hd && hd.set) { Object.defineProperty(metaProto, "httpEquiv", { get: function () { var shadow = getMetaShadow(this, "httpEquiv"); if (shadow !== undefined) return shadow; return hd.get ? hd.get.call(this) : ""; }, set: function (v) { if (wouldBlockMetaRefresh(this, v, undefined)) return; setMetaShadow(this, "httpEquiv", v); if (isRefreshHttpEquiv(v) && !extractRefreshUrl(getMetaContent(this))) return; commitMetaShadow(this); }, configurable: true, enumerable: hd.enumerable }); } recordHook("meta refresh prehook properties", !!((cd && cd.set) || (hd && hd.set)), "HTMLMetaElement content/httpEquiv patched"); } catch (e3) { recordHook("meta refresh prehook properties", false, e3.name || e3); } try { recordHook("document.write meta refresh", installHtmlWriteHooks(document, "document"), "document.write/writeln guarded"); } catch (e4) { recordHook("document.write meta refresh", false, e4.name || e4); } try { recordHook("HTML string meta refresh", installHtmlStringSinkHooks(), "innerHTML/outerHTML/insertAdjacentHTML guarded"); } catch (e5) { recordHook("HTML string meta refresh", false, e5.name || e5); } } installMetaDomHooks(); // document-start 时初始 meta(扫描一次 + 观察后续注入) try { var metas = document.getElementsByTagName("meta"); for (var i = 0; i < metas.length; i++) checkMeta(metas[i]); } catch (e) {} try { new MutationObserver(function (muts) { for (var mi = 0; mi < muts.length; mi++) { if (muts[mi].type === "attributes") { checkMeta(muts[mi].target); continue; } var added = muts[mi].addedNodes; for (var i = 0; i < added.length; i++) { var node = added[i]; if (node.nodeName === "META") checkMeta(node); else if (node.querySelectorAll) { var inner = node.querySelectorAll("meta"); for (var j = 0; j < inner.length; j++) checkMeta(inner[j]); } } } }).observe(document.documentElement || document.head || document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ["http-equiv", "content"] }); recordHook("meta refresh observer", true, "MutationObserver installed"); } catch (e) { function retryObserve() { var root = document.documentElement || document.head || document.body; if (!root) { setTimeout(retryObserve, 50); return; } if (typeof MutationObserver === "undefined") { var timer = setInterval(function () { fallbackScanCount++; try { var metas = document.getElementsByTagName("meta"); for (var i = 0; i < metas.length; i++) checkMeta(metas[i]); } catch (e2) {} if (fallbackScanCount > 200) clearInterval(timer); }, 100); recordHook("meta refresh observer", true, "polling fallback installed"); return; } try { new MutationObserver(function (muts) { for (var mi = 0; mi < muts.length; mi++) { if (muts[mi].type === "attributes") { checkMeta(muts[mi].target); continue; } var added = muts[mi].addedNodes; for (var i = 0; i < added.length; i++) { var node = added[i]; if (node.nodeName === "META") checkMeta(node); else if (node.querySelectorAll) { var inner = node.querySelectorAll("meta"); for (var j = 0; j < inner.length; j++) checkMeta(inner[j]); } } } }).observe(root, { childList: true, subtree: true, attributes: true, attributeFilter: ["http-equiv", "content"] }); recordHook("meta refresh observer", true, "delayed MutationObserver installed"); } catch (e3) { recordHook("meta refresh observer", false, e3.name || e3); } } retryObserve(); } } // ============================================================ // MODULE B: 询问弹层(平时零界面,仅询问时临时出现) // ============================================================ function askCSS(root) { if (askCssInstalled) return; askCssInstalled = true; var s = document.createElement("style"); s.textContent = [ "#_ang_ask{all:initial;position:fixed;inset:0;z-index:2147483647;background:rgba(12,18,28,.58);", "display:none;align-items:center;justify-content:center;padding:calc(12px + env(safe-area-inset-top)) calc(12px + env(safe-area-inset-right)) calc(12px + env(safe-area-inset-bottom)) calc(12px + env(safe-area-inset-left));", "font:13px/1.55 system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;color:#1f2933}", "#_ang_ask *{box-sizing:border-box}", "#_ang_ask .card{width:min(440px,100%);max-height:calc(100vh - 24px);overflow:auto;background:#fff;color:#1f2933;border-radius:8px;", "padding:16px;box-shadow:0 12px 40px rgba(0,0,0,.36);outline:none}", "#_ang_ask .title{font-weight:700;font-size:16px;margin:0 0 10px;color:#b42318}", "#_ang_ask .row{margin:5px 0;color:#4b5563}", "#_ang_ask .row b{color:#111827;word-break:break-all;font-weight:650}", "#_ang_ask .mono{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}", "#_ang_ask .risk-high{color:#c0392b;font-weight:bold}", "#_ang_ask .risk-medium{color:#e67e22;font-weight:bold}", "#_ang_ask .reasons{margin:10px 0 4px;padding-left:18px;color:#4b5563;font-size:12px}", "#_ang_ask .reasons li{margin:3px 0}", "#_ang_ask .btns{display:flex;justify-content:flex-end;gap:8px;margin-top:14px;flex-wrap:wrap}", "#_ang_ask button{min-height:44px;border:1px solid transparent;border-radius:8px;padding:9px 15px;font:600 13px/1.2 system-ui,-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;cursor:pointer}", "#_ang_ask button:focus{outline:2px solid #2563eb;outline-offset:2px}", "#_ang_ask .cancel{background:#f3f4f6;color:#111827;border-color:#d1d5db}", "#_ang_ask .allow{background:#238636;color:#fff}", "@media (max-width:360px){#_ang_ask .btns{display:grid;grid-template-columns:1fr 1fr}#_ang_ask button{padding-left:8px;padding-right:8px}}" ].join(""); s.id = "_ang_css"; (function insert() { var target = root || document.head; if (target && target.appendChild) target.appendChild(s); else if (document.head) document.head.appendChild(s); else setTimeout(insert, 10); })(); } function ensureAskModal() { if (askEl) return; if (!document.body) return; // 等待 body(showNextAsk 会重试) askRoot = null; try { if (document.createElement("div").attachShadow) { askHost = document.createElement("div"); askHost.id = "_ang_host"; askHost.style.cssText = "all:initial!important;position:fixed!important;inset:0!important;z-index:2147483647!important;pointer-events:none!important;"; document.body.appendChild(askHost); askRoot = askHost.attachShadow({ mode: "closed" }); recordHook("ask modal Shadow DOM", true, "closed shadow root"); } } catch (e) { askRoot = null; recordHook("ask modal Shadow DOM", false, e.name || e); } if (!askRoot) { askRoot = document.body; recordHook("ask modal Shadow DOM", false, "fallback to page DOM"); } askCSS(askRoot); askEl = document.createElement("div"); askEl.id = "_ang_ask"; askEl.style.pointerEvents = "auto"; askEl.setAttribute("aria-hidden", "true"); askRoot.appendChild(askEl); // 事件委托:按钮点击 askEl.addEventListener("click", function (e) { var t = e.target; if (!t || !t.classList) return; if (t.classList.contains("allow")) resolveAsk(true); else if (t.classList.contains("cancel")) resolveAsk(false); else if (t === askEl) resolveAsk(false); // 点遮罩 = 不跳转 }); document.addEventListener("keydown", handleAskKeydown, true); } var asking = false; var previousFocus = null; function showNextAsk() { if (asking) return; // 正在询问中,新请求排队 ensureAskModal(); if (!askEl || !document.body) { setTimeout(showNextAsk, 200); return; } if (!askQueue.length) return; asking = true; var ask = askQueue[0]; renderAsk(ask); askEl.style.display = "flex"; askEl.setAttribute("aria-hidden", "false"); try { previousFocus = document.activeElement || null; } catch (e) { previousFocus = null; } setTimeout(focusAskDefault, 0); } function renderAsk(ask) { var desc = describeUrl(ask.url); var riskCls = ask.risk === "high" ? "risk-high" : "risk-medium"; var riskText = ask.risk === "high" ? "🔴 高风险 · 强烈不推荐跳转" : "⚠ 可疑 · 不推荐跳转"; var lis = ""; for (var i = 0; i < ask.reasons.length; i++) { lis += "
  • " + escapeHtml(ask.reasons[i]) + "
  • "; } var curHost = ""; try { curHost = String(location.hostname || ""); } catch (e) {} askEl.innerHTML = ''; } function focusAskDefault() { if (!askEl || askEl.style.display !== "flex") return; var target = null; try { target = askEl.querySelector(".cancel") || askEl.querySelector(".card"); } catch (e) {} try { if (target && target.focus) target.focus(); } catch (e2) {} } function focusablesInAsk() { if (!askEl || !askEl.querySelectorAll) return []; try { var nodes = askEl.querySelectorAll("button,[href],input,select,textarea,[tabindex]:not([tabindex='-1'])"); var out = []; for (var i = 0; i < nodes.length; i++) { if (!nodes[i].disabled && nodes[i].offsetParent !== null) out.push(nodes[i]); } return out; } catch (e) { return []; } } function handleAskKeydown(e) { if (!asking || !askEl || askEl.style.display !== "flex") return; var key = e.key || e.keyCode; if (key === "Escape" || key === "Esc" || key === 27) { if (e.preventDefault) e.preventDefault(); if (e.stopPropagation) e.stopPropagation(); resolveAsk(false); return; } if (key !== "Tab" && key !== 9) return; var items = focusablesInAsk(); if (!items.length) { if (e.preventDefault) e.preventDefault(); focusAskDefault(); return; } var first = items[0]; var last = items[items.length - 1]; var active = null; try { active = document.activeElement; } catch (e2) {} if (e.shiftKey && active === first) { if (e.preventDefault) e.preventDefault(); try { last.focus(); } catch (e3) {} } else if (!e.shiftKey && active === last) { if (e.preventDefault) e.preventDefault(); try { first.focus(); } catch (e4) {} } } function resolveAsk(go) { if (!asking) return; var ask = askQueue.shift(); asking = false; if (askEl) { askEl.style.display = "none"; askEl.setAttribute("aria-hidden", "true"); } if (go && ask.resume) { console.log("[AutoNavGuard] 用户选择跳转 → " + String(ask.url).substring(0, 120)); try { ask.resume(); } catch (e) { console.error("[AutoNavGuard] 跳转执行失败:", e); } } else { console.log("[AutoNavGuard] 用户选择不跳转 → 已阻止 " + String(ask.url).substring(0, 120)); // 记录拒绝(冷却期内同一地址不再询问) if (ask && ask.url) rejectedUrls[String(ask.url)] = Date.now(); // 一次"不跳转"= 本次排队的所有跳转全部拒绝,不再逐个弹窗 while (askQueue.length) { var extra = askQueue.shift(); if (extra && extra.url) rejectedUrls[String(extra.url)] = Date.now(); console.log("[AutoNavGuard] 连带拒绝排队中的跳转: " + String(extra && extra.url || "").substring(0, 120)); } } if (!askQueue.length && previousFocus && previousFocus.focus) { try { previousFocus.focus(); } catch (e2) {} } showNextAsk(); } // ============================================================ // MAIN // ============================================================ function init() { installLocationHooks(); installOpenHooks(); installClickHooks(); installFormHooks(); installMetaRefresh(); console.log("[AutoNavGuard] v2.5.3 已启动 — 询问式跳转防护(增强兜底版),特征库 " + FEATURE_DOMAINS.length + " 条"); logDiagnostics(); } try { init(); } catch (e) { console.error("[AutoNavGuard] 启动失败:", e); } })();