微前端和模块联邦流行之后,我们的页面越来越多地从远端拉取 JS 模块执行。这些模块一旦在传输途中被劫持或源站被攻破,恶意代码就会直接运行在主应用的上下文里。社区里常说的“认证接收链”,本质上就是在 Webpack 5 的构建产物和浏览器运行时之间建立一条可验证的信任链:构建时为产物签名,接收时验签,未通过校验的模块一律拒绝执行。这篇文章把这个概念拆开讲透,并给出可以直接落地的代码。

认证接收链的三个核心环节
一条完整的信任链由三部分组成:构建签名、清单传递、运行时验签。构建签名发生在打包阶段,Webpack 5 的 output.hashFunction 配合额外的插件,可以为每个 chunk 计算出确定性哈希,我们再用自己的私钥对哈希清单签名,生成一份 manifest 文件。这一步的关键在于哈希必须覆盖文件全部字节,而不是只取文件名中的 contenthash,因为文件名可以被中间人替换。
清单传递是指把签名后的清单安全地送到浏览器。常见做法是把 manifest 内联进 HTML 的 meta 标签,随主文档一起走 HTTPS 传输,或者由主应用在启动时从可信域名拉取。注意清单本身也要参与校验:主应用内置公钥指纹,任何指纹不匹配的清单直接丢弃。这样就形成了一条从“构建机私钥”到“浏览器内置公钥”的完整链路,中间任何环节被篡改都会导致验签失败。
运行时验签是最后一道闸门。Webpack 5 的模块加载器在执行模块工厂函数之前,会先拿到模块内容计算哈希,与清单中的值比对,比对通过才允许进入 __webpack_require__ 的执行流程。换句话说,验签必须发生在“接收”而不是“使用”阶段,这也是“认证接收链”这个名字的由来——先把住入口,再谈执行。
结合模块联邦落地实现
Module Federation 是最需要这条链路的场景。远程容器通过 remoteType 和 remotes 配置声明后,主应用启动时会动态拉取 remoteEntry.js,这个文件完全暴露在公网传输路径上。我们先看基础配置:
const { ModuleFederationPlugin } = require('webpack').container;
const crypto = require('crypto');
const fs = require('fs');
class SignManifestPlugin {
apply(compiler) {
compiler.hooks.afterEmit.tapAsync('SignManifestPlugin', (compilation, callback) => {
const manifest = {};
for (const [name, info] of compilation.assets) {
if (name.endsWith('.js')) {
const content = info.source();
manifest[name] = crypto.createHash('sha256').update(content).digest('hex');
}
}
// 用私钥对清单签名,私钥只存在于构建机
const sign = crypto.createSign('RSA-SHA256');
sign.update(JSON.stringify(manifest));
const signature = sign.sign(process.env.BUILD_PRIVATE_KEY, 'base64');
fs.writeFileSync('dist/manifest.json', JSON.stringify({ manifest, signature }));
callback();
});
}
}
module.exports = {
output: {
hashFunction: 'xxhash64', // Webpack 5 内置 xxhash,性能优于 md5
hashDigest: 'hex',
},
plugins: [
new ModuleFederationPlugin({
name: 'host',
remotes: { widgets: 'widgets@https://cdn.example-static.com/remoteEntry.js' },
}),
new SignManifestPlugin(),
],
};主应用侧需要一个 runtime guard,在模块加载阶段做校验。Webpack 5 提供的 __webpack_init_sharing__ 和容器加载 API 都是可插拔的,我们可以包装一层:
async function loadVerifiedRemote(url, scope, module) {
const res = await fetch(url);
const code = await res.text();
// 对拉取到的远程代码整体计算哈希
const hash = await crypto.subtle.digest('SHA-256',
new TextEncoder().encode(code));
const hex = [...new Uint8Array(hash)]
.map(b => b.toString(16).padStart(2, '0')).join('');
// 与可信清单比对,清单验签过程省略
const { manifest } = await fetchVerifiedManifest();
const expected = manifest[urlToAssetName(url)];
if (hex !== expected) {
throw new Error('远程模块完整性校验失败,已拒绝执行');
}
// 校验通过后才注入执行
self.evalWebAssembly ? null : (0, eval)(code);
await __webpack_init_sharing__('default');
const container = window[scope];
await container.init(__webpack_share_scopes__.default);
const factory = await container.get(module);
return factory();
}除了自研方案,浏览器原生的 <script> 标签 integrity 属性也值得用上,也就是常说的 SRI。对通过 HTML 直引的 chunk,加上 integrity 和 crossorigin 属性,浏览器会在执行前自动比对 sha384 哈希,等于免费获得一层接收校验。需要注意的是 SRI 只覆盖静态标签引入的资源,动态 import 的模块仍需上面的自定义 guard 兜底。
性能开销与降级策略
验签不是免费的。以 SHA-256 为例,对 500KB 的 chunk 计算哈希在桌面浏览器上通常耗时几毫秒,移动端可能达到十几毫秒,多个模块叠加后对首屏有一定影响。优化手段有两个:一是把校验放在 requestIdleCallback 之外的独立 Worker 中并行计算,主线程只做比对;二是利用 Webpack 5 的持久缓存,同一版本产物的哈希结果缓存在 IndexedDB 里,版本号不变就跳过重复计算。
降级策略同样重要。当远程 CDN 故障或清单服务不可用时,需要有本地兜底包:可以把关键 remote 的产物同步一份到主应用域名下,校验失败或拉取超时后切换到兜底地址重试。同时务必做好监控上报,把验签失败事件接入告警——它既可能是攻击信号,也可能是构建流程出了问题导致清单与产物不同步,两者的响应动作完全不同。
最后提醒一个常见误区:不要把 contenthash 当作完整性校验的依据。contenthash 出现在文件名里,攻击者完全可以替换文件内容并重新计算文件名,浏览器并不知道原始哈希是什么。只有当哈希值通过独立的安全通道(内联在主文档或经过签名的清单)传递时,整条接收链才算真正闭合。把构建签名、清单传递、运行时验签三件事都做扎实,远程模块的安全边界才算真正建立起来。