Repository navigation
Plugin Security
插件有 isolated 与 trusted 两种执行模式,由 manifest 的 execution 字段选择。permissions、roleTemplates 与路由 permission 构成另一层独立的访问控制,作用于 HTTP 路由准入与前端可见性,与执行模式不是一回事。
execution |
运行方式 | 适用场景 |
|---|---|---|
isolated |
入口在独立 Node 子进程中运行 | 生产环境第三方插件(默认) |
trusted |
与 API 进程同进程运行 | 内置插件,以及非生产的开发环境 |
PluginManifest.execution?: 'trusted' | 'isolated'(packages/sdk/src/plugin.ts)。取值规则:
- 生产环境(
NODE_ENV=production):未声明execution的第三方插件按isolated加载。 - 非生产环境:未声明
execution的插件按trusted加载,兼容早期本地插件。 - 内置插件在任何环境都走
trusted,不会进入子进程。 - 生产环境中,非内置插件声明
execution: "trusted"会在清单校验阶段被拒绝,返回 403生产环境第三方插件必须使用 isolated 执行模式。
API 进程只保留插件清单、路由代理与 RPC 桥,插件入口在独立 Node 子进程中执行,运行环境经过裁剪。宿主与 worker 之间通过带版本的 IPC RPC 协议通信,ctx.* 的能力全部经该协议暴露:
- 协议版本
RPC_PROTOCOL = 1,每条消息携带protocol与pluginId,二者不匹配即丢弃。 - 单条消息上限
RPC_MAX_BYTES = 1 MiB(双向,超出即拒绝)。 - 并发上限
RPC_MAX_IN_FLIGHT = 64(双向,超出即拒绝)。 - 请求超时
RPC_TIMEOUT_MS = 10s,超时返回code: 'rpc.timeout'、HTTP 504,并向对端发送cancel消息。 - 消息不可序列化时直接抛错,不做降级。
生命周期钩子、媒体引用、声明式与动态事件、任务、状态(含 withLock)、认证、通知、支付、钱包、汇率、插件密钥、事务、自定义模型 CRUD 与结构化日志都可通过 RPC 调用。三个同步方法 auth.sessionCookieConfig()、secrets.isAvailable()、payments.listPaymentMethods() 在生命周期激活时接收快照,以保留同步返回语义。
worker 崩溃时的处理:
- 插件状态置为
failed,清理该插件的路由、模型与任务,拒绝在途请求。 -
workerFailures计数加一;达到 3 次后打开熔断(circuit open)。 - 熔断后再次激活会抛错
插件 <id> worker 已熔断,请重新注册插件或部署修复版本后再试;必须重新注册修复后的包才能恢复。
跨进程的异步 IPC 无法保留下述同步语义,调用会得到明确错误:
-
ctx.events.intercept()抛出ctx.events.intercept 暂不支持跨进程同步协议(要求同步next())。 -
ctx.events.waterfall()抛出ctx.events.waterfall 暂不支持跨进程同步协议(要求同步返回值)。
另外两类定义会在 worker 启动阶段被拒绝,而不是延迟到运行时报错:
- 原始流式路由处理器。
- 无法表示为 JSON Schema 的自定义模型,错误形如
插件 <id> 的模型 <kind> schema 无法跨进程序列化:...。
trusted 插件在激活时由内核 import() 进 API 进程,与内核共享同一进程、同一事件循环、同一 process,不存在 VM、Worker、子进程或容器级隔离。插件以内核进程相同的操作系统身份运行,可触达该进程能触达的一切:文件系统、环境变量、网络、内核已连接的 PostgreSQL / Redis / 对象存储,以及内核注入的服务对象。因此只应把 trusted 用于内置插件或本地开发。
permissions、roleTemplates 与路由 permission 是路由准入与前端可见性控制,约束「哪个已认证用户能调用哪条路由、看到哪些入口」,不约束插件代码本身,也不是 OS 沙盒。isolated 同样不是完整容器:它与宿主共享操作系统身份,其边界是进程隔离加 RPC 协议约束,而非内核级沙盒。
PluginContext 不提供数据库句柄,插件拿不到 PrismaClient,也访问不到内核表。插件只能经 Extension 引擎(ctx.extensions / ctx.tx)读写自己声明的模型。这是数据访问边界,用于防止插件代码无意破坏内核 schema;在 trusted 模式下它不是安全沙盒,同进程代码仍可通过其他途径间接影响进程。
插件包签名验证的是来源与完整性(provenance),用于确认包来自可信发布者且未被篡改。生产环境应开启包签名强校验并配置 STACKPANEL_SIGNING_PUBLIC_KEY。签名不提供运行时隔离。
| 角色 | 说明 |
|---|---|
ADMIN |
管理员,拥有全部权限,无需逐一授权 |
USER |
普通用户,权限由激活的插件通过 roleTemplates 动态授予 |
在 manifest 中声明插件使用的权限字符串:
{
"permissions": ["my-plugin.read", "my-plugin.write", "my-plugin.delete"]
}权限字符串是任意字符串,建议使用 <plugin-id>.<action> 格式。
roleTemplates 定义插件激活后自动授予内置角色的权限:
{
"roleTemplates": [
{ "role": "USER", "permissions": ["my-plugin.read"] },
{ "role": "ADMIN", "permissions": ["my-plugin.read", "my-plugin.write", "my-plugin.delete"] }
]
}
ADMIN始终拥有全部权限,roleTemplates 中为 ADMIN 声明的权限是冗余的(但可用于文档化)。
权限在插件激活时由 PluginRuntime.registerPluginPermissions() 写入:
- 对
permissions中每个 key:upsertPermission,并授予管理员组group_admin。 - 对每个
roleTemplates:把模板权限授予对应内置组(ADMIN→group_admin,USER→group_user)。
这是 upsert 语义,停用插件不会撤销已写入的权限(组授权是持久数据)。因此重新启用不会重复出错。
插件路由可声明 permission 字段:
routes: [
{
method: 'GET',
path: '/my-plugin/items',
auth: 'user',
permission: 'my-plugin.read',
handler: listItems,
},
{
method: 'DELETE',
path: '/my-plugin/items/:id',
auth: 'user',
permission: 'my-plugin.delete',
handler: deleteItem,
},
],Dispatcher 的 runGuards 按以下顺序检查:
1. isActive? → 否则 404
2. requireAuth → auth='user'/'admin' 或有 permission 时,需认证
3. requireRole(admin) → auth='admin' 时,需 ADMIN 角色
4. hasPermission → 有 permission 时,检查角色权限
运行时用扁平权限集合判定:request.user.permissions.has(route.permission)(request.user 由认证层解析,含用户所属组的全部权限)。auth: 'admin' 单独要求 platform.admin 权限。声明 permissions 的插件会把它们授予 group_admin,因此管理员天然通过;USER 需由 roleTemplates 显式授予。
前端 BFF 可通过 API 检查当前会话的权限:
GET /permissions/check?permission=my-plugin.read
→ { "allowed": true }
权限不足时,内核会写入审计日志 frontend.permission.denied。
管理后台可查看所有激活插件声明的权限(按插件展开为 pluginId + permission):
GET /admin/rbac/templates
→ {
"templates": [
{ "pluginId": "my-plugin", "permission": "my-plugin.read" },
{ "pluginId": "my-plugin", "permission": "my-plugin.write" }
]
}
返回的是 manifest
permissions的展开,不是roleTemplates。角色模板可在GET /admin/plugins的每项roleTemplates字段查看。
插件的 frontend adminActions 可以声明 permission 字段。渲染前,管理后台会检查当前用户是否拥有该权限,未授权的 action 不会展示。详见 管理后台扩展。
前端运行时的权限相关事件通过 POST /frontend/audit 写入审计日志:
| action | 说明 |
|---|---|
frontend.permission.denied |
权限检查未通过(由 /permissions/check 写入) |
frontend.finder.call |
Finder 数据调用 |
frontend.admin.route.render |
管理路由渲染 |
frontend.account.route.render |
账户路由渲染 |
frontend.action.execute |
前端 action 执行 |
/frontend/audit的 action 枚举为后四项;frontend.permission.denied由内核在权限检查失败时写入,不接受前端上报。
插件代表平台发起出站请求(上游 API、Webhook、回调)时,目标地址往往来自管理员
配置或远端数据。若不加校验,攻击者可把它当作访问内网/云元数据的 SSRF 原语。内核
内置 @stackpanel/net-guard 供内核与插件复用,避免各插件各写一套、口径漂移:
| 导出 | 作用 |
|---|---|
isPublicHttpUrl(url, opts?) |
协议白名单(默认仅 https:,allowHttp 放宽)、禁止内嵌凭证、主机非内网 |
isUnsafeIpAddress(address) |
判定 IPv4/IPv6 字面量是否属于保留/内网段(含 IPv4-mapped IPv6 十六进制形式) |
isUnsafeHostname(hostname) |
判定主机名是否为 localhost/内网 TLD/私有 IP |
resolvesToUnsafeAddress(host) |
异步解析 DNS,检查是否落到内网(防 DNS rebinding) |
用法:在配置写入的 zod schema 上 refine(isPublicHttpUrl(...)) 拦截,并在真正
fetch 之前再校验一次,以防御历史存量数据。生产环境必须开启(开发联调可用
NODE_ENV 等开关放行本机上游,但绝不可带入生产)。
核心开发
插件开发
主题开发
前端包开发
RESTful API