js-浏览器沙箱
问题😮
现在有一个脚本编辑器,它会在浏览器端运行js脚本,我们需要限制脚本来操作window、localStorage、发请求等操作,确保改网页下其他项目的安全。
解决方案👹
1、eval或者new Function()🤢
1、这样可以用来执行脚本,但是非常不安全,并没有对上面的操作进行限制。
2、eval是直接在局部环境执行,可以通过作用域链修改局部变量;
3、new Function()是在全局作用域执行,无法改局部变量,但是可以改全局变量。
new Function语法:
//let func = new Function ([arg1, arg2, ...argN], functionBody);let sum = new Function('a', 'b', 'return a + b');
sum(1, 2) // 3
2、web worker🤠
如果脚本不涉及dom有关的内容,可以采用web worker构建沙箱环境。
场景
-
需要执行复杂的计算或数据处理
-
不需要 DOM 操作
-
希望提高应用的响应性
-
需要并行处理任务
-
可以在脚本中加载js脚本
特点
1、web Worker 无法直接访问 localStorage、sessionStorage 或其他 Window 对象的属性和方法。
2、可以通过重写fetch等方法对网络请求进行控制。
代码示例
index.html:
点击运行按钮,通过postMessage将脚本交给worker执行,当脚本执行fetch等操作时,会通过message将消息传递给主线程来执行,从而在主线程做限制。
<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Document</title>
</head><body><button onclick="run()">运行</button><script>const worker = new Worker('sandbox-worker.js');// 运行function run() {let code = `console.log("Hello from sandbox");//console.log(document);//console.log(window.a);//alert("This is a test alert");fetch('http://localhost:3000/getData').then(response => console.log(response)).catch(error => console.error(error));console.log('6666');`executeInSandbox(code)}function executeInSandbox(code) {worker.postMessage({ type: 'sandbox-execute', code: code });}// 处理worker的消息,对console、alert、请求 做处理worker.onmessage = function (event) {switch (event.data.type) {case 'log':console.log.apply(console, event.data.data);break;case 'error':throw event.data.databreak;case 'warn':console.warn.apply(console, event.data.data);break;case 'alert':alert(event.data.data);break;case 'fetch':// 处理 fetch 请求handleFetch(event.data.id, event.data.url, event.data.options);break;}};function handleFetch(id, url, options = { method: 'get' }) {// 实现 URL 过滤和请求处理if (isAllowedUrl(url)) {fetch(url, options).then(response => response.json()).then(res => worker.postMessage({ type: 'fetchResult', data: { res, id } })).catch(error => worker.postMessage({ type: 'fetchError', error: error.message }));} else {worker.postMessage({ type: 'fetchError', error: 'URL not allowed' });}}function isAllowedUrl(url) {// 实现 URL 检查逻辑// ...if (url === 'http://localhost:3000/getData') {return true}}</script></body></html>
sandbox.js:
在worker中主要是执行脚本,拦截fetch、console等操作并交由主线程来执行。
由于postMessage不能传递函数,我们需要用map记录resolve函数和本次请求的id,并将id和请求信息传给主线程来请求,等有结果后再将id和结果传给worker线程,最后通过id获取resolve函数并执行,从而完成请求操作。
let requestId = 0
const pendingRequests = new Map()self.onmessage = function (event) {if (event.data.type === 'sandbox-execute') {try {const fn = new Function(event.data.code)fn()} catch (error) {self.postMessage({ type: 'error', data: error })}} else if (event.data.type === 'fetchResult') {const { id, res, error } = event.data.dataconst request = pendingRequests.get(id)if (request) {if (error) {request.reject(new Error(error))} else {request.resolve(res)}pendingRequests.delete(id)}}
}// 重写importScripts加载脚本
const originalImportScripts = self.importScripts
self.importScripts = function (...urls) {const allowedScripts = ['https://trusted-cdn.com/script1.js', 'https://trusted-cdn.com/script2.js']for (const url of urls) {if (!allowedScripts.includes(url)) {throw new Error(`不允许加载脚本: ${url}`)}}// 如果所有脚本都被允许,则调用原始的 importScriptsreturn originalImportScripts.apply(this, urls)
}// 重写 console 方法
self.console = {log: (...args) => self.postMessage({ type: 'log', data: args }),error: (...args) => self.postMessage({ type: 'error', data: args }),warn: (...args) => self.postMessage({ type: 'warn', data: args })
}// 模拟 alert
self.alert = (message) => self.postMessage({ type: 'alert', data: message })// 重写 fetch
self.fetch = (url, options) => {return new Promise((resolve, reject) => {// 需要一个机制来处理主线程的响应const id = requestId++pendingRequests.set(id, { resolve, reject })self.postMessage({ type: 'fetch', url, options, id })})
}
3、iframe-sandbox🤓
如果包含dom操作,脚本结果需要显示到浏览器上,这时就可以用iframe来做沙箱了。
场景
-
需要加载和隔离完整的第三方页面
-
需要在沙箱中进行 DOM 操作
-
要显示可视化内容
-
需要更严格的安全隔离
特点
1、通过设置sandbox属性,可以alert(allow-modals)等操作。
2、能阻止访问父页面的变量(allow-same-origin)。
【界面显示后用户再手动改sandbox属性时无效的】
3、要对请求拦截,可以重写fetch方法。
代码示例
index.html
通过postMessage给iframe脚本,并给iframe设置一些sandbox属性。
<!DOCTYPE html>
<html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>安全的iframe脚本执行</title>
</head><body><iframe id="sandboxedFrame" src="./shandbox-iframe.html" style="position: fixed;top: 200px;left: 200px;"sandbox="allow-scripts allow-popups"></iframe><button onclick="run()">运行</button><script>function run() {const iframe = document.getElementById('sandboxedFrame');iframe.contentWindow.postMessage({type: 'sandbox-run',data: {code: `console.log(11) //可以console// 创建一个新的 div 元素const newDiv = document.createElement('div');// 设置 div 的背景色为红色newDiv.style.backgroundColor = 'red';// 设置 div 的宽度和高度(可选)newDiv.style.width = '100px';newDiv.style.height = '100px';// 将 div 添加到 body 中document.body.appendChild(newDiv);`}}, '*')}</script>
</body></html>
sandbox-iframe.html:
接收脚本,从新fetch方法,在new Function时进行传递,则在脚本中可以使用重写的fetch方法。
<!DOCTYPE html>
<html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Document</title>
</head><body><div>11</div><script>window.addEventListener('message', (res) => {console.log(res.data);const { type, data } = res.dataif (type !== 'sandbox-run') {return}// 保存原始的 fetch 方法const originalFetch = window.fetch;// 允许的源列表const allowedOrigins = ['https://api.trusted-site.com', 'https://another-trusted-site.com'];// 重写 fetch 方法const safeFetch = function (url, options) {const parsedUrl = new URL(url);if (!allowedOrigins.includes(parsedUrl.origin)) {return Promise.reject(new Error('不允许访问该源'));}return originalFetch(url, options);};// 使用 new Function 创建动态函数,并传递重写的 fetch 方法const dynamicFunction = new Function('fetch', data.code);// 调用动态函数,并传递重写的 fetch 方法dynamicFunction(safeFetch);})</script></body></html>
sandbox属性
1. allow-forms:
- 允许 `iframe` 中的表单提交。
- 默认情况下,`iframe` 中的表单提交是被禁止的。
2. allow-modals:
- 允许使用模态窗口(如 `alert`、`prompt` 和 `confirm`)。
- 默认情况下,`iframe` 中的模态窗口是被禁止的。
3. **`allow-orientation-lock`**:
- 允许锁定屏幕方向。
- 默认情况下,`iframe` 中的内容不能锁定屏幕方向。
4. **`allow-pointer-lock`**:
- 允许使用 Pointer Lock API。
- 默认情况下,`iframe` 中的内容不能使用 Pointer Lock API。
5. **`allow-popups`**:
- 允许弹出窗口(如 `window.open`)。
- 默认情况下,`iframe` 中的弹出窗口是被禁止的。
6. **`allow-popups-to-escape-sandbox`**:
- 允许弹出窗口逃离沙箱限制。
- 默认情况下,`iframe` 中的弹出窗口也会受到沙箱限制。
7. **`allow-presentation`**:
- 允许使用 Presentation API。
- 默认情况下,`iframe` 中的内容不能使用 Presentation API。
8. **`allow-same-origin`**:
- 允许 `iframe` 中的内容被视为与主页面同源。
- 默认情况下,`iframe` 中的内容被视为不同源,不能访问主页面的内容。
9. **`allow-scripts`**:
- 允许执行脚本。
- 默认情况下,`iframe` 中的脚本执行是被禁止的。
10. **`allow-top-navigation`**:
- 允许 `iframe` 中的内容导航(加载)顶级浏览上下文。
- 默认情况下,`iframe` 中的内容不能导航顶级浏览上下文。
END🤡
我们可能还需要代码审查,日志记录等操作,但是这些技术只是防君子不防小人,哈哈。😹