Chrome Native Messaging 详解:从沙箱到本地进程的通信桥梁
做浏览器扩展开发的人迟早会遇到这样一个需求网页要读取本地的身份证读卡器、打印机状态、扫码枪或者是拿一串待签名的数据去调用本机的加密证书。但浏览器扩展的代码跑在沙箱里权限被卡得死死的文件系统、硬件设备、外部进程一概碰不到。Chrome Native Messaging原生消息通信就是官方专门为这条桥接需求设计的通道它允许扩展通过浏览器拉起一个本地可执行程序并通过标准输入输出交换消息。这篇文章会把协议原理、清单配置、注册表、代码实现和常见坑一次讲透适合正在做 Chrome 扩展或者第一次接触 Native Messaging 的人直接参考。1. 原生消息通信到底解决什么问题扩展的沙箱与本地资源需求1.1 扩展代码碰不到硬件但业务需要只要做过浏览器扩展就会对扩展的能力边界有一个清晰感受页面里能用 JavaScript 操作 DOM、发网络请求但一旦想读写本地文件、调用 USB 设备、启动外部程序浏览器会直接拒绝。这不是 Chrome 故意恶心开发者而是安全模型的核心设计。扩展如果什么都能做那和直接安装一个木马没有区别。所以 Chrome 把扩展扔进沙箱所有涉及系统资源和硬件访问的操作都被隔离。但实际业务并不会因此消失。很多企业应用需要在网页里完成原本只能靠桌面程序完成的事情比如 OA 系统要调用高拍仪拍照、电子签章系统要用本地证书做签名、财务系统要读取 U 盾。早年这些需求靠 ActiveX、NPAPI 控件实现浏览器更新后全部被淘汰。这时 Chrome 给出的替代方案就是 Native Messaging。它让一个受信任的扩展去启动一个本地代理程序扩展负责接收网页事件本地程序负责真正的高权限操作两者之间通过 Chrome 这个中间人交换数据。1.2 和本地 WebSocket、CDP 相比为什么用 Native Messaging很多人第一次遇到这个需求时会想到另一个方案扩展里发起 fetch 请求到http://localhost:一些端口本地程序开一个 HTTP 服务不就行了这确实可行但问题也很明显。我整理过几个方案的对比这里直接放出来方案连接方向优点缺点Native Messaging扩展发起Chrome 拉起本地进程浏览器管理进程生命周期本地程序无需监听端口权限可精确到扩展 ID需要配置清单和注册表消息大小有限制本地 HTTP/WebSocket 服务扩展发起到 localhost 的请求服务常驻实现简单跨语言方便需要本地常驻进程端口冲突风险网页任意代码都可能对 localhost 发请求易被利用Chrome DevTools Protocol外部程序连接 Chrome 调试端口能远程控制浏览器行为方向相反适合自动化测试不适合扩展主动调本地能力Native Messaging 最大的优势在于本地程序不需要一直开着Chrome 会在扩展发起连接时把进程拉起来连接关闭后回收它。本地程序也不用监听端口所有通信都走标准输入输出管道外部攻击面大幅度减小。还有一条一般人容易忽略的好处Chrome 在启动本地 Host 之前会先确认扩展 ID 是否在 Host 的允许名单里。你写了一个恶意扩展也调用不了别人的本地 Host因为宿主程序有allowed_origins校验。所以我的判断是如果需求是“网页要调用本机能力”第一选择就是 Native Messaging只有在本地服务本身就需要长期运行、且你有办法保护好 localhost 端口安全时才考虑 HTTP 方案。CDP 则完全不是一回事它更适合做浏览器自动化测试别混着用。2. 通信链路拆解消息协议、清单注册与进程生命周期2.1 消息在管道里怎么走四字节长度前缀加 JSONNative Messaging 的通信协议我第一眼看着挺朴素的每条消息都是一个 JSON 字符串UTF-8 编码前面再加 4 个字节的长度。但就是这个简单的设计比很多自己造的协议都管用。具体拆解是这样的当扩展调用chrome.runtime.sendNativeMessage(hostName, message, callback)或者chrome.runtime.connectNative(hostName)之后Chrome 会把消息序列化成 JSON然后按照“4 字节长度 JSON 字节”的格式写入本地 Host 进程的 stdin 管道。反过来Host 向扩展返回消息时也要按同样的格式写到 stdout。因为管道是纯字节流没有天然的“一条消息”边界所以必须用固定长度的前缀来切分。长度用无符号 32 位整数按本机字节序排列。在绝大多数 x86/x64 平台上就是小端序。我见过不少人写 Host 端代码时直接从 stdin 里read()拿到什么就解析什么这是错的。正确流程一定是先读 4 个字节解码出消息体长度n再继续读n个字节最后把这n个字节按 UTF-8 解码成 JSON 字符串。发送的时候反过来先把消息json.dumps成字符串、编码成 UTF-8拿到长度用 4 字节整型前缀拼上去一起写到 stdout。这里有一个非常关键的约定本地 Host 的 stdout 只能用来发送协议数据不能打印任何调试日志。因为 Chrome 会原封不动地把 stdout 里的内容按协议解析你多打一行print(hello)Chrome 读取长度前缀后就会发现数据不合法直接断开连接。很多人的 Native Messaging 程序第一次跑通后加个日志就挂了基本都是这个原因。2.2 Chrome 怎么找到本地 Host清单与注册表Chrome 启动本地进程之前需要先找到这个 Host 的安装信息。它不是一个简单路径而是一个 JSON 文件一般叫 Host Manifest内容长这样{ name: com.example.echo, description: Echo Native Host, path: /usr/local/bin/echo_host, type: stdio, allowed_origins: [chrome-extension://abcdefghijklmnop/] }字段含义不复杂name是 Host 的名字扩展调用sendNativeMessage(com.example.echo, ...)时用的就是这个字符串。这个名字必须和后续注册表键名、清单文件名一致否则 Chrome 会报“找不到指定主机”。path是本地可执行程序的绝对路径注意不能带参数。所以如果你是 Python 写的脚本在 Windows 上不能直接把path指向python.exe并塞一个脚本路径参数凑不出来这种效果只能把脚本打包成 exe或者在 Linux/macOS 下给脚本加 shebang 和可执行权限后直接指向它。type固定写成stdio意思是走标准输入输出通信。allowed_origins是允许调用这个 Host 的扩展 ID 列表必须以chrome-extension://开头并且结尾要带斜杠/。这个数组是真正的安全门禁随便填会极大增加被其他扩展调用的风险。那么 Chrome 去哪儿找这个 JSON 文件不同系统不一样。Windows 上通过注册表Chrome 会去下面的路径下找以 Host 名为键名的项当前用户HKEY_CURRENT_USER\Software\Google\Chrome\NativeMessagingHosts\com.example.echo系统级HKEY_LOCAL_MACHINE\SOFTWARE\Google\Chrome\NativeMessagingHosts\com.example.echo注册表项的默认值也就是(默认)数据必须指向 JSON 文件的完整路径。Linux 下没有注册表Chrome 会去固定的目录找比如用户级是~/.config/google-chrome/NativeMessagingHosts/文件名字必须叫com.example.echo.json。macOS 则在~/Library/Application Support/Google/Chrome/NativeMessagingHosts/或系统目录下。这一个环节是最容易出问题的经常是扩展 ID 对不上、JSON 路径写错、注册表键名和 name 不一致导致报错的时候排查半天都找不到原因。2.3 进程生命周期和谁负责关闭Native Messaging 的进程生命周期是很多初学者理解偏差最大的地方。当你调sendNativeMessage做一次性消息时Chrome 的行为是找到 Host 配置、启动进程、把消息写进 stdin、等待进程从 stdout 返回响应、然后把进程收掉。整个过程像一次普通函数调用。而当你调connectNative时Chrome 会启动进程并建立一条可复用的长连接扩展可以通过port.postMessage()反复发送消息直到某一边调用port.disconnect()或进程退出。Host 端怎么感知连接结束很简单stdin 读到 EOF。当 Chroome 不再需要这个进程时会关闭自己那一端的管道写入口Host 端继续read就会返回空字节此时应该退出循环并结束进程。如果你写的 Host 不处理 EOF 而是一直阻塞读Chrome 可能会强制终止它。反过来如果 Host 处理到一半崩溃退出扩展端的onDisconnect会触发并且chrome.runtime.lastError会带有Native host has exited之类的错误信息。生命周期还有一个隐藏含义Native Messaging Host 进程不是常驻服务不要在里面维护一些期待跨连接保存的全局状态。你无法保证下一次调用时还是同一个进程。如果业务上有状态需求建议把状态落盘或者走外部服务。3. 从零实现一个 Echo Host 示例最小可跑通闭环3.1 编写本地 Host 程序Python 版我先把核心逻辑写出来这个程序不做任何实际业务只负责把收到的 JSON 原样从 stdout 返回这样一个最小闭环能验证整条链路是否通。import json import sys import struct def read_message(): raw_length sys.stdin.buffer.read(4) if not raw_length or len(raw_length) ! 4: return None message_length struct.unpack(I, raw_length)[0] if message_length 0: return None body sys.stdin.buffer.read(message_length) return json.loads(body.decode(utf-8)) def send_message(message): payload json.dumps(message).encode(utf-8) sys.stdout.buffer.write(struct.pack(I, len(payload))) sys.stdout.buffer.write(payload) sys.stdout.buffer.flush() def main(): while True: msg read_message() if msg is None: break send_message({echo: msg}) if __name__ __main__: main()这段代码有几个细节要特别说明。struct.unpack(I, raw_length)中的表示使用本机字节序这样和 Chrome 写入长度前缀的方式一致。有些教程写I也是可以的因为在绝大多数 PC 上本机字节序本来就小端但为了严谨用更贴近官方对“native byte order”的描述。stdout.flush()不能漏。管道通信是有缓冲的如果不主动刷新数据可能一直堆积在缓冲区Chrome 会一直等不到响应。Python 的sys.stdout在写文件时默认行缓冲或者块缓冲这里必须显式刷新。read_message返回None的时机也要注意只要读到不足 4 字节就说明浏览器已经关闭了 stdin 这一端的写句柄这是 Host 退出的信号。如果忽略这个信号继续阻塞读程序就可能变成僵尸进程。3.2 编写 Host Manifest 并注册假设上面这个 Python 脚本保存成echo_host.py在 Linux/macOS 下直接给它加可执行权限并在文件第一行加上 shebang#!/usr/bin/env python3然后创建 Host Manifest 文件com.example.echo.json{ name: com.example.echo, description: Echo Host for Native Messaging Demo, path: /home/user/bin/echo_host.py, type: stdio, allowed_origins: [chrome-extension://your-extension-id/] }Linux 用户级安装把 JSON 文件放到~/.config/google-chrome/NativeMessagingHosts/com.example.echo.json然后给脚本加可执行权限chmod x /home/user/bin/echo_host.py mkdir -p ~/.config/google-chrome/NativeMessagingHosts cp com.example.echo.json ~/.config/google-chrome/NativeMessagingHosts/Windows 下路径注册用注册表。假设 JSON 文件在C:\demo\com.example.echo.json执行REG ADD HKCU\Software\Google\Chrome\NativeMessagingHosts\com.example.echo /ve /t REG_SZ /d C:\demo\com.example.echo.json /f注意这里使用的是当前用户HKCU好处是不需要管理员权限。如果想系统级生效换HKLM对应HKEY_LOCAL_MACHINE\SOFTWARE\Google\Chrome\NativeMessagingHosts\路径一般需要安装程序才有权限写。如果你的 Chrome 安装在 32 位而操作系统是 64 位注册表还涉及 WOW6432Node 的节点问题实际项目里需要按浏览器位数对应好。最稳妥的做法是用安装包来写注册表而不是手动敲命令。3.3 编写扩展侧代码扩展这边需要两个文件manifest.json和后台脚本。先看 manifest{ manifest_version: 3, name: Native Messaging Demo, version: 1.0, permissions: [nativeMessaging], background: { service_worker: background.js } }关键点只有一个permissions数组里必须声明nativeMessaging否则chrome.runtime.sendNativeMessage是 undefined。后台脚本我用一次性短连接的方式演示const HOST_NAME com.example.echo; chrome.runtime.onInstalled.addListener(() { chrome.runtime.sendNativeMessage(HOST_NAME, { text: hello from extension }, (response) { if (chrome.runtime.lastError) { console.error(Native messaging error:, chrome.runtime.lastError.message); return; } console.log(收到 Host 响应:, response); }); });如果你想做长连接可以这样let port null; function connect() { port chrome.runtime.connectNative(HOST_NAME); port.onMessage.addListener((msg) { console.log(收到消息:, msg); }); port.onDisconnect.addListener(() { if (chrome.runtime.lastError) { console.error(连接断开:, chrome.runtime.lastError.message); } port null; }); } function send(msg) { if (!port) { console.error(连接未建立); return; } port.postMessage(msg); }一次性短连接适合“发一个请求、收一个响应”的场景长连接适合 Host 主动往扩展推送数据比如设备状态变化。不过长连接在 Manifest V3 的 Service Worker 里要注意一点Service Worker 随时可能休眠休眠后长连接可能断开。如果你的项目依赖长时间保持连接要么用别的方式保证 Service Worker 存活要么设计成断线重连。这是 MV3 迁移里特别常见的坑我这里提前说明。3.4 在 chrome://extensions 里加载并验证第一步先在地址栏打开chrome://extensions/右上角开启开发者模式。第二步点击“加载已解压的扩展程序”选择扩展目录。第三步查看扩展卡片上的 ID这个 ID 形如abcdefghijklmnop的字符串需要把它填到 Host Manifest 的allowed_origins里。这一步非常关键因为临时加载的扩展 ID 不一定稳定你每次重新加载目录ID 可能会变。接着在扩展详情页找到 Service Worker 入口点击后打开 DevTools 面板。正常流程下启动浏览器后打开扩展的 Service Worker 控制台就会看到onInstalled被触发随后出现 “收到 Host 响应: {echo: ...}” 的日志。如果没有就按下一章排查。4. 实操中的坑排查清单与避坑指南4.1 常见错误速查表Native Messaging 的报错信息不多但每个报错都能让人卡半天。我把常见错误整理成了一张表报错或表现大概率原因处理方式Specified native messaging host not found.注册表/Host Manifest 路径没配好或 Host 名拼写不一致检查chrome.runtime.sendNativeMessage的 Host 名是否和注册表键名、JSON 里name一致检查 JSON 文件是否在正确目录Native host has exited.Host 进程启动后立刻退出或 stdout 格式书写错误先在命令行手动运行 Host模拟 stdin 输入 JSON观察输出是否符合长度前缀规则Could not communicate with the native messaging host.Host 返回了非法 JSON或 stdout 混入了日志确认 Host stdout 只写协议数据禁止 print/logAccess to the specified native messaging host is denied.allowed_origins不包含当前扩展 ID仔细核对扩展 ID在 Chrome 的chrome://extensions页面复制不要猜扩展能发消息但收不到响应Host 没有 flush stdout或响应长度超限检查 Host 端flush()确认消息体小于 1MB调用 API 报错chrome.runtime.sendNativeMessage is not a functionmanifest.json 缺少nativeMessaging权限在permissions数组里补上nativeMessaging这张表不是万能但能覆盖 80% 的入门问题。4.2 “载荷不能复制对象”到底哪来的我在一些中文 Chrome 版本里看到过这个问题。排查之后发现它本质上是消息序列化失败导致的报错英文原文通常类似Could not serialize message中文被浏览器翻译成了“载荷不能复制对象”。触发条件常见有几种你往postMessage或sendNativeMessage里塞了一个 Function、DOM 节点、undefined或者循环引用的对象。你在消息里夹带了事件对象然后试图把这个对象整体发出去。有些对象虽然看起来是普通对象但它内部包含非序列化字段比如某些 SDK 返回的自定义类实例。Native Messaging 要求消息必须能被 JSON 序列化所以发数据之前先做一次严格的自查。我在实际开发中习惯写一个类似白名单风格的组装逻辑只从业务对象里手动拷贝需要的字段function buildPayload(data) { return { id: data.id, content: String(data.content || ), extra: JSON.parse(JSON.stringify(data.extra || {})) }; }这样能提前暴露很多序列化问题。如果业务里有二进制数据切记不能把 ArrayBuffer 直接塞进消息Native Messaging 的载荷本质是 JSON不是任意二进制流。正确做法是转成 base64 字符串再发Host 端再解码。4.3 消息大小限制、二进制和性能Chrome 对 Native Messaging 的单条消息体大小有硬性约束官方文档里写得很明确单条消息最大 1MB。超过这个值Chrome 可能直接断开连接或者报序列化相关错误。这个限制对传大文件非常不友好。我在一个项目里需要把网页上的一个几十 MB 的附件发给本地打印程序一开始想 base64 后放在消息里结果连测试都过不去。后来改成了先让扩展把附件上传到本地临时目录再通过 Native Messaging 把临时文件路径传给 Host由 Host 去读取文件。这个方案绕开了 1MB 限制也避免了 base64 带来的额外 33% 体积膨胀。性能方面也要注意。虽然sendNativeMessage用起来像同步调用但它本质还是进程间通信涉及 JSON 序列化、管道读写、进程启动。如果调用频繁Host 进程每次都被重新拉起开销不小。如果你需要高频交互最好用connectNative建立长连接并把 Host 设计成一个循环处理消息的服务。但要记得长连接不是免费的它占着一个进程需要管理端口生命周期。4.4 跨平台注册、权限与安装器设计Native Messaging 在不同平台的差异很容易在团队协作时埋雷。Windows 上写注册表Linux 放 JSON 到固定目录macOS 放 JSON 到 Application Support 目录每一处都有权限和路径问题。我建议从项目第一天就把安装过程自动化。一个简单的脚本负责三件事检查 Host 可执行文件是否存在、写入 Host Manifest、写入注册表Windows。脚本要可重复执行幂等。否则每次开发机换环境手动配一遍配置迟早出错。权限问题也别忽略。Linux 下如果把 Host Manifest 放在用户目录当前用户可写如果放在系统目录要注意 JSON 文件是否可读Host 是否有执行权限。Host 本身是高权限程序如果被恶意替换整个系统的安全都会出问题。正规产品应该用系统级安装并限制配置文件只能被管理员/root 修改。另外Chrome 扩展商店的审核也会关注 Native Messaging 权限用途建议在应用描述里写清楚 Host 是做什么的。本地开发时用加载已解压的程序没问题但发布到商店时扩展 ID 会变成商店分配的那个 IDallowed_origins必须跟着改。5. 应用场景与扩展思路从一个 Echo Host 到能落地的产品5.1 典型场景到底长什么样很多人看完示例并不知道 Native Messaging 能干什么。我列几个真实见到的场景电子签章/加密签名网页要签合同私钥保存在本地 USB Key 或证书库中浏览器不能直接访问。扩展把待签名的哈希发给本地 HostHost 调用加密驱动签名后返回签名结果。打印服务网页调用本地打印程序的模板把打印数据传给 Host由 Host 调系统打印接口绕过浏览器打印的不可控样式。身份读取设备身份证读卡器、扫码枪、指纹仪等设备通常只提供本地 SDK扩展通过 Host 读取证件信息再回填到表单完成实名认证。企业内网办公网页需要从本地共享目录读取文件列表或者把网页内容保存到指定本地文件夹Host 是最直接的桥接层。这些场景有一个共同点核心业务数据不能完全暴露给普通网页必须经过 Host 做权限校验和高权限操作。5.2 在 Echo 示例基础上实际项目要补哪些东西Echo Host 只是验证链路真正落地一个 Host 还需要考虑四件事。第一消息协议要设计成带请求 ID 的格式。长连接场景下扩展可能连续发多个请求Host 无法保证响应顺序如果每个响应不带请求 ID扩展根本没法配对。我在项目里用的消息结构类似{ type: request, requestId: uuid-1234, action: sign, data: {} }响应则带着同一个requestId回传。扩展端维护一个 Promise 映射表收到响应后按 ID 主动 resolve代码结构会清晰很多。第二Host 端一定要做好输入校验。allowed_origins只保证了谁可以调用这个 Host但不保证扩展的行为一定受控。恶意网页可能通过漏洞把任意 JSON 发给扩展扩展不会做过滤就把内容转给 Host。所以 Host 端要验证action是否在白名单里校验data的类型和长度避免把本地高权限接口暴露成裸奔 API。第三日志要写到文件而不是 stdout。前面已经解释过 stdout 只能走协议但开发时你不可能不看 Host 日志。我自己一般会加一个环境变量控制日志行为当NATIVE_HOST_DEBUG1时把日志写到固定目录下的日志文件。这样线上不开日志开发时可以打开看两不误。第四处理好断开重连。扩展端onDisconnect触发后要判断是不是 Host 主动崩溃。通常我会设计一个重试机制加上退避策略避免 Host 刚启动就崩溃时扩展疯狂重试。结尾再说一个我自己的习惯第一次跑通 Native Messaging 时不要一上来就写业务逻辑先让扩展发{ping: true}Host 回{pong: true}。这一条通了说明注册表、清单、扩展权限、stdin/stdout 通信全是好的。之后再逐步加业务字段。这个习惯帮我避开了大量无关 Bug也建议你试试。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →