惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

The Last Watchdog
The Last Watchdog
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
GbyAI
GbyAI
Y
Y Combinator Blog
CTFtime.org: upcoming CTF events
CTFtime.org: upcoming CTF events
The GitHub Blog
The GitHub Blog
博客园_首页
小众软件
小众软件
I
InfoQ
J
Java Code Geeks
月光博客
月光博客
S
Secure Thoughts
Microsoft Security Blog
Microsoft Security Blog
V
Visual Studio Blog
Hacker News - Newest:
Hacker News - Newest: "LLM"
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
Stack Overflow Blog
Stack Overflow Blog
cs.CV updates on arXiv.org
cs.CV updates on arXiv.org
N
News and Events Feed by Topic
Exploit-DB.com RSS Feed
Exploit-DB.com RSS Feed
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
The Cloudflare Blog
T
Threat Research - Cisco Blogs
A
About on SuperTechFans
H
Help Net Security
MongoDB | Blog
MongoDB | Blog
博客园 - 聂微东
人人都是产品经理
人人都是产品经理
H
Hackread – Cybersecurity News, Data Breaches, AI and More
Recent Commits to openclaw:main
Recent Commits to openclaw:main
Latest news
Latest news
G
GRAHAM CLULEY
IT之家
IT之家
C
Cisco Blogs
Last Week in AI
Last Week in AI
Engineering at Meta
Engineering at Meta
L
LangChain Blog
The Register - Security
The Register - Security
SecWiki News
SecWiki News
M
MIT News - Artificial intelligence
NISL@THU
NISL@THU
T
Tenable Blog
博客园 - Franky
美团技术团队
I
Intezer
U
Unit 42
雷峰网
雷峰网
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
S
SegmentFault 最新的问题
C
Cyber Attacks, Cyber Crime and Cyber Security

博客园 - 爱喝可乐的咖啡

引入AI辅助的3D游戏美术工作流 基于ThreeJs的大屏3D地图(二)——气泡图、渐变柱体与热力图 基于Three.js的大屏3D地图(一)——地图模型渲染 不止于面向对象的SOLID原则 沿SVG路径的颜色渐变 参照DefenseGrid在Unity中实现合理的塔防寻路机制 slate源码解析(三)- 定位 slate源码解析(二)- 基本框架与数据模型 slate源码解析(一)- 序言 重新捋一捋React源码之更新渲染流程 从源码入手探究一个因useImperativeHandle引起的Bug [译]深入了解现代web浏览器(四) [译]深入了解现代web浏览器(三) [译]深入了解现代web浏览器(二) [译]深入了解现代web浏览器(一) 依据HTML标准再探Javascript事件循环及其与浏览器渲染的关系 记一次flex布局中子项目尺寸不受flex-shrink限制的问题 浅析富文本编辑器框架Slate.js 关于在异步操作中访问React事件对象的问题
定位解析一个因脚本劫持导致webpack动态加载异常的问题
爱喝可乐的咖啡 · 2023-03-04 · via 博客园 - 爱喝可乐的咖啡

问题描述

项目现场的前端项目在点击顶部的导航栏切换不同的模块时,会有小概率出现模块加载报错的情况:

我们的前端项目里是有基于react-loadable做的懒加载的,上图的12.be789340.chunk.js就是懒加载需要请求的模块。现场复现问题时出错的模块每次都可能不一样,并且出现问题的频率也挺稳定的,差不多每一二十次就会出现一次这种情况。

在复现出问题时,再看到网络请求的面板:

可以看到,先是有一个正常的js文件请求,接着会再发出一个相同地址的请求但后缀带上了个从没见过的参数。并且看到在最右侧一列,第二个请求发出的地方是12.be789340.chunk.js:3,是在上一个js文件里发出的!

看完请求面板这里,再结合控制台的(missing: xxx.js)报错,几乎可以断定是我们的js脚本被第三方劫持了。劫持了第一个请求后将里边的内容都替换为自己的,加载完后执行的就是它们的代码,然后再重新发送一次请求,这次请求加载到的内容才是我们前端项目里真正的代码。并且还带上了参数用来标识。


webpack动态加载原理

虽然第一个js脚本的请求被劫持了,但不是接着就发送了第二个请求去加载真正的js内容了吗?为何还会报上图的错误呢。这要从webpack动态加载模块的实现说起。

懒加载模块是利用ES10的新特性import()方法来完成的,经过webpack编译后如下:

