今天看一个 Hacker News 上冒出来的有意思项目:在 Markdown 里安全地嵌入 HTML,然后用一个可以在文档里跑的 Doom 小游戏来证明这套沙箱机制是能用的。项目标题叫 “Show HN: Sandboxed HTML in Markdown (With Doom, Sort Of)”,核心思路可以理解成:Markdown 文档不再只是静态文字,还可以嵌入可交互的前端组件,同时通过沙箱技术保证这些组件不会脱离文档容器、不会拿到宿主页面的权限。
这个项目最值得关注的点在于它把 Markdown 的“内联 HTML”能力提升了一个层次。CommonMark 规范本身就允许 Markdown 里写 HTML 标签,很多渲染器也会直接渲染它们。但问题是,如果 HTML 里带了<script>或者事件属性,渲染出来就是裸脚本,放在本地还好,一旦部署到博客、文档站、知识库,就成了 XSS 攻击的入口。这个项目用 iframe sandbox 或者说一套隔离渲染方案,把“能跑脚本的 HTML”限制在一个独立空间里,页面主体不受影响,脚本能跑,但不能越界。顺带用 Doom 这种自带交互、自带键盘事件、自带 Canvas 渲染的经典游戏来证明沙箱内的能力足够支撑一个完整游戏。
看完这个项目,你可以把它用在很多地方。比如写技术文档时,嵌入一个可以实时调试的代码块;做算法教程时,放一个可以拖拽参数的可视化图表;甚至做内部工具导航页,把各个小工具都用沙箱 iframe 包起来。本文会从项目能力、部署方式、沙箱隔离机制、功能验证、接口调用和排错清单几个方面展开,尽量把“它到底在做什么”和“我自己能怎么跑起来”这两件事说清楚。
如果你平时写 Markdown 比较多,或者在做文档平台、前端工具链、内容安全相关的方向,这篇文章可以直接收藏。
1. 核心能力速览
在深入了解之前,先看这张速览表,判断它适不适合你现在手上的场景。
| 能力项 | 说明 |
|---|---|
| 项目类型 | Markdown 渲染增强 / 沙箱执行容器 / 前端实验工具 |
| 核心机制 | 在 Markdown 文档中嵌入受控 HTML + JavaScript,用沙箱隔离执行环境 |
| 演示亮点 | 文档内可运行 Doom 游戏(受性能限制,属于技术验证型演示) |
| 支撑技术 | iframe sandbox 属性、srcDoc、CSP、ES Module、WebAssembly(视具体实现版本而定) |
| 硬件门槛 | 无需 GPU,普通 CPU 即可;浏览器建议使用最新版 Chrome/Edge/Firefox |
| 依赖环境 | 通常需要 Node.js 环境,或直接使用浏览器预览;具体版本以 README 为准 |
| 启动方式 | 本地开发服务预览 / 构建产物导出 / 作为组件库接入现有项目 |
| 是否支持 API | 大概率提供渲染服务接口,具体路径与参数需按项目 README 确定 |
| 是否支持批量任务 | 可以批量转换多个 Markdown 文件,但输入输出规范需要自行封装 |
| 典型场景 | 交互式技术文档、在线编辑器、内部知识库、前端安全沙箱测试 |
需要注意:这个项目的定位不是“Markdown 编辑器”,而是“让 Markdown 具备可交互、可运行代码、可安全承载第三方内容”的渲染基础设施。所以你在验证它的时候,重点不是看排版,而是看隔离性和交互性。从标题里的 “Sort Of” 也能看出来,Doom 跑起来了,但不是让你把它当成游戏平台,而是美术、音频、输入、Canvas、脚本执行这一套链路在沙箱里能完整跑通。
2. Markdown 内嵌 HTML 的痛点与沙箱必要性
2.1 直接用 HTML 有什么风险
Markdown 支持内联 HTML 是一个很方便的功能。GitHub 渲染 README 时会渲染表格、图片、引用块,也会放行部分 HTML 标签。但如果你写的是<script>alert(document.cookie)</script>,在 GitHub 上会被过滤掉,因为它的渲染节点不是普通浏览器页面,而且有严格的过滤策略。
问题出在自建文档站和在线编辑器上。很多自建系统直接用dangerouslySetInnerHTML或者v-html渲染 Markdown 转 HTML 的结果,这时候内联 HTML 里的脚本就拥有了顶层页面的完整权限。它可以:
- 读取页面内所有 DOM 节点和表单内容
- 取得当前域下的 Cookie、localStorage
- 调用当前页面的接口并携带身份凭证
- 在页面里伪造登录框,诱导用户输入
也就是说,一个用户提交的 Markdown 内容,如果服务端不过滤、前端不隔离,就等于给了别人在你的域名下任意执行脚本的权限。这在开源文档平台、评论系统、多人协作编辑器里都是高危问题。
2.2 沙箱化方案怎么解决
这个项目的核心思路是:不拦截 HTML,而是把 HTML 放进一个受限的执行容器里。最直接的做法是 iframe + sandbox 属性,让内部脚本拥有独立执行上下文,隔离掉顶层页面的 DOM、Cookie 和 Storage 访问能力。
<iframe sandbox="allow-scripts" srcdoc="<div id='app'></div><script>document.getElementById('app').innerText='hello'</script>" ></iframe>这个组合很典型:srcdoc属性可以直接把一段 HTML 字符串灌进 iframe,不需要额外生成文件;sandbox限制了脚本的执行边界。注意这里只给了allow-scripts,意味着不能弹窗、不能提交表单、不能与父页面同源互通。如果脚本想获取父页面的 localStorage,直接被拒绝。
从效果上看,一个带<canvas>的游戏、一个可拖拽的图表组件、一个需要跑几十行 JS 的交互演示,都能在这个沙箱里工作。用户看到的体验几乎和在真实页面里一样,但页面主体是安全的。这个方案的权衡点在于:iframe 沙箱能充分隔离 DOM,但如果脚本内部要做性能密集计算,iframe 的方案会有一定开销。这也是后来 WebAssembly 沙箱逐渐被讨论的原因。不过对于 Markdown 文档中的交互组件来说,iframe 方案在易用性、浏览器兼容性和维护成本上仍然是第一选择。
3. 适用场景与安全边界
3.1 适合谁用
这个项目最适合以下四类人:
- 技术文档作者。想在自己的 VuePress、Docusaurus、VitePress 站点里加入交互示例,又不想专门开发组件库,可以直接在 Markdown 里写 HTML + JS 片段。
- 在线 Markdown 编辑器开发者。编辑器只负责编辑和预览,预览区用沙箱 iframe 包裹,用户的 HTML 再乱也不会影响编辑器主进程。
- 前端安全测试人员。用这个项目来快速验证某段脚本在沙箱内是否能执行、能否以某种方式逃逸、CSP 配置是否能生效。
- 企业内部知识库建设者。在内网部署一个 Markdown 文档中心,允许团队上传带交互的文档,但不希望内网文档里的脚本直接访问公司内部系统。
3.2 不适合什么场景
它不适合用来做高度敏感的生产级渲染。比如银行页面、支付页面里嵌入的 HTML 内容,不应该只依赖前端沙箱,后端还必须做内容过滤和校验。因为沙箱只能约束脚本运行环境,不能约束内容本身的合规性。另外,如果嵌入的组件需要频繁访问顶层页面的状态、需要读写 Cookie、需要与父页面做复杂通信,那 iframe 沙箱会带来很多跨域通信成本。
还有一点要注意:iframe 沙箱不能抵御所有的浏览器漏洞。如果用户用的是非常老旧的浏览器,沙箱本身的逃逸漏洞理论上也存在。团队内部使用建议统一升级浏览器版本。
3.3 安全与合规提醒
如果你要把这个项目用于内容平台或多人协作环境,务必遵守以下几点:
- 不加载来源不明的外部脚本。沙箱内的代码虽然不是顶层权限,但过度自由的外部脚本仍可能发起网络请求、占用大量 CPU、做挖矿或跟踪行为。
- 嵌入第三方版权素材前确认授权。比如嵌入 Doom 时,Doom 的引擎代码是 GPL 协议,传播和再发布需要保留版权声明并遵循 GPL 条款;如果替换成其他商业游戏素材,问题还会更复杂。
- 涉及用户上传内容时做好隐私隔离。沙箱隔离的是脚本,不是数据流。如果某段脚本能接收到用户输入,仍然要警惕恶意内容被拿去钓鱼或诈骗。
- 不要以为“沙箱了就能免审查”。沙箱是减轻风险的机制,不是免责声明。任何对外发布的内容都应该有人工审核流程。
4. 环境准备与部署启动
4.1 环境准备
这类前端实验项目的环境通常不复杂。以下是一个通用检查清单,具体版本以项目的 README 为准:
| 检查项 | 建议要求 |
|---|---|
| Node.js | LTS 版本,比如 18 或 20 |
| 包管理器 | npm / pnpm / yarn,任选 |
| 浏览器 | 最新版 Chrome、Edge、Firefox |
| 磁盘空间 | 500MB 以内即可(依赖和构建缓存) |
| GPU | 不需要 |
| 端口 | 预留 5173、3000 或 8080 |
项目本身不需要数据库,也基本不依赖原生模块,所以安装失败的概率比较低。最常见的环境问题就是 Node 版本太低导致 Vite 或者依赖安装报错,建议先确认 Node 版本。
4.2 安装部署
因为不知道作者发布的具体仓库地址,这里给一套通用的克隆和启动模板,实际操作时替换成你自己的仓库地址即可。
# 拉取项目代码 git clone <项目仓库地址> cd <项目目录> # 安装依赖 npm install # 启动开发服务 npm run dev启动后终端会输出一个本地访问地址,通常是http://localhost:5173或者http://localhost:3000,打开就能看到演示页面。如果端口被占用,Vite 这类工具一般会自动换个端口,或者你手动指定端口:
# 手动指定端口启动 npm run dev -- --port 8899如果项目提供了后端渲染服务,通常也会有一个单独的启动脚本,例如:
# 构建并启动服务 npm run build npm run start此时服务会监听某个端口,提供 Markdown 转 HTML 或 HTML 转沙箱 iframe 的 API 能力。由于项目可能只是一个前端 Demo,没有后端服务,所以在动手之前先打开 package.json 看一眼 scripts 部分,确认里面有哪些可用的命令。
5. 沙箱隔离机制与 Doom 演示原理
5.1 sandbox 属性怎么工作
iframe 的sandbox属性是整个项目的安全地基。它的取值方式比较像白名单:默认不启用任何能力,必须显式放行。
| 属性值 | 作用 |
|---|---|
allow-scripts | 允许执行脚本 |
allow-same-origin | 允许保持同源,可访问 localStorage、Cookie,需谨慎使用 |
allow-forms | 允许提交表单 |
allow-popups | 允许打开弹窗 |
allow-modals | 允许使用 alert、confirm 等对话框 |
allow-pointer-lock | 允许锁定鼠标指针,游戏场景常用 |
allow-top-navigation | 允许导航到顶层页面,一般不建议启用 |
在 Markdown 渲染场景里,推荐的最小可运行配置是allow-scripts。如果组件里需要绘制图表、跑动画、做游戏操作,可以再叠加allow-pointer-lock。一定不要随手把allow-same-origin加上,因为当allow-scripts和allow-same-origin同时存在时,沙箱内的页面可以把自己当成同一个源,绕过部分隔离机制,比如尝试访问父页面的 DOM,这就基本失去了隔离意义。
5.2 Doom 是怎么在文档里跑起来的
Doom 是 1993 年 id Software 发布的经典第一人称射击游戏,后来其引擎代码以 GPL 协议开源。社区里有很多 Web 移植版本,通过 Emscripten 把 C/C++ 引擎编译成 JavaScript 或 WebAssembly,在浏览器里用 Canvas 渲染画面,用键盘监听玩家输入。
这个项目把这类移植版装进了 Markdown 生成的 iframe 里。渲染流程可以理解为:
- Markdown 编写者写一个带特殊标记的 HTML 块
- 渲染器把它提取出来,填充到 iframe 的
srcdoc属性中 - iframe 开启
sandbox="allow-scripts allow-pointer-lock",加载游戏引擎代码 - 玩家在文档里点击游戏区域,进入全键盘操控状态,通过方向键移动、空格键射击
“Sort Of”这个表述很诚实。它不是在 Markdown 里跑一个 60 帧、带完整声卡模拟的完美 Doom,而是跑了一个可以交互、画面能出、操作能响应的代表性版本。这已经足够证明沙箱内不仅能跑业务脚本,还能跑重度的 Canvas + 实时输入类应用。
5.3 CSP 与沙箱的配合
除了 iframe 自带的 sandbox 属性,CSP(内容安全策略)也是安全加固的重要一环。如果你的文档站是一个 Vue 或 React 应用,可以在页面响应头里加上:
Content-Security-Policy: frame-src 'self'; script-src 'self';这会让浏览器只允许加载同源 iframe,外部网站无法在你的页面里嵌入恶意 iframe。就算沙箱内部脚本要被加载,也必须在允许的域名范围内。把 CSP 和 iframe sandbox 组合使用,能显著提升整体安全等级。
6. 功能测试与效果验证
部署完成之后,不要急着看效果,先按下面这套步骤做功能验证。这样才能确认沙箱真的在起作用,而不只是“看起来能跑”。
6.1 基础嵌入测试
测试目的:确认普通的 Markdown 文本和 HTML 组件可以混排渲染。
在 Markdown 文件中加入下面这段内容:
# 沙箱测试 这是一段普通文本,下面是一个受控 HTML 组件: <div style="border:1px solid #ccc;padding:16px;border-radius:8px"> <p style="color:#c00">这段内容来自 HTML 组件</p> <button onclick="document.body.style.background='#eee'">点击改变背景</button> </div>预期结果:页面正常渲染出带边框的卡片,点击按钮后卡片内部背景发生变化,但是页面主体的背景不动。
如果点击后整个页面背景都变了,说明组件跑在顶层页面而不是沙箱中,需要检查 iframe 包裹层是否正确。
6.2 脚本隔离测试
测试目的:验证沙箱内的脚本无法读取顶层页面的 Cookie、localStorage,也无法修改父页面 DOM。
在一份带浏览器环境模拟的 Markdown 文档里嵌入以下内容:
<script> try { console.log('localStorage:', window.top.localStorage); window.top.document.body.innerHTML = 'hacked'; } catch (e) { console.log('sandbox blocked:', e.message); } </script>预期结果:控制台输出sandbox blocked之类的错误提示,页面主体内容没有被篡改。
如果脚本成功读取了顶层 localStorage,说明allow-same-origin配置不当,或者根本没有走沙箱 iframe。
6.3 交互组件测试(Doom 演示)
测试目的:验证沙箱内可以稳定处理键盘和鼠标事件,Canvas 渲染流畅度可以接受。
打开项目提供的 Doom 演示页面,点击游戏画面,移动鼠标或键盘控制角色。判断标准是:
- 画面能渲染出室内场景和怪物,不花屏
- 键盘操作有响应,转身和移动不出现明显延迟
- 浏览器标签页长时间运行时内存占用稳定,没有暴涨
- 关闭游戏后 CPU 使用率能降到正常水平
如果游戏无法获得鼠标焦点或键盘输入,大概率是缺少allow-pointer-lock配置。如果画面能出但操作延迟明显,可以尝试在 iframe 的allow属性里加入autoplay,或者在生成代码时降低画布分辨率。
6.4 资源占用观察
在浏览器开发者工具里打开 Performance Monitor(按Ctrl+Shift+P搜索 Performance Monitor)或使用 Chrome 自带的任务管理器(Shift+Esc),观察运行一个或多个沙箱组件时的 CPU 和内存变化。
重点看两个数据:
- 多个 iframe 并存在页面中时,每个 iframe 的独立进程占用多少内存
- 运行 Doom 这类 Canvas 应用时,GPU 进程的 CPU 占用是否过高
如果发现大量 iframe 导致页面卡顿,可以先减少同时渲染的组件数量,或给 iframe 添加loading="lazy",让滚动到可视区域时才加载。
6.5 批量渲染测试
准备一个目录,里面放多份 Markdown 文件,每份都嵌入一个沙箱 HTML 组件。批量转换后检查:
- 每个文件的 HTML 组件是否正确生成独立 iframe
- iframe 的 sandbox 属性是否保持一致
- 是否存在因为某个文件内容不合法导致整批构建失败的情况
批量任务建议写一个简单脚本循环处理,输出日志里保留每个文件的状态,方便定位出错文件。
7. 接口 API 与批量任务
7.1 渲染服务接口
如果项目提供了渲染服务,通常会有一个接收 Markdown 内容并返回安全 HTML 的接口。下面是一个通用模板,具体路径和参数需要对照项目的 README 调整:
import requests url = "http://127.0.0.1:8899/api/render" payload = { "markdown": """ # 示例 <iframe sandbox="allow-scripts" srcdoc="<script>document.body.innerHTML='ok'</script>"></iframe> """, "options": { "sandbox": "allow-scripts", "csp": "frame-src 'self'" } } response = requests.post(url, json=payload, timeout=30) print(response.status_code) print(response.json().get("html"))核心思路是把 Markdown 文本发给渲染服务,服务端解析并生成 HTML,替换其中的安全 iframe 配置,然后返回结果。接口超时时间建议设置得长一些,因为首次渲染可能要加载依赖资源。
7.2 批量转换脚本
批量处理一批 Markdown 文档时,可以在 Python 脚本里做目录遍历和失败重试:
import json import logging import requests from pathlib import Path logging.basicConfig( level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s", filename="render_batch.log" ) input_dir = Path("./md_files") output_dir = Path("./html_output") output_dir.mkdir(exist_ok=True) render_url = "http://127.0.0.1:8899/api/render" for md_file in input_dir.glob("*.md"): try: content = md_file.read_text(encoding="utf-8") payload = {"markdown": content, "options": {"sandbox": "allow-scripts"}} response = requests.post(render_url, json=payload, timeout=30) response.raise_for_status() html = response.json().get("html") out_file = output_dir / f"{md_file.stem}.html" out_file.write_text(html, encoding="utf-8") logging.info(f"OK: {md_file.name}") except Exception as exc: logging.error(f"FAIL: {md_file.name} - {exc}")批量任务有三个工程要点:
- 输出文件名要可追溯,用源文件名保留对应关系
- 每处理一个文件就写一条日志,失败时能快速定位
- 对异常文件做单独重试,不要中断整批
7.3 接口调用失败排查
接口最可能出的问题是参数名不匹配。很多项目的接口字段不是markdown,而是content或text;接口路径也可能不同。先打开项目的 README 找接口文档,或者用浏览器开发者工具抓一次官方演示页面的请求,看它实际传了什么字段,再照着写。
8. 常见问题与排查方法
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 沙箱内脚本不执行 | sandbox 属性缺少 allow-scripts | 检查 iframe 的 sandbox 属性 | 在 sandbox 白名单中加入 allow-scripts |
| 沙箱内脚本读到了顶层 Cookie | 同时启用了 allow-scripts 和 allow-same-origin | 检查 iframe 属性组合 | 去掉 allow-same-origin,保持最小权限 |
| 页面整体样式错乱 | Markdown 渲染器把 iframe 当成普通标签,处理方式不兼容 | 查看最终 HTML 输出 | 改用组件化渲染器,将 iframe 交给前端组件渲染 |
| 点击按钮后郭整个页面背景变了 | 脚本没有进入 iframe,直接在顶层执行 | 检查 iframe 是否真的包裹了代码 | 确认 srcdoc 或 src 属性配置正确 |
| Doom 画面能出但无法移动 | 缺少指针锁定权限 | 检查 iframe sandbox 和 allow 属性 | 添加 allow-pointer-lock,并让用户点击游戏区域触发锁 |
| Doom 掉帧明显 | 低配机器 + Canvas 高分辨率渲染 | 打开性能监控面板 | 降低游戏渲染分辨率,减少同屏并行的 iframe 数量 |
| 接口返回 404 | 接口路径或请求方法不对 | 查看服务路由日志和 README | 按 README 调整 URL 和 method |
| 批量任务中途失败 | 某个 Markdown 文件内容不合法 | 查看批量日志,定位失败文件 | 单独渲染该文件,逐段删除定位错误片段 |
| npm install 报错 | Node 版本过低或网络问题 | 执行 node -v 查看版本 | 升级 Node 版本,或者更换 npm 镜像 |
9. 最佳实践与使用建议
9.1 安全配置要最小权限
在配置 sandbox 时,坚持“能不开就不开”的原则。绝大多数交互组件只需要allow-scripts,不要让用户随意往 iframe 里加权限。如果某些高级组件确实需要弹窗或表单,再逐项放行,并且做好使用说明。
9.2 内容分级与审核
Markdown 里的 HTML 虽然有沙箱保护,但内容本身仍然可能包含不适合公开发布的信息。尤其是内网知识库或社区文档,建议建立内容审核流程:先沙箱预览、再人工确认、最后发布。发布后的内容如果被用户举报,要有快速下线机制。
9.3 组件物料目录化
如果团队长期使用这个方案,建议把常见的交互组件封装成模板目录。每个组件包含三部分:
- Markdown 源文件:方便编写和阅读
- 沙箱 HTML 模板:固定 iframe 属性和 CSP 头
- 参数说明文档:标注哪些配置项可以调、哪些不建议动
这样团队成员写文档时不需要理解沙箱细节,只需要选择合适的组件模板。
9.4 离线与内网部署
这个项目本身不需要 GPU,也不需要大规模模型文件,部署到内网非常方便。只要把前端构建产物放上静态服务器,或者用 Docker 封装一个渲染服务,团队内部就能使用。内网部署时建议把端口绑定到127.0.0.1或限定访问 IP,不要让渲染接口暴露在公网。
9.5 合规提醒
如果你不满足于跑演示,而是要在自己的项目里长期使用,务必注意:
- 遵守项目自身的开源协议。如果是 GPL 系协议,你的衍生项目也可能需要开源。
- 嵌入 Doom 等经典游戏素材时,保留原始版权声明和许可证文件。如果要做商业化产品,优先替换成自绘或可商用素材。
- 在社区或博客中发布嵌入脚本的内容时,明确标注组件来源,避免被误认为原创。
- 涉及人脸、声音、个人信息的任何嵌入功能,都要先确认授权链条完整。
10. 总结与下一步
这个项目值得一试的核心点,是把“Markdown 能内嵌 HTML”这个老功能,升级成了“Markdown 能安全地运行不受信任的交互代码”。它用 Doom 做演示不是为了炫技,而是为了用最直观的方式告诉你沙箱内的能力边界在哪里:能渲染复杂画面、能捕获用户输入、能跑游戏逻辑,同时不影响页面主体。
第一次尝试时,建议先跑通官方的 Doom 演示,确认沙箱环境完整。然后自己写一个最简单的<div>组件,测试样式和基础脚本。最后再加成长一点的交互逻辑,比如接一个表单、画一个图表,感受一下沙箱的边界。最容易踩的坑是 sandbox 权限配置写多了,导致隔离失效,所以每加一个权限都要问自己是否真的需要。
后续可以往这几个方向延伸:把这个渲染层封装成 VitePress 或 Docusaurus 插件,让写文档的人可以通过一个简单的代码块标记获得沙箱组件能力;把渲染服务做成批量转换工具,结合 CI/CD 在构建文档站点时自动生成安全组件;还可以接入 LLM,让 AI 根据自然语言生成 Markdown 内嵌的交互组件,再通过沙箱直接渲染预览。这套“沙箱 + Markdown + 交互组件”的组合,比较适合作为下一代文档平台的基础设施方向。建议收藏备用,后面做文档站或在线编辑器时直接用得上。