Pascal Editor 开源 3D 建筑编辑器:MCP 服务器暴露出去前的风险面梳理

2026-08-05

本文基于 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 是分区,用来圈出”这块区域是卧室、那块是厨房”这样的功能划分。
  • roofroof-segment 是屋面与屋面分块,stair / stair-segment 是楼梯及其分段。
  • duct-segment / pipe-segment / hvac-equipment 这类是机电专业的风管、水管、暖通设备。

MCP 服务器暴露出来的工具,就是对这些节点的增删改查。从 packages/mcp/src/tools/ 下各文件的 registerTool 调用里能直接读到工具名,比如 create_wall(建墙)、create_room(建房间)、add_dooradd_window(加门窗)、cut_opening(在墙体上开洞,门窗要落位就得先在墙上挖出一个洞口)、place_item(放家具设备)、set_zone(设分区)、create_roofcreate_stair_between_levelsundo / redodelete_nodedelete_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.dbPASCAL_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_sceneanalyze_floorplan_imageanalyze_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/8172.16.0.0/12192.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 结尾的名字。
  • 重定向fetchredirect: 'manual',每一跳拿到 Location 后重新走一遍 assertAllowedUrl,跳数由 MAX_REDIRECTS 常量封顶。这是关键——只在第一跳做校验的实现,会被”公网域名 302 跳到内网地址”轻松绕过。
  • 响应体积:先看 Content-Length 预判,再在流式读取时累计字节数,超了就 reader.cancel() 并抛 response_too_large。两道一起做,是因为服务端可以在 Content-Length 上撒谎。
  • 超时AbortController 加一个 setTimeout,超时抛 fetch_timeout。上限值都写在文件顶部的 DEFAULT_MAX_BYTESDEFAULT_TIMEOUT_MS 常量里,调用方可以按 SafeFetchOptions 覆盖。

还有一个收窄开关:环境变量 PASCAL_ALLOWED_ASSET_ORIGINS,逗号分隔,命中的是 parsed.origin 的精确比对,不在名单里就抛 url_origin_not_allowlisted。如果你的用法里图片只会来自某个固定的对象存储域名,把这个变量配上,出网面立刻从”整个公网”收缩到一个 origin。

这里必须补一句代价:图片抓回来之后并不是本地跑视觉模型。analyze_room_photoanalyze_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.tsconnectHttp 开头:

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 返回的守卫函数,顺序是固定的:

  1. Origin 校验:带了 Origin 头且不被允许,直接 403 origin_not_allowedisOriginAllowed 的规则是——回环 origin 一律放行;等于请求自身 Host 的 origin 放行;否则查允许集合(来自 --cors-origin 或环境变量 PASCAL_MCP_HTTP_ORIGINS,都做过 normalizeOrigin 归一化)。
  2. 补 CORS 与安全响应头:命中允许集合才回 Access-Control-Allow-Origin 并加 Vary: Origin;无条件加 X-Content-Type-Options: nosniff
  3. OPTIONS 直接 204 收尾
  4. 路径必须是 /mcp,其它一律 404 not_found。没有兜底路由,也就没有顺手暴露出别的东西。
  5. 令牌校验:从 Authorization: Bearer 或自定义头 x-pascal-mcp-token 取值,用 node:cryptotimingSafeEqual 比对,长度不等先返回 false。不匹配就 401 unauthorized
  6. 限速:按 req.socket.remoteAddress 分桶,窗口长度是 WINDOW_MS,每窗口上限来自 DEFAULT_RATE_LIMIT_PER_MINUTE 或传入配置,超了回 429 rate_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 的服务的共同前提,但你部署时得把这个前提补上。

另外两件跟安全间接相关但很实用的机制:场景保存走乐观并发控制,GETETagPUT / PATCH / DELETEIf-MatchparseIfMatch 按 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.tsphoto_to_sceneanalyze_floorplan_imageanalyze_room_photo 且传的是 http(s) 链接时
HTTP 传输守卫非回环绑定强制令牌、Origin 校验、路径限定 /mcp、定长时间比对、按来源限速packages/mcp/src/transports/http.tspascal-mcp --http 起服务,尤其是改了 --host
CLI 入口解析 --stdio / --http / --port / --host / --auth-token / --cors-origin / --scenepackages/mcp/src/bin/pascal-mcp.ts写 MCP 宿主配置、决定启动参数时
场景接口守卫编辑器侧接口的 Origin、令牌、限速,以及缺令牌时的 503apps/editor/lib/scene-api-security.ts把编辑器部署到本机以外的地方时
场景路由场景的增删改查与事件流,zod 校验与 ETag/If-Match 并发控制apps/editor/app/api/scenes/route.tsapps/editor/app/api/scenes/[id]/route.tsapps/editor/app/api/scenes/[id]/events/route.ts排查 400/409/413,或想知道浏览器怎么实时看到 Agent 的改动
本地场景存储SQLite 落盘、路径解析、体积上限、版本冲突packages/mcp/src/storage/sqlite-scene-store.tspackages/mcp/src/storage/index.ts关心数据落在哪、要不要纳入备份与清理时
实时同步把 MCP 改动写回场景并追加事件供浏览器订阅packages/mcp/src/tools/live-sync.ts想搞清 Agent 改动如何传到打开的标签页时
漏洞上报口径私密上报渠道、支持范围、明确的 in scope / out of scopeSECURITY.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_nodedelete_scene 跟建墙加窗一样只是工具列表里的一项,模型不会天然对它们更谨慎。避法是在系统提示或工作流里明确约束,并利用 undo / redo 的存在感——但要清楚撤销栈不是备份,跨会话别指望它。

八、部署到代理后面时,确认代理真的覆写了 x-forwarded-for 会踩是因为场景接口的限速直接信这个头的第一段,代理没覆写的话客户端自己写一个随机值就能绕开分桶。避法是在代理配置里显式设置,而不是透传。

收尾:五分钟自检

把这套东西接进你的 Agent 工作流之前,对着下面五条过一遍,每条都能在仓库里核实:

  1. 传输方式确认了吗——stdio 还是 HTTP,HTTP 的 host 绑的是什么,令牌从哪来。
  2. 会不会用到那三个吃图片 URL 的工具,用的话 PASCAL_ALLOWED_ASSET_ORIGINS 要不要配死。
  3. 图片经 sampling 交给宿主模型这条路径,在你的数据合规口径里过得去吗。
  4. PASCAL_DATA_DIR 指到哪,谁能读那个 SQLite 文件,要不要备份。
  5. 编辑器应用有没有一起对外,有的话 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.mdCLAUDE.mdGEMINI.md 三份 Agent 约定文件,按 AGENTS.md 自己的说明,后两份是指向它的符号链接——你派 Agent 去改这个仓库之前,先让它读这一份。

想把这类”逐层拆开一个开源项目的防护代码”的方法用到别的项目上,可以接着看开源项目的选型与评估方法

本篇属于一个把开源3D 建筑编辑器 Pascal Editor逐层拆开讲的系列,整体地图见 Pascal Editor 是什么:浏览器里的开源 3D 建筑编辑器,与它自带的建模 MCP 服务器;沿着这条线往下,还可以看 Pascal Editor 三维建筑编辑器的 Agent 实时同步链路Pascal Editor 3D 建筑编辑器排障:显卡回退与报错分类

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。