// This file contains only the entry chunk.
// The chunk loading function for additional chunks
__webpack_require__.e = function requireEnsure(chunkId) {
	var promises = [];


	// JSONP chunk loading for javascript

	var installedChunkData = installedChunks[chunkId];
	if(installedChunkData !== 0) { // 0 means "already installed".

		// a Promise means "currently loading".
		if(installedChunkData) {
			promises.push(installedChunkData[2]);
		} else {
			// setup Promise in chunk cache
			var promise = new Promise(function(resolve, reject) {
				installedChunkData = installedChunks[chunkId] = [resolve, reject];
			});
			promises.push(installedChunkData[2] = promise);

			// start chunk loading
			var head = document.getElementsByTagName('head')[0];
			var script = document.createElement('script');
			var onScriptComplete;

			script.charset = 'utf-8';
			script.timeout = 120;
			if (__webpack_require__.nc) {
				script.setAttribute("nonce", __webpack_require__.nc);
			}
			script.src = jsonpScriptSrc(chunkId);

			onScriptComplete = function (event) {
				// avoid mem leaks in IE.
				script.onerror = script.onload = null;
				clearTimeout(timeout);
				var chunk = installedChunks[chunkId];
				if(chunk !== 0) {
					if(chunk) {
						var errorType = event && (event.type === 'load' ? 'missing' : event.type);
						var realSrc = event && event.target && event.target.src;
						var error = new Error('Loading chunk ' + chunkId + ' failed.\n(' + errorType + ': ' + realSrc + ')');
						error.type = errorType;
						error.request = realSrc;
						chunk[1](error);
					}
					installedChunks[chunkId] = undefined;
				}
			};
			var timeout = setTimeout(function(){
				onScriptComplete({ type: 'timeout', target: script });
			}, 120000);
			script.onerror = script.onload = onScriptComplete;
			head.appendChild(script);
		}
	}
	return Promise.all(promises);
};

对于需要加载的模块chunkId,流程如下:

  1. 设置installedChunkData[chunkId],标记该模块正在加载。

  2. 创建<script/>标签,并插入页面中,开始加载js脚本。

  3. 加载完js脚本后会立即执行。在由webpack打包出来的chunk中,会执行webpackJsonpCallback函数。在该函数中,会修改installedChunks[chunkId] = 0,并且还会执行installedChunks[chunkId]数组中的第一个函数也就是上面那个promiseresolve函数,将__webpack_require__.e函数中返回的promise变成成功状态。 webpackJsonpCallback函数的代码如下:

    // install a JSONP callback for chunk loading
    function webpackJsonpCallback(data) {
    	var chunkIds = data[0];
    	var moreModules = data[1];
    	var executeModules = data[2];
    
    	// add "moreModules" to the modules object,
    	// then flag all "chunkIds" as loaded and fire callback
    	var moduleId, chunkId, i = 0, resolves = [];
    	for(;i < chunkIds.length; i++) {
    		chunkId = chunkIds[i];
    		if(installedChunks[chunkId]) {
    			resolves.push(installedChunks[chunkId][0]);
    		}
    		installedChunks[chunkId] = 0;
    	}
    	for(moduleId in moreModules) {
    		if(Object.prototype.hasOwnProperty.call(moreModules, moduleId)) {
    			modules[moduleId] = moreModules[moduleId];
    		}
    	}
    	if(parentJsonpFunction) parentJsonpFunction(data);
    
    	while(resolves.length) {
    		resolves.shift()();
    	}
    
    	// add entry modules from loaded chunk to deferred list
    	deferredModules.push.apply(deferredModules, executeModules || []);
    
    	// run deferred modules when all chunks ready
    	return checkDeferredModules();
    };
    
  4. 执行完后,执行<script/>的onload回调,也就是上面的onScriptComplete函数。如果加载成功会判断到installedChunks[chunkId] === 0,则无需做任何操作。否则的话,说明资源加载出错,执行reject(error)抛出异常。

捋清了webpack动态加载chunk文件的流程,导致报错问题的真正原因也就清楚了。我们把导致问题的整个流程也梳理一遍:

  1. webpack的运行时 向页面中插入需要动态加载的chunk的<script/>标签,并添加onload回调。
  2. <script/>标签发起请求,但是被拦截了并返回篡改后的代码。
  3. 浏览器接收到篡改后的js脚本后立即执行。由于里面并不是我们前端项目中的chunk的内容,所有并不会有执行installedChunks[chunkId] = 0这一步。
  4. 第[3]步执行完后,触发<script/>onload回调。在回调函数中,因为判断到installedChunks[chunkId] !== 0,所以reject(error)抛出异常。
  5. 在篡改的代码内容中,最后还会再请求一次真正的chunk内容。而这个chunk中的代码执行后就算设置了installedChunks[chunkId] = 0并调用resolve()也已经没有作用了,因为对应的promise在前面已经被reject掉了。

解决办法

  • 使用https来加密传输的数据。对于运营商劫持的情况,用https连接就可以很大程度上解决问题。
  • 对于笔者的这种情况,是由于项目现场内网环境的一些特殊原因造成的并且没法干预,只能想办法绕开:通过前文对导致报错问题流程的梳理,我们知道是因为第一个执行了篡改内容的<script/>提前先触发了onload回调(即onScriptComplete函数),才导致了webpack报错。因此我们采用的临时解决办法就是覆写Element.prototype.appendChild方法,使得在document.head.appendChild(script)添加<script/>标签并且资源是属于webpack的动态加载的chunk时,就给原script.onload的回调加上一个延时后再执行(但不要超过script.timeout)。因为在chunk中的js代码执行时调用的webpackJsonpCallback函数会将__webpack_require__.e中的promiseresolve掉,所以onload回调是否执行并不影响webpack动态加载的流程,回调中的代码只是处理 在出错时能够抛出异常的逻辑而已。