Pascal Editor 开源 3D 建筑编辑器:MCP 服务器暴露出去前的风险面梳理
本文基于 Pascal Editor 仓库 commit 64dca3d(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/pascalorg/editor 最新代码与文档为准。
Pascal Editor 的 MCP 服务器不是一个”让模型读一读你的图”的只读接口——它同时握着三样权限:改动场景图、往你机器上的一个 SQLite 文件里写数据、按工具入参发起出网请求。它的默认姿势是只听本机回环地址,而你一旦把这个默认值改掉,风险面就整体换了一档。 这篇要做的事,就是把这三样权限分别对应到仓库里的哪个文件、拦在哪一步、以及哪些事它根本不管。
先做一次消歧:这里说的 Pascal Editor,是 GitHub 上 pascalorg/editor 这个开源项目,一个用 React Three Fiber 和 WebGPU 做的 3D 建筑编辑器(仓库 README 这样定位自己),许可证是 MIT(Copyright 2026 Pascal Group Inc.)。它跟 Pascal 编程语言没有任何关系,也跟压强单位帕斯卡没关系。你可以把它理解成”能在浏览器里画房子、并且开了一个 MCP 口子让 Agent 直接来画”的编辑器。
站内已有几篇相邻的文章,分工先说清楚:MCP 的通用安全边界讲的是协议层面通用的威胁模型,API Key 的安全管理讲凭据本身怎么存怎么轮换,把数据交给 AI 的风险面讲数据流向的合规判断;本篇不重复这三件事,只做一件它们做不了的事——把一个具体开源项目的防护代码逐层拆开,让你能对着文件路径自己复核。
一、先搞清楚:这套东西里谁在动你的场景
Pascal Editor 是个 Turborepo 单体仓库。你在根目录 ls 一下就能数出来:apps/ 下 2 个应用,packages/ 下 9 个包。跟本篇相关的只有两侧——packages/mcp(MCP 服务器与场景存储适配)和 apps/editor(Next.js 编辑器应用)。
它们操作的公共对象叫场景图(scene graph):一棵由节点组成的树,每个节点是建筑里的一个东西。节点类型有多少种,你自己数得出来——packages/nodes/src/ 下有 46 个子目录,其中 shared/ 装的是公用代码不算类型,所以是 45 种节点类型。名字大致能猜到用途,但有几个对非建筑背景的读者需要一句话解释:
wall是墙,slab是楼板(一层的水平板,既是这层的地面也是下层的顶面),level是楼层。zone是分区,用来圈出”这块区域是卧室、那块是厨房”这样的功能划分。roof和roof-segment是屋面与屋面分块,stair/stair-segment是楼梯及其分段。duct-segment/pipe-segment/hvac-equipment这类是机电专业的风管、水管、暖通设备。
MCP 服务器暴露出来的工具,就是对这些节点的增删改查。从 packages/mcp/src/tools/ 下各文件的 registerTool 调用里能直接读到工具名,比如 create_wall(建墙)、create_room(建房间)、add_door 和 add_window(加门窗)、cut_opening(在墙体上开洞,门窗要落位就得先在墙上挖出一个洞口)、place_item(放家具设备)、set_zone(设分区)、create_roof、create_stair_between_levels、undo / redo、delete_node、delete_scene。
工具列表里还挂着一个 export_glb(GLB 是一种把模型、材质、贴图打包进单个二进制文件的三维交换格式),但读一眼 packages/mcp/src/tools/export-glb.ts 就知道它在无头模式下并不真干活:输出 schema 被写死成 not_implemented,理由是 GLB 导出依赖 Three.js 渲染器、只能在浏览器里跑。这件事跟安全的关系在于——别看见工具名就假设它的能力边界,同理也别看见一个不熟的工具名就假设它没能力。工具清单只是声明,实际做什么得回文件里读。
注意最后两个。Agent 手上有删除权限,删的是节点,也能删整个已保存的场景。这不是设计缺陷,是”让 Agent 直接建模”这件事的必然代价,但你得知道它存在。
数据落在哪也要说清。packages/mcp/src/storage/index.ts 里的工厂函数注释写得很直白:默认写到 ~/.pascal/data/pascal.db,PASCAL_DB_PATH 指定确切文件路径,PASCAL_DATA_DIR 指定放 pascal.db 的目录。这是本地优先的设计——没有外部数据库服务,Agent 画的东西直接落在你自己的磁盘上。编辑器那侧和 MCP 那侧共享同一个数据目录时,MCP 的改动会写进 SQLite 并追加一条本地事件,浏览器标签页订阅这条流就能实时看到 Agent 在改什么(packages/mcp/src/tools/live-sync.ts 里的 publishLiveSceneSnapshot 干的就是这件事)。
至于文件系统的暴露面,把 packages/mcp/src 下所有 node:fs 调用数一遍(排除测试文件),一共只有两处:bin/pascal-mcp.ts 里的 readFileSync,读的是 --scene 参数指定的初始场景 JSON,路径由启动命令写死,不经过任何工具入参;storage/sqlite-scene-store.ts 里的 mkdirSync,只做一件事——数据库文件所在目录不存在时把它建出来。除此之外没有别的读写调用。
这个数法值得单独说一句方法论:判断一个 MCP 服务器的文件系统暴露面,比读文档更可靠的是数它源码里的文件系统原语,然后逐个看每个原语的路径参数是谁给的。路径来自启动参数或写死的常量,Agent 就够不着;路径来自工具入参,那就是一个需要单独评估的入口。按这个标准看,Pascal 的 MCP 服务器不给 Agent 提供”读写任意路径”的原语——Agent 能改的落盘对象,就是那一个 SQLite 库。
二、出网这一层:safe-fetch 挡的是什么
SSRF(服务端请求伪造)说白了就一句话:你的服务替攻击者去访问了攻击者自己够不着的地址。典型目标是云厂商的实例元数据接口,只有从机器内部访问才拿得到,一拿到往往就是临时凭据。
Pascal 的 MCP 服务器有三个工具会接受用户给的图片 URL:photo_to_scene、analyze_floorplan_image、analyze_room_photo。这三处会不会成为 SSRF 入口,是很实在的问题——它们的入参 image 描述就是”Base64-encoded image or http(s) URL”,Agent 完全可能把一个从别处读来的字符串直接塞进去。
packages/mcp/src/lib/safe-fetch.ts 就是拦这一层的。它的头部注释把来龙去脉写得很坦白,原文提到 Phase 10 A2 阶段发现这三个工具当时调的是裸 fetch(url),没有任何保护,等于在任何宿主机上给了一个直连元数据地址的原语。现在的实现替换成了 safeFetch,三处调用点用 grep 一搜就能核对(都在各自文件里动态 import 后调用,并带上 accept: 'image/*')。
它挡的东西按代码读是这些:
- 协议:
assertAllowedUrl只放行http:和https:,其它 scheme 抛url_scheme_not_allowed。 - IPv4 私网与保留段:
isPrivateOrLoopbackV4逐段判断,127.0.0.0/8回环、10.0.0.0/8、172.16.0.0/12、192.168.0.0/16私网、169.254.0.0/16链路本地(注释里点名了云元数据的169.254.169.254)、0.x当前网络、以及a >= 224的组播与保留段。解析不出四段合法数字时直接当作不安全,这个”畸形即拒绝”的取向值得学。 - IPv6:
::1、::、fe80:等链路本地前缀、fc/fd开头的 ULA,以及::ffff:开头的 v4 映射地址(映射出来的 v4 会再走一遍 v4 判断)。 - 本地含义的主机名:不是 IP 的时候也拦,
localhost及其子域、broadcasthost、以.local/.internal/.corp结尾的名字。 - 重定向:
fetch用redirect: 'manual',每一跳拿到Location后重新走一遍assertAllowedUrl,跳数由MAX_REDIRECTS常量封顶。这是关键——只在第一跳做校验的实现,会被”公网域名 302 跳到内网地址”轻松绕过。 - 响应体积:先看
Content-Length预判,再在流式读取时累计字节数,超了就reader.cancel()并抛response_too_large。两道一起做,是因为服务端可以在Content-Length上撒谎。 - 超时:
AbortController加一个setTimeout,超时抛fetch_timeout。上限值都写在文件顶部的DEFAULT_MAX_BYTES、DEFAULT_TIMEOUT_MS常量里,调用方可以按SafeFetchOptions覆盖。
还有一个收窄开关:环境变量 PASCAL_ALLOWED_ASSET_ORIGINS,逗号分隔,命中的是 parsed.origin 的精确比对,不在名单里就抛 url_origin_not_allowlisted。如果你的用法里图片只会来自某个固定的对象存储域名,把这个变量配上,出网面立刻从”整个公网”收缩到一个 origin。
这里必须补一句代价:图片抓回来之后并不是本地跑视觉模型。analyze_room_photo 和 analyze_floorplan_image 走的是 MCP 的 sampling 能力——先检查宿主的 caps?.sampling,不支持就返回 sampling_unavailable,支持就把图片作为 base64 块通过 server.server.createMessage 交给宿主去推理。也就是说图片内容最终去哪个模型、经过谁,取决于你的 MCP 宿主怎么配的,不由这个仓库决定。 户型图和房间照片是不是敏感信息,你自己判断。
三、传输这一层:绑非本机地址就强制要令牌
CLI 入口在 packages/mcp/src/bin/pascal-mcp.ts,默认走 stdio;加 --http 才起 HTTP 传输,--port 默认 3917,--host 默认 127.0.0.1,另有 --auth-token 和可重复的 --cors-origin。
真正的闸门在 packages/mcp/src/transports/http.ts 的 connectHttp 开头:
const host = options.host ?? DEFAULT_HOST
const authToken = options.authToken ?? process.env.PASCAL_MCP_HTTP_TOKEN
if (!(isLoopbackHost(host) || authToken)) {
throw new Error(
'HTTP transport on a non-loopback host requires PASCAL_MCP_HTTP_TOKEN or authToken',
)
}
这行判断的意思是:绑回环地址随你,一旦要绑 0.0.0.0 或某个网卡地址,没有令牌就直接启动失败,不是打一条警告日志继续跑。这是个值得称道的取向——把”忘了配令牌”从一个静默的线上事故变成一次启动崩溃。isLoopbackHost 认的是 localhost、.localhost 后缀、127.0.0.1、::1,且会先剥掉端口和 IPv6 方括号。
请求进来之后过 createHttpGuard 返回的守卫函数,顺序是固定的:
- Origin 校验:带了
Origin头且不被允许,直接 403origin_not_allowed。isOriginAllowed的规则是——回环 origin 一律放行;等于请求自身Host的 origin 放行;否则查允许集合(来自--cors-origin或环境变量PASCAL_MCP_HTTP_ORIGINS,都做过normalizeOrigin归一化)。 - 补 CORS 与安全响应头:命中允许集合才回
Access-Control-Allow-Origin并加Vary: Origin;无条件加X-Content-Type-Options: nosniff。 - OPTIONS 直接 204 收尾。
- 路径必须是
/mcp,其它一律 404not_found。没有兜底路由,也就没有顺手暴露出别的东西。 - 令牌校验:从
Authorization: Bearer或自定义头x-pascal-mcp-token取值,用node:crypto的timingSafeEqual比对,长度不等先返回 false。不匹配就 401unauthorized。 - 限速:按
req.socket.remoteAddress分桶,窗口长度是WINDOW_MS,每窗口上限来自DEFAULT_RATE_LIMIT_PER_MINUTE或传入配置,超了回 429rate_limited并带Retry-After。配置成小于等于 0 就关掉。
对你意味着什么?这一层解决的是”别人能不能从网络上直接调你的 MCP 服务器”,它不解决”调用方是谁、能做什么”。令牌只有一个,比对通过就等于拿到全部工具权限,包括 delete_scene。所以它是网络边界,不是权限系统——这两件事的区别,最小权限的设计方法那篇讲得更细。
四、编辑器那一侧:场景接口另有一层校验
很多人会以为”我只管住 MCP 端口就行了”。不对。编辑器应用自己也开着一组场景接口,MCP 与浏览器共享同一个本地数据库时,这组接口就是另一条进场景的路。
apps/editor/lib/scene-api-security.ts 是这层的守卫,被三个路由文件引用:apps/editor/app/api/scenes/route.ts(列表与创建)、apps/editor/app/api/scenes/[id]/route.ts(读、整体保存、改名、删除)、apps/editor/app/api/scenes/[id]/events/route.ts(服务器推送的场景事件流)。三者的每个方法开头都是同一句 guardSceneApiRequest(request),返回非空就直接把这个响应返回去。
它的构成跟 HTTP 传输那层很像——Origin 校验、令牌、限速——但有两处差别值得单独拎出来。
第一处是没配令牌时的行为:
function validateAuth(request: Request): NextResponse | null {
const token = process.env.PASCAL_SCENE_API_TOKEN
if (!token) {
if (isLoopbackRequest(request)) return null
return sceneApiJson(request, { error: 'scene_api_token_required' }, { status: 503 })
}
// ...
}
本机访问放行,非本机访问返回 503 scene_api_token_required。跟传输层”启动即失败”是同一个思路的两种落法:宁可这个接口不可用,也不让它在没有令牌的情况下对外服务。 你要是在服务器上部署编辑器却忘了配 PASCAL_SCENE_API_TOKEN,症状就是这个 503,别去改代码绕过它。
第二处是限速的取 IP 方式:clientIp 先读 x-forwarded-for 的第一段,没有再读 x-real-ip,都没有就记 unknown。这两个头是客户端可以随便写的,只有在你确实把服务放在会覆写这些头的反向代理后面时才可信。直接把端口暴露出去,限速等于按攻击者自报的身份分桶——这不是这个文件的锅,是所有这么取 IP 的服务的共同前提,但你部署时得把这个前提补上。
另外两件跟安全间接相关但很实用的机制:场景保存走乐观并发控制,GET 回 ETag,PUT / PATCH / DELETE 读 If-Match(parseIfMatch 按 RFC 7232 处理强弱 ETag 和通配 *),版本对不上返回 409 version_conflict 并尽量带上 currentVersion;MCP 那侧对应的错误码是 live_sync_version_conflict,遇到就先 load_scene 重新加载再继续。请求体则由 zod schema 校验,超大场景返回 413 too_large,存储层的 DEFAULT_MAX_SCENE_BYTES 常量决定这个门槛。
五、这几块分别在哪、你什么时候会碰到
| 组成部分 | 它负责什么 | 仓库位置 | 你什么时候会碰到它 |
|---|---|---|---|
| 出网请求防护 | 校验用户给的图片 URL,拦私网/回环/链路本地,逐跳复核重定向,限体积与超时 | packages/mcp/src/lib/safe-fetch.ts | 用 photo_to_scene、analyze_floorplan_image、analyze_room_photo 且传的是 http(s) 链接时 |
| HTTP 传输守卫 | 非回环绑定强制令牌、Origin 校验、路径限定 /mcp、定长时间比对、按来源限速 | packages/mcp/src/transports/http.ts | 用 pascal-mcp --http 起服务,尤其是改了 --host 时 |
| CLI 入口 | 解析 --stdio / --http / --port / --host / --auth-token / --cors-origin / --scene | packages/mcp/src/bin/pascal-mcp.ts | 写 MCP 宿主配置、决定启动参数时 |
| 场景接口守卫 | 编辑器侧接口的 Origin、令牌、限速,以及缺令牌时的 503 | apps/editor/lib/scene-api-security.ts | 把编辑器部署到本机以外的地方时 |
| 场景路由 | 场景的增删改查与事件流,zod 校验与 ETag/If-Match 并发控制 | apps/editor/app/api/scenes/route.ts、apps/editor/app/api/scenes/[id]/route.ts、apps/editor/app/api/scenes/[id]/events/route.ts | 排查 400/409/413,或想知道浏览器怎么实时看到 Agent 的改动 |
| 本地场景存储 | SQLite 落盘、路径解析、体积上限、版本冲突 | packages/mcp/src/storage/sqlite-scene-store.ts、packages/mcp/src/storage/index.ts | 关心数据落在哪、要不要纳入备份与清理时 |
| 实时同步 | 把 MCP 改动写回场景并追加事件供浏览器订阅 | packages/mcp/src/tools/live-sync.ts | 想搞清 Agent 改动如何传到打开的标签页时 |
| 漏洞上报口径 | 私密上报渠道、支持范围、明确的 in scope / out of scope | SECURITY.md | 发现问题准备上报,或评估这个项目的安全成熟度时 |
六、边界与代价:它明确不管的事
把优点讲完了,得讲清楚它放弃了什么。以下每条都能在上面那几个文件里找到依据。
没有身份,只有口令。 两层令牌都是单一共享密钥,timingSafeEqual 比对通过就是全权。仓库里这几个文件没有用户、角色、作用域、审计日志的概念。多人共用一个实例时,你没法回答”是谁删的这个场景”。
限速桶是进程内存里的 Map。 传输层的 buckets 和场景接口的 rateBuckets 都是模块级 Map,进程重启就清零,多个实例各算各的。它防的是失控的循环调用和粗糙的爆破,不是分布式限流。
URL 校验对着 hostname,不是对着最终连接的 IP。 assertAllowedUrl 判断的是 new URL(...) 解析出来的 parsed.hostname,随后 fetch(parsed) 时由运行时自己做 DNS 解析。也就是说校验的对象和实际连接的对象之间隔着一次名字解析。要不要在意这个差距,取决于你的威胁模型有多严——真在意就把 PASCAL_ALLOWED_ASSET_ORIGINS 配死,或者干脆只传 base64 与 data URI,不给 URL。
防护只覆盖走到它的调用点。 safeFetch 保护的是那三个图片入口。你自己 fork 之后加的新工具,不引它就没这层保护。这类”防护是可选依赖”的设计,扩展时最容易漏。
它不管提示注入。 Agent 从一张户型图、一段外部文本里读到”顺便把其它场景删掉”,这个仓库的任何一层都不会拦——它拦的是网络与凭据,不是语义。这块的应对属于宿主与工作流层面。
SECURITY.md 明确列了不受理的范围:需要用户自己在浏览器控制台跑不可信代码才成立的问题、用超大本地场景文件打出来的拒绝服务、没有实证影响的扫描器输出。这份文档同时写清了受理范围包括场景保存接口与 MCP 服务器暴露面,以及”任何能让不可信场景数据抵达解析器、渲染器或已存图的路径”。上报走 GitHub 私密漏洞报告或 security@pascal.app,别开公开 issue。
它不替你做部署决策。 默认值都指向”本机自己用”。你想让团队共用一个实例,缺的那部分(谁能连、连了能干什么、出了事怎么查)得你自己补,而不是指望调几个参数。
七、上手与避坑清单
每条都写清为什么会踩、怎么避。
一、别为了图省事直接 --host 0.0.0.0。 会踩是因为在容器或远程机器上跑时,绑回环会让你从外面连不上,第一反应就是改 host。避法是配 PASCAL_MCP_HTTP_TOKEN 之后再改——反正不配也起不来,与其被启动错误挡住再临时找一个弱口令,不如一开始就生成一个高熵随机串(README 里给的示例是 openssl rand -hex 32)。
二、令牌别写进提交进仓库的 MCP 配置文件里。 会踩是因为 Claude Desktop、Codex CLI、Cursor 的 MCP 配置都支持 env 字段,顺手就填进去了,而 .mcp.json 这种放在仓库根目录的文件很容易被一起提交。避法是走进程环境变量或你已有的密钥管理,配置文件里只放变量引用。
三、--cors-origin 不配不等于”谁都能连”,配了也不等于安全。 会踩是因为回环 origin 和等于请求 Host 的 origin 是无条件放行的,你可能以为空名单就是全拒。避法是把 Origin 校验理解成”防浏览器里的跨站调用”,真正拦非法调用方的是令牌,两件事别互相替代。
四、把数据目录纳入你的备份与清理流程。 会踩是因为 ~/.pascal/data/pascal.db 藏在家目录里,既不在项目里也不在你熟悉的备份路径上,Agent 画了一周的东西可能就在那儿孤零零躺着。避法是显式设 PASCAL_DATA_DIR 指到你自己管的目录,并且清楚这里面会累积场景快照和事件流。
五、编辑器与 MCP 要共享同一个数据目录才谈得上实时同步。 会踩是因为两侧默认值一样,本机开发时看起来”自动就通了”,一换环境(比如编辑器在容器里、MCP 在宿主机)就断,且不报错,只是浏览器什么都不动。避法是两侧都显式设同一个 PASCAL_DATA_DIR,并把它当成一项部署前置检查。
六、遇到 live_sync_version_conflict 不要重试硬写。 会踩是因为版本冲突的直觉反应是再试一次,而这个错误的含义是”别人已经存了更新的版本”,硬写会把浏览器里刚做的编辑覆盖掉。避法是按 README 说的先 load_scene 重新加载再继续。
七、给 Agent 的任务里预设好删除类工具的用法。 会踩是因为 delete_node、delete_scene 跟建墙加窗一样只是工具列表里的一项,模型不会天然对它们更谨慎。避法是在系统提示或工作流里明确约束,并利用 undo / redo 的存在感——但要清楚撤销栈不是备份,跨会话别指望它。
八、部署到代理后面时,确认代理真的覆写了 x-forwarded-for。 会踩是因为场景接口的限速直接信这个头的第一段,代理没覆写的话客户端自己写一个随机值就能绕开分桶。避法是在代理配置里显式设置,而不是透传。
收尾:五分钟自检
把这套东西接进你的 Agent 工作流之前,对着下面五条过一遍,每条都能在仓库里核实:
- 传输方式确认了吗——stdio 还是 HTTP,HTTP 的 host 绑的是什么,令牌从哪来。
- 会不会用到那三个吃图片 URL 的工具,用的话
PASCAL_ALLOWED_ASSET_ORIGINS要不要配死。 - 图片经 sampling 交给宿主模型这条路径,在你的数据合规口径里过得去吗。
PASCAL_DATA_DIR指到哪,谁能读那个 SQLite 文件,要不要备份。- 编辑器应用有没有一起对外,有的话
PASCAL_SCENE_API_TOKEN配了没。
接下来该读哪个文件,按你的角色分:只把它当 MCP 服务器用,读 packages/mcp/README.md(宿主配置、本地存储、实时更新、坐标约定都在里面,坐标那节尤其值得看——它讲清了平面坐标到世界坐标的映射,以及为什么从外部算好的布局送进来可能是转过角度的);要自建工具或改造,读 packages/mcp/src/tools/ 下跟你目标最近的那个文件加它旁边的 .test.ts;关心架构约束,读 wiki/architecture/,那里有 20 份 md,一份 README 索引加 19 篇分主题;README 用一张表逐条说明每篇覆盖什么,表里第一条就是 layers.md,从它和 tools.md 起步是比较省力的读法。仓库根目录同时放着 AGENTS.md、CLAUDE.md、GEMINI.md 三份 Agent 约定文件,按 AGENTS.md 自己的说明,后两份是指向它的符号链接——你派 Agent 去改这个仓库之前,先让它读这一份。
想把这类”逐层拆开一个开源项目的防护代码”的方法用到别的项目上,可以接着看开源项目的选型与评估方法。
本篇属于一个把开源3D 建筑编辑器 Pascal Editor逐层拆开讲的系列,整体地图见 Pascal Editor 是什么:浏览器里的开源 3D 建筑编辑器,与它自带的建模 MCP 服务器;沿着这条线往下,还可以看 Pascal Editor 三维建筑编辑器的 Agent 实时同步链路 和 Pascal Editor 3D 建筑编辑器排障:显卡回退与报错分类。