写过爬虫脚本、做过数据清洗的重度 console 使用者都知道:
浏览器控制台就是世界上最随性的 REPL------
没有 package.json,没有 node_modules,
却想立刻用到 lodash、dayjs。
于是"在控制台里 npm install"成了暗网级需求:
复制本文 200 行代码,
install('lodash')回车,魔法生效,全局变量_从天而降。
引言
在前端开发过程中,我们经常需要在浏览器控制台中快速测试某个 npm 包,比如我们想快速测试某个版本的 lodash 是否存在原型链污染攻击。传统的方法要么过于繁琐,要么缺乏良好的用户反馈。本文将介绍一种直接在控制台中安装 npm 包的解决方案,它不仅能稳健地完成安装,还能智能地检测并提示新添加的全局变量。
效果
一睹为快,效果先行。
随便进入某个网站,比如 nodejs.org/api/vm.html 打开浏览器控制台,输入:
ts
// 快速安装 lodash,立即体验!
await console.install('lodash')
控制台立即"吭哧吭哧"开始工作:
ts
📦 install script https://unpkg.com/lodash@4.17.21/lodash.js
📦 'lodash' installing ⏳...
📦 'lodash' installed success ✅ costs ⏱️: 1285.686767578125 ms
📦 Try input: `_` in the console.
试试:
ts
_.VERSION
'4.17.21'
🎉 🥳!
核心设计理念
API 简洁全程有详细的安装状态反馈,安装完成后,清晰地告知用户哪些全局变量现在可用。
难点:如何得知安装的变量。答:通过安装前后全局变量对比来实现。
关键技术实现
1. 浏览器如何安装脚本
1.1 选择 CDN unpkg.com
通过动态注入 script 标签即可,src 我们选择更稳定的 unpkg.com。
动态注入使用 document.body.appendChild 在某些页面有坑,我们先讲如何获取 src
ts
async function fetchSrc(name) {
if (/^https?:\/\//.test(name)) {
return name;
}
const endpoint = `https://unpkg.com/${name}`
log(label, 'install script', endpoint);
return endpoint;
}
我们支持通过完整的 CDN 链接按照 install('https://...'),但是大部分时候都是直接通过包名安装,故我们需要做一个映射。
为什么能直接拼接
https://unpkg.com/${包名}?其实 script 会自动为我们做 302 到原址 unpkg.com/lodash@4.17...
1.2 注入 script
ts
const npmInstallScript = document.createElement('script');
npmInstallScript.src = src;
npmInstallScript.onload = (resp) => {
...
};
npmInstallScript.onerror = (error) => {
...
};
beforeInsert() // 获取会讲到作用
document.body.appendChild(npmInstallScript)
这里一个坑 :document.body.appendChild 其他网站好好的,但是在 bing.com 无法安装包,原因是这个方法被覆写了,设置了同域白名单导致无法插入来自 unpkg 的script。
控制台输入 document.body.appendChild 点击后你会发现 appendChild 被覆写了:
ts
Element.prototype.appendChild = function(n) {
return t(n, "AC") ? u.apply(this, arguments) : null
}
怎么办?将 script 内联嵌入是一种办法,但更简单的是换一个没被"污染"的方法。
diff
+ const append = getSafeAppend()
beforeInsert()
- document.body.appendChild(npmInstallScript)
+ append(npmInstallScript);
getSafeAppend 即找一个纯净的插入方法。
js
/**
* 更健壮的 `document.body.appendChild`
* @param {HTMLScriptElement} script
* @returns {(element: HTMLElement) => void}
*/
function getSafeAppend() {
// bing.com 会拦截非本 hostname 的 script 标签的插入,我们需要找到一个原生的插入方法
const candidates = ['appendChild', 'append', 'prepend', 'before', 'after'];
let nativeOperation = candidates.find((op) =>
isNativeCode(document.body[op])
);
if (!nativeOperation) {
nativeOperation = candidates[0];
}
const insert = document.body[nativeOperation].bind(document.body);
return insert;
}
/** func passed in should not be bound otherwise the result is always true */
const isNativeCode = (func) => {
return func?.toString().includes('[native code]');
};
!Important
注意:
insert方法需要bind否则报错TypeError: Illegal invocation。但在判断isNativeCode之前不能bind否则一律会被认为是native code。
释义: 既然 appendChild 被覆写了,浏览器还有很多原生插入方法呢,逐个试看看哪一个是原生方法即 includes('[native code]'),直到找到为止,如果没找回退到第一个。
其实除了上述五个,还有
insertBeforeinsertAdjacentElementinsertAdjacentHTMLinnerHTML等,但是这些函数需要接受更多参数,还需要特殊操作,故没有纳入,当然如果为了健壮性,还可以继续使用几个兜底,当然逻辑就要增加一些了。
接下来这一部分是健壮性:
ts
try {
if (!document.querySelector(`#${id}`)) {
throw new Error('Failed to insert script');
}
} finally {
npmInstallScript.remove();
}
释义: 如果发现插入后找不到则报错,说明没有插入成功,或许是没有找"纯净的"插入方法。最后清理元素。
至此包安装成功了,我们需要提醒用户如何使用,这也是我们的重点加难点,业界其他同类方法没有的。
2. 探测安装了哪些变量
下面我们将实现『全局变量变化检测机制 』------巧妙利用 iframe 的纯净环境建立基准,准确识别出用户代码添加的全局变量。下面代码值得反复阅读:
javascript
// https://mmazzarolo.com/blog/2022-02-14-find-what-javascript-variables-are-leaking-into-the-global-scope/
function getVariablesLeakingIntoGlobalScope() {
// 创建临时 iframe 获取浏览器默认的全局变量基准
const iframe = window.document.createElement('iframe');
iframe.src = 'about:blank';
window.document.body.appendChild(iframe);
const browserGlobals = Object.keys(iframe.contentWindow);
window.document.body.removeChild(iframe);
// 过滤出运行时添加的全局变量(排除浏览器原生变量)
return Object.keys(window).filter(key => !browserGlobals.includes(key));
}
释义: 函数返回类型是字符串数组,内容是除了浏览器本身的全局变量之外任何代码导致的全局变量,通常用来检测意外泄露的全局变量,只不过我们这里用来探测安装包。
稍微重构下 :使用 Set 优化过滤逻辑以及增加排序。
ts
function getVariablesLeakingIntoGlobalScope() {
const iframe = window.document.createElement('iframe');
iframe.src = 'about:blank';
window.document.body.appendChild(iframe);
// 获取纯净的全局变量
const builtinGlobals = new Set(Object.keys(iframe.contentWindow));
window.document.body.removeChild(iframe);
// 获取当前 window 的全局变量
// 二者的差集即为"泄露的全局变量"
const runtimeGlobals = new Set(Object.keys(window)).difference(builtinGlobals);
return [...runtimeGlobals].sort();
}
很容易懂大家看注释即可。
我们可以在掘金首页执行一下,返回 45 个全局变量:
ts
[
"$nuxt",
"ComponentRuntime",
"LogPluginObject",
"SMS",
"SlardarWeb",
"TEAVisualEditor",
"TTGCaptcha",
"Vue",
"VueCompositionAPI",
"_NOW_",
"_SdkGlueInit",
"_SdkGlueLoadingMap",
"__NUXT__",
"__SLARDAR_REGISTRY__",
"__VC_LOG__REPORT__",
"__bytreplay_bridge__",
"_vc_intercepted_fetch",
"_vc_intercepted_pathList",
"_xssProject",
"autoRender",
"bdms",
"clearImmediate",
"closeCaptcha",
"getCaptchaWebId",
"getFilterXss",
"initVerifyCenter",
"initVerifyOptions",
"isHeadless",
"isSafeDomain",
"isSafeProtocol",
"isSafeUrl",
"onNuxtReady",
"onNuxtReadyCbs",
"onwheelx",
"regeneratorRuntime",
"renderCaptcha",
"renderSecondVerifyWeb",
"setImmediate",
"slardarPluginImageReport",
"verifyCenter",
"verifyCenterTea",
"verifySDK",
"webpackJsonp",
"xss",
"xssNamespace"
]
__NUXT__和$nuxt告诉我们掘金是基于 Nuxt 框架的 SSR 应用。
接下来我们通过追踪安装前后全局变量的变化来得知此次安装了哪些变量,从而提醒用户可以输入什么来 play,重中之重。
1. 记录 安装前 的全局变量
javascript
let globalsBefore;
const beforeInsert = () => {
globalsBefore = new Set(getVariablesLeakingIntoGlobalScope());
};
!Important
注意:
beforeInsert应该在 script 元素被插入前一刻触发,如果过早触发会有什么问题?可能会导致加入其他脚本引入的全局变量,因为不只是我们一个脚本会导致全局变量的增加,从而最后提示用户的变量有误。
2. 记录 安装后 的全局变量
脚本 <script src> onload 后触发 success,然后依旧利用 Set 的差集方法 difference 过滤出安装后全局变量的增量,即为我们此次安装的包变量。
ts
const success = () => {
const globalsAfter = new Set(getVariablesLeakingIntoGlobalScope());
const added = [...globalsAfter.difference(globalsBefore)];
// 核心提示功能:告诉用户新增了哪些全局变量
if (added.length > 0) {
console.log('📦', 'Package installed successfully! Try input:',
added.at(-1),
'in the console.'
);
}
};
这个提示还是蛮重要的否则安装完毕用户一脸懵我应该输入什么来试用刚刚安装的包?
小结: 我们通过打开一盏"iframe 探照灯":
- 开一个
about:blank的空白 iframe, - 把它的
window键值表当作"浏览器出厂默认清单", - 安装前后各拍一张"全局变量快照",
- 集合做一次
difference,新增的就是"漏网之鱼"。
健壮性保障机制
1. 防御网站对原生方法的篡改
某些网站(如 bing.com)会重写原生 DOM 方法,导致脚本插入失败。我们通过检测真正的原生方法来应对:
javascript
const isNativeCode = (func) => {
return func?.toString().includes('[native code]');
};
const candidates = ['appendChild', 'append', ...];
const nativeOperation = candidates.find((op) =>
isNativeCode(document.body[op])
);
2. 插入后查询验证是否真实插入
ts
try {
if (!document.querySelector(`#${id}`)) {
throw new Error('Failed to insert script');
}
} finally {
npmInstallScript.remove();
}
插入后移除,防止元素越积越多。
3. 灵活的重复安装控制
javascript
// 强制重新安装机制
if (name === 'lodash') {
const _ = window._;
if (typeof _ === 'function' &&
typeof _.flowRight === 'function' &&
typeof _.VERSION === 'string') {
console.log(`📦 lodash@${_.VERSION} has been installed already.`);
// 只有明确设置 force: true 才会重新安装
if (!force) {
console.log('📦 Use install("lodash", { force: true }) to reinstall');
return true;
}
}
}
- flowRight VERSION 是否为了判断 lodash 是否已经安装过。
- 安装后提示无需重复安装,但仍然可以通过
force参数强制覆盖。
优雅的展示
1. 智能的用户反馈
安装过程详细的安装状态反馈,安装完成后,系统会自动检测并提示新增的全局变量:
javascript
📦 'lodash' installing ⏳...
📦 install script https://unpkg.com/lodash@4.17.15/lodash.min.js
📦 'lodash' installed success ✅ costs ⏱️: 1250ms
📦 'lodash' installed failed ‼️ costs ⏱️: 1250ms
📦 Try input: `_` in the console.
2. 多种安装方式支持
javascript
// 1. 通过包名安装(自动解析版本)
await install('lodash');
// 2. 安装特定版本
await install('lodash@4.17.15');
// 3. 强制重新安装(即使已存在)
await install('lodash', { force: true });
// 4. 直接使用 CDN URL(跳过解析)
await install('https://cdn.jsdelivr.net/npm/lodash@4.17.15/lodash.min.js');
不支持安装 ES 模块,因为即使安装了也没法在控制台使用。
实际应用场景
快速原型验证
javascript
// 想测试 dayjs 的使用?
await install('dayjs');
// 控制台提示:Try input `dayjs` in the console.
dayjs().format('YYYY-MM-DD') // 立即开始测试!
// 需要特定版本?
await install('dayjs@1.10.7');
// 强制更新到最新版本?
await install('dayjs', { force: true });
比如我最近琢磨原型链污染,lodash 低版本存在该风险,那我可以在控制台如此验证:
js
console.install('lodash@4.17.15')
// Malicious payload
_.set({}, '__proto__.isAdmin', true);
const obj = { hello: 'world' }
console.log(obj.isAdmin); // Outputs: true 说明原型链被污染了
我不需要新建一个文件夹 npm install lodash......。只需随手打开控制台,在几秒钟就可以完成验证且随用随弃。
释义: 我们通过 _.set({}, '__proto__. 污染了 Object.prototype. 同理可以通过 _.merge({}, { '__proto__.isAdmin2': true }) 也会导致原型链污染。
验证结果: lodash@4.17.15 输出 true,说明原型链被污染。
试试 lodash@latest
diff
- console.install('lodash@4.17.15')
+ console.install('lodash@latest', { force: true })
ts
// Malicious payload
_.set({}, '__proto__.isAdmin2', true);
const obj = { hello: 'world' }
console.log(obj.isAdmin2); // Outputs: true 说明原型链被污染了
释义:
@latest或不写版本号即安装最新版本,force是因为我们直接安装过,需要强制覆盖。- 需要改成
isAdmin2因为isAdmin已经被赋值
验证下是否安装了最新版本:
js
_.VERSION
'4.17.21'
验证结果: lodash@4.17.15 输出 undefined,说明原型链未被被污染。
多包安装场景
javascript
// 同时安装多个工具库
await install('lodash');
await install('axios');
await install('moment');
// 分别提示每个包引入的全局变量
技术亮点
1. 精准的变量检测
通过 iframe 基准对比,准确识别真正由安装包引入的全局变量,避免误报。
2. 防御性编程
- 🛡️ 检测并绕过被网站篡改的原生方法
- 🔄 自动回退机制确保脚本插入成功
3. 灵活的安装策略
- 🔧 强制更新:通过
{ force: true }明确控制 - 🎯 版本控制:支持精确版本管理
4. 友好的开发者体验
- 🎯 精确提示新增的全局变量名
- ⏱️ 安装耗时监控
- 🛡️ 完善的错误处理
- 🔄 重复安装智能检测和提示
完整代码
js
const { log, warn, error } = console;
// https://mmazzarolo.com/blog/2022-02-14-find-what-javascript-variables-are-leaking-into-the-global-scope/
function getVariablesLeakingIntoGlobalScope() {
const iframe = window.document.createElement('iframe');
iframe.src = 'about:blank';
window.document.body.appendChild(iframe);
const builtinGlobals = new Set(Object.keys(iframe.contentWindow));
window.document.body.removeChild(iframe);
const runtimeGlobals = new Set(Object.keys(window)).difference(
builtinGlobals
);
return [...runtimeGlobals].sort();
}
function npmDownload(
src,
{ originName, successCallback, errorCallback, beforeInsert }
) {
const label = '📦';
log(label, `'${originName}' installing ⏳...`);
const successTimerLabel = `${label} '${originName}' installed success ✅ costs ⏱️`;
const failedTimerLabel = `${label} '${originName}' installed failed 😱 costs ⏱️`;
console.time(successTimerLabel);
console.time(failedTimerLabel);
const npmInstallScript = document.createElement('script');
const id = [
'tampermonkey-utils-npm-install',
originName.replaceAll('@', '-').replaceAll('.', '-'),
Date.now(),
].join('-');
npmInstallScript.setAttribute('id', id);
npmInstallScript.src = src;
// npmInstallScript.setAttribute('crossorigin', '');
npmInstallScript.onload = (resp) => {
console.timeEnd(successTimerLabel);
successCallback(resp);
};
npmInstallScript.onerror = (error) => {
console.timeEnd(failedTimerLabel);
errorCallback(error);
};
const append = getSafeAppend();
beforeInsert();
append(npmInstallScript);
try {
if (!document.querySelector(`#${id}`)) {
// console.error(new Error('Failed to insert script'))
throw new Error('Failed to insert script');
}
} finally {
npmInstallScript.remove();
}
}
/** func passed in should not be bound otherwise the result is always true */
const isNativeCode = (func) => {
// console.log('func:', func)
// console.log('func?.toString():', func?.toString())
return func?.toString().includes('[native code]');
};
/**
* 更健壮的 `document.body.appendChild`
* @param {HTMLScriptElement} script
* @returns {(element: HTMLElement) => void}
*/
function getSafeAppend() {
// bing.com 会拦截非本 hostname 的 script 标签的插入,我们需要找到一个原生的插入方法
const candidates = ['appendChild', 'append', 'prepend', 'before', 'after'];
let nativeOperation = candidates.find((op) =>
isNativeCode(document.body[op])
);
// console.log('nativeOperation:', nativeOperation)
if (!nativeOperation) {
warn(
label,
'insert method not found in',
candidates,
'but the installment is still trying to insert.'
);
nativeOperation = candidates[0];
}
const insert = document.body[nativeOperation].bind(document.body);
return insert;
}
async function npmInstallInBrowser(name, { info, beforeInsert }) {
const label = '📦';
const originName = name.trim();
// console.log(originName);
const { promise, resolve, reject } = Promise.withResolvers();
const options = {
originName,
info,
successCallback: resolve,
errorCallback: reject,
beforeInsert,
};
if (/^https?:\/\//.test(originName)) {
npmDownload(originName, options);
} else {
const url = `https://unpkg.com/${originName}`;
log(label, 'install script', url);
npmDownload(url, options);
}
return promise;
}
/**
* Install js package in your console.
* @param {string} name npm package name or github url
* @param {{ force?: boolean }} info
* @returns {Promise<boolean>}
* @example
* install('lodash')
* install('lodash@4.17.15')
*
*/
async function install(name, info = {}) {
const { force } = info;
const label = '📦';
if (name === 'lodash') {
const _ = window._;
if (
typeof _ === 'function' &&
typeof _.flowRight === 'function' &&
typeof _.VERSION === 'string'
) {
log(
label,
`lodash@${_.VERSION} has been installed already. Enable \`force\` option to reinstall.`
);
if (!force) return true;
}
}
if (!name) {
error(label, 'invalid params: missing package name or url');
return false;
}
// figure out what installed in global scope
let globalsBefore;
const beforeInsert = () => {
globalsBefore = new Set(getVariablesLeakingIntoGlobalScope());
};
const success = () => {
const globalsAfter = new Set(getVariablesLeakingIntoGlobalScope());
const added = [...globalsAfter.difference(globalsBefore)];
if (info.force) {
console.assert(added.length === 0);
} else {
added.length !== 1 &&
warn(label, 'Should be only one global variable installed', added);
}
// console.log('added', added)
added.length &&
log(label, 'Try input', `\`${added.at(-1)}\``, 'in the console.');
};
try {
await npmInstallInBrowser(name, { info, beforeInsert });
success();
return true;
} catch (err) {
error(label, err);
return false;
}
}
// 绑定到 console 方便使用
console.install = install;
将完整代码复制到浏览器控制台中,然后开始使用!:
javascript
await console.install('lodash');
结语
这个解决方案重新定义了在浏览器控制台中安装 npm 包的体验。它不仅仅是一个安装工具,更是一个智能的开发助手。通过精确的全局变量检测、防御性的环境适应机制和灵活的重装控制,它为开发者提供了可靠且信息丰富的安装体验。
核心价值:在复杂多变的浏览器环境中,稳健地完成包安装,并通过清晰的反馈让开发者立即了解安装结果。
现在就在你的浏览器控制台中尝试,感受这种优雅的安装方式吧!