简介面向.NET开发者的一套CEFSharp完整拦截方案代码目标是让应用程序接管浏览器内全部WSS与WebSocket通信适合需要调试实时数据、实施安全审计或做自定义过滤的中高级开发人员。资源以WinForm项目为载体覆盖从注册自定义Scheme Handler、拦截WSS请求、建立代理连接到逐帧读取和改写数据的关键环节形成一条可直接运行和验证的代码链路。压缩包共21个文件其中13个C#源文件作为核心逻辑辅以配置文件、资源文件、解决方案文件等整体仅76KB结构紧凑使用Visual Studio打开CEF.sln即可浏览工程全貌并二次开发。目前已有201人学习下载。借助其中请求拦截器、响应过滤器及Scheme Handler的具体实现可以快速掌握CEFSharp对WSS流量的控制思路并依此扩展出日志记录、协议分析、消息篡改等实用功能显著降低研究CEFSharp网络层的时间成本。1. CEF 拦截一切网站的 WSS不是把 HTTP 拦好就完事如果你在 CEF 里接过一次真实项目应该见过这种翻车现场HTTP、HTTPS 的请求全拦得干干净净唯独 WSS也就是 WebSocket Secure 的握手请求从BeforeResourceLoad里漏过去页面里那张实时图表照样把数据传得飞起。原因不是回调写错而是 WSS 的请求类型是RT_WEBSOCKET多数拦截写法默认只对RT_MAIN/RT_XHR/RT_SCRIPT生效于是漏成了常事。本文按我做内网浏览器控件时的一套拦截方案来拆先讲为什么难拦再给一份可直接改用的完整代码中间补四个最容易踩的坑。这套思路适合做客户端数据防泄露、内网页面管控、自动化测试里要按域名级联阻断 WSS 连接的开发者。新手能照着跑通熟手可以参考边界信息决定要不要在这个方向投入。2. WSS 为什么难拦请求生命周期里它占了两个特殊位置2.1 WSS 不走常规 POST/GET 通道CEF 请求生命周期里 WSS 的特殊位置CEF 的请求拦截大半集中在CefRequestHandler::OnBeforeResourceLoad。这个回调能看到所有页面发起的网络请求前提是请求类型被 CEF 识别成 resource load。HTTP/HTTPS 的页面跳转、XHR、图片、脚本都算回调里拿GetResourceType()判断一下就能精确过滤。WSS 分两段先是一个普通的 GET 握手请求带Upgrade: websocket头然后才是二进制帧的双向通道。CEF 的OnBeforeResourceLoad能看到第一段握手请求前提是请求类型被识别成 WebSocket。问题就出在这不同内核版本的 CEF 对 WS 握手请求的资源类型归属不一样有的返回RT_WEBSOCKET有的版本直接归到RT_XHR。如果你的拦截代码只判断RT_XHR大多数情况下能拦到但如果反过来只判断RT_WEBSOCKET在归到 XHR 的版本上就漏干净了。第二段帧传输则完全在 CEF 资源加载体系外面任何基于 resource load 的方案都看不到帧内容只能拦在握手前。这就解释了为什么“拦截一切网站的 WSS”不是改改 UA、加个不缓存响应头就能实现的。要拦就必须在握手请求还没发出前按住它或者在渲染层做前置注入让 WebSocket 构造器失效。两者二选一或者叠加用后面给的是主进程拦截方案。2.2 两个层面可选浏览器进程回调与渲染进程前置脚本CEF 是多进程架构拦截行为可以在浏览器进程里做也可以在渲染进程里做。浏览器进程的拦截点是OnBeforeResourceLoad优点是一处代码管控所有标签页和 iframe缺点是只能看到请求元信息看不到帧内容而且回调必须尽快返回不能做耗时操作。渲染进程的拦截点通常在 document start 阶段注入一段脚本把window.WebSocket换掉。优点是不光能拦截还能在构造器里直接拒绝连接甚至可以留一个假对象让页面误以为还在连接中、实际不发任何数据缺点是要考虑注入时机、页面 CSP内容安全策略以及 worker 里的 WebSocket 可能漏网。两者叠加最稳。常见做法是形成双层主进程OnBeforeResourceLoad负责兜底拦截渲染进程注入脚本负责给前端一个明确的失败回调。如果产品只要求“连不上就行”单层主进程就够如果前端要靠WebSocket.onerror跳错误码就得上注入脚本。这里先讲主进程这条主链路。2.3 选型为什么优先选 BeforeResourceLoad 而不是重写握手层CEF 也允许你重写 net 层、甚至给请求管道挂自定义 filter但这类做法维护成本很高。CEF 每次升级都可能动私有 API自定义 scheme handler 也覆盖不了标准ws://协议在页面内的完整生命周期。所以我给的方案全部走公开 APICefRequestHandlerCefResourceRequestHandler用resource_type和 URL 做决策返回 trueblock就把握手拦住。选这个方案的另一个理由是它顺带覆盖了http://页面里发起的ws://不加密连接因为判断逻辑只跟 resource_type 与 host 相关不关心 scheme。对“一切网站”这个需求统一判断远比逐个站点配置省事。下面直接进代码。3. 完整拦截代码在握手前把 WSS 挡住3.1 最小可运行实现RequestHandler 里拦下 ws/wss 握手先实现一个自定义的CefRequestHandler。CEF 77 之后OnBeforeResourceLoad的触发依赖GetResourceRequestHandler返回非空处理器所以两个方法都要写。// 继承 CefRequestHandler重写 GetResourceRequestHandler 与 OnBeforeResourceLoad class BlockingRequestHandler : public CefRequestHandler { public: // 每个资源请求都会先经过这里返回一个 resource handler CefRefPtrCefResourceRequestHandler GetResourceRequestHandler( CefRefPtrCefBrowser browser, CefRefPtrCefFrame frame, CefRefPtrCefRequest request, bool is_download, const CefString request_initiator, CefRefPtrCefResourceRequestHandler old_handl) override { return this; // 直接复用自身作为 resource handler } // 核心拦截点返回 true 表示 block 掉这个请求 bool OnBeforeResourceLoad(CefRefPtrCefBrowser browser, CefRefPtrCefFrame frame, CefRefPtrCefRequest request, CefRefPtrCefCallback callback) override; }; bool BlockingRequestHandler::OnBeforeResourceLoad( CefRefPtrCefBrowser browser, CefRefPtrCefFrame frame, CefRefPtrCefRequest request, CefRefPtrCefCallback callback) { // 1. 先看资源类型是不是 websocket 握手。 // RT_WEBSOCKET 是正统类型RT_XHR 是兼容旧内核的兜底 // 部分 CEF 版本会把 WS 握手归到 XHR 下。 cef_resource_type_t rt request-GetResourceType(); bool is_ws_handshake rt RT_WEBSOCKET || rt RT_XHR; // 2. 再按 URL 前缀判断确保只拦 ws:// / wss:// std::string url request-GetURL().ToString(); bool is_ws_scheme url.rfind(ws://, 0) 0 || url.rfind(wss://, 0) 0; if (!is_ws_handshake || !is_ws_scheme) { return false; // 放行非目标请求 } // 3. 检查白名单命中放行未命中拦截 if (HostInWhitelist(url)) { return false; } // 4. 返回 true 直接阻止发送握手请求 return true; }逻辑说明rt RT_WEBSOCKET || rt RT_XHR这一段是关键。很多现成代码只写一个类型换一个 CEF 版本就漏把两个类型都纳入判断版本兼容性最好。rfind(wss://, 0)是判断字符串前缀的惯用写法比substr(0, 6)更安全不会因为 URL 过短越界。这里只拦了 scheme 为 ws/wss 的请求。如果目标网站用http://页面但内部 JSON-P 轮询那是另一个问题不在本方案的覆盖范围内。返回true会令 CEF 直接取消请求页面会触发 WebSocket 的error/close事件大多数情况下符合预期。关于callback参数除非你确有必要异步决定放行还是拦截否则不要持有它。持有callback却不在回调线程及时调用会导致请求挂起、页面卡死。3.2 白名单与配置让“一切网站”之外留几个出口直接给一个白名单辅助函数。注意用 host 后缀精匹配不要用std::string::find笨办法。#include unordered_set std::unordered_setstd::string g_whitelist_hosts { ws.example.com, api.corp.local }; bool HostInWhitelist(const std::string url) { // 简单解析 host size_t scheme_pos url.find(://); if (scheme_pos std::string::npos) return false; size_t host_start scheme_pos 3; size_t host_end url.find(/, host_start); if (host_end std::string::npos) host_end url.size(); std::string host_port url.substr(host_start, host_end - host_start); // 去掉端口WSS 请求经常带 :8443、:8080 size_t colon host_port.find(:); if (colon ! std::string::npos) host_port host_port.substr(0, colon); // 精确匹配 或 子域名匹配 for (const auto whitelist : g_whitelist_hosts) { if (host_port whitelist) return true; // 匹配子域名xxx.whitelist注意点号边界 if (host_port.size() whitelist.size() host_port.compare(host_port.size() - whitelist.size(), whitelist.size(), whitelist) 0 host_port[host_port.size() - whitelist.size() - 1] .) { return true; } } return false; }参数说明g_whitelist_hosts这个集合建议从配置文件加载不要写死。运行期改配置即刻生效比重新编译客户端省事太多。子域名匹配一定要检查点号边界。不加这个判断api.example.com会被aaaaexample.com连带放行这是我实际踩过的坑。端口单独剥掉因为握手请求的 URL 里可能带:8080、:8443不清掉会把“host 相等”误判成不等白名单失效。这个函数只在回调里做字符串比较不发起任何网络请求性能可以接受。3.3 注册 Handler让你的 Client 挂上拦截器上面的 handler 要挂到 CEF 创建浏览器时使用的 Client 对象上。常见做法是自定义一个CefClient的子类同时继承BlockingRequestHandler。class MyClient : public CefClient, public BlockingRequestHandler { public: CefRefPtrCefRequestHandler GetRequestHandler() override { return this; // CEF 会查询这个接口 } };创建浏览器时这样挂载CefWindowInfo info; info.SetAsChild(parent_wnd, rect); // 嵌入桌面窗口的场景 CefBrowserSettings settings; CefRefPtrMyClient client(new MyClient()); CefBrowserHost::CreateBrowser(info, client, url, settings, nullptr, nullptr);参数说明GetRequestHandler()返回this即可前提是MyClient同时继承了BlockingRequestHandler。CefBrowserHost::CreateBrowser的第三个参数是初始 URL这里是入口页面拦截器对所有后续页面资源请求同样生效。如果你的工程里已经有一个 Client 类直接把BlockingRequestHandler的调用合并进去不用新起类。SetAsChild是嵌入模式如果你做的是独立窗口应用可以改SetAsPopup或走无窗口模式拦截逻辑不受影响。4. 兜底与边界单层拦截挡不住的角落4.1 拦不到的地方自动重试、先连接后鉴权、Worker 内连接先说死角心里有数才不会误判。主进程OnBeforeResourceLoad能拦截常规 iframe 里的 WSS但下面几类情况会漏第一浏览器自动重试。WSS 握手失败后部分浏览器会按 1s、2s、4s 的退避策略自动重连。你的回调每次都能拦到但页面端一直处于“连接中”状态表现很不友好。这不是拦截失效是体验设计问题。第二先连接后鉴权。某些站点先建立 WSS 通道再用业务层令牌鉴权握手本身不携带敏感信息。如果你只拦 URL 前缀而令牌在帧内传输握手阶段拦住了实际上已经阻止了后续一切帧传输。这个方向没问题但反过来如果你只根据 URL 里有没有 token 参数做判断会判断不准。第三Worker / SharedWorker 里创建的 WebSocket。主进程的OnBeforeResourceLoad理论上能看得到但如果在渲染进程只注入了替换window.WebSocket的脚本Worker 全局对象没有window替换就不生效。所以“注入脚本 主进程兜底”最好都做。4.2 注入脚本兜底体验让前端明确知道失败主进程拦了连接前端有时迟迟不报错表现为一直“连接中”。要解决这个体验问题常见做法是在渲染进程 document start 阶段注入一段脚本替换window.WebSocket// 在 document start 阶段执行具体时机见下方参数说明 frame-GetV8Context()-Eval( window.__cef_ws_blocked true; window.WebSocket function(url, protocols) { setTimeout(function(){ if (this.onerror) this.onerror(new Event(error)); if (this.onclose) this.onclose(new CloseEvent(close)); }.bind(this), 0); }; window.WebSocket.prototype window.WebSocket.prototype;);注意这段脚本要在页面业务代码执行前注入否则业务代码已经保存了原生构造器引用替换就晚了。如果页面有严格 CSP 禁止evalEval会失败。这时应改用CefRequestHandler::OnBeforeResourceLoad直接返回一段脚本资源让页面以 script 标签方式执行。替换后的构造器不发起任何真实请求。setTimeout里触发error和close是为了让前端按标准事件流把连接状态置为失败避免停在 CONNECTING。业务代码如果捕获了原生构造器这种注入会被绕过所以它只是体验工具不是安全边界真正的防线在主进程拦截。4.3 什么时候不该用这套性能与误伤红线OnBeforeResourceLoad回调运行在浏览器进程的 IO 线程上任何同步网络请求、磁盘读大文件、正则大列表匹配都会拖慢所有页面的加载表现为白屏、卡顿。我见到过最典型的误伤场景有人把整个公司内部监控域名的根域名加进黑名单结果运维平台的实时日志、内部的指标看板全部连不上数据链路没断开监控链路先断了。所以“一切网站”听上去很爽落地时一定要配白名单和审计位。另外如果你的业务是银行、证券类客户端页面里混合了加密传输的业务数据和内部组件心跳拦截 WSS 前要确认这些内部组件走的是独立的 wss 域名把它们加进白名单。莫要一刀切。5. 避坑与排查拦截 WSS 时我实际踩过的四个坑5.1 现象HTTP 全拦得住WSS 一条也拦不住原因CEF 版本较老时RT_WEBSOCKET枚举值存在但握手请求的类型实际被标成RT_XHR或者GetResourceRequestHandler返回了空指针导致OnBeforeResourceLoad根本没被调用。解决先在所有请求入口打日志打印resource_type和 URL确认回调真的进得来。判断条件写成rt RT_WEBSOCKET || rt RT_XHR并保证GetResourceRequestHandler返回非空。日志里如果看到GetResourceType()返回 0说明 CEF 版本把这个请求归类为未知类型那就得连URL fragment一起兜底判断。5.2 现象一拦截就卡死整个标签页白屏原因在OnBeforeResourceLoad里做了耗时操作。比如为了判断 IP 段而发起同步 DNS 查询或者写了一个 O(n) 的大规则正则把回调线程拖住了。解决回调里只做字符串判断。规则预处理放在启动时做好把 URL 前缀和 host 列表转成std::unordered_set或哈希集合回调里查表 O(1)。永远不要持有CefCallback等异步结果除非你明确知道自己在做什么。5.3 现象白名单子域名误伤或漏放原因用std::string::find判断“字符串包含”或忘了剥端口。find会把notexample.com当成example.com的子域放行端口没剥则api.example.com:8080匹配不上白名单被误拦。解决用 3.2 节的HostInWhitelist实现后缀匹配前检查点号边界并先剥掉端口。上线前在审计模式下跑一两天专门收集被拦的 URL 列表比对白名单边界。5.4 现象页面显示“连接中”没有 error 回调过很久才失败原因主进程拦掉了握手但页面端WebSocket不被视为“连接失败”而是一直停在 CONNECTING 状态等待超时。浏览器有自动重试逻辑看起来像“卡住了”。解决按 4.2 节注入替换构造器的脚本在页面一开始就让new WebSocket()直接回调 error/close。如果不想注入脚本可以在拦截后通过frame-ExecuteJavaScript主动给页面派发一个CustomEvent由前端监听后主动断开。6. 进阶把拦截做成可热更新的策略服务6.1 策略热更新与审计旁路最后给一个进阶做法把白名单和黑名单从代码里挪出去放到一个 JSON 配置文件里启动时加载同时监听文件变化。这样你可以在不重新编译客户端的情况下临时放行某台测试服务器的 wss。{ mode: block, blocked_host_suffixes: [example.com, corp.biz], whitelist_hosts: [ws.example.com, api.corp.local], log_file: ./wss_audit.log }参数说明mode有三种block拦截、audit只记录 放行、off关闭。audit是灰度阶段最好用的开关。blocked_host_suffixes是黑名单后缀列表命中即拦截whitelist_hosts优先级更高先查白名单再查黑名单。log_file指向审计日志路径每条拦截判断追加一行 JSON。加载逻辑要注意一个细节如果配置文件缺失或 JSON 解析失败默认值是block还是off我的习惯是默认block加报警。因为对于数据管控场景漏放比误杀风险大。宁可服务不可用也不要让敏感数据走一条未受控的 WSS 通道。6.2 写日志验证拦截效果审计日志的格式建议这样设计便于事后回溯{time: 2025-06-01 10:11:23, from: https://app.example.com/main, target: wss://push.example.com/socket, action: block, rule: blocked_host_suffixes}对着日志你能回答领导最常问的两个问题拦截率多少误伤了多少。我一般先在测试环境开audit跑一周确认白名单边界再切block上线后保留审计旁路日志。出问题的时候日志能在五分钟内定位是哪个页面在连哪个 wss而不是在会场被人问到当场黑匣子。6.3 我的一点习惯这套方案我做过一轮“模式字符串写错、整夜没拦住”的低级事故从那以后所有判断逻辑的默认值都设计成保守原则配置加载失败就拦截并报警而不是放行。想让这个方向真正可用建议先拿一个内部测试页面人为开一条 wss 连接在audit模式下对比日志和浏览器开发者工具里的网络记录确认拦得住、放得对再切到block。希望帮到你。本文还有配套的精品资源点击获取