Skip to content

Plugin Security

worable edited this page Oct 12, 2026 · 1 revision

执行模式、权限与 RBAC

插件有 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 执行模式。

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 崩溃时的处理:

  1. 插件状态置为 failed,清理该插件的路由、模型与任务,拒绝在途请求。
  2. workerFailures 计数加一;达到 3 次后打开熔断(circuit open)。
  3. 熔断后再次激活会抛错 插件 <id> worker 已熔断,请重新注册插件或部署修复版本后再试;必须重新注册修复后的包才能恢复。

isolated 模式下不可用的能力

跨进程的异步 IPC 无法保留下述同步语义,调用会得到明确错误:

  • ctx.events.intercept() 抛出 ctx.events.intercept 暂不支持跨进程同步协议(要求同步 next())。
  • ctx.events.waterfall() 抛出 ctx.events.waterfall 暂不支持跨进程同步协议(要求同步返回值)。

另外两类定义会在 worker 启动阶段被拒绝,而不是延迟到运行时报错:

  • 原始流式路由处理器。
  • 无法表示为 JSON Schema 的自定义模型,错误形如 插件 <id> 的模型 <kind> schema 无法跨进程序列化:...。

trusted 执行

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:upsert Permission,并授予管理员组 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 字段查看。

Admin Actions 权限

插件的 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 由内核在权限检查失败时写入,不接受前端上报。

出站请求与 SSRF

插件代表平台发起出站请求(上游 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 等开关放行本机上游,但绝不可带入生产)。

Clone this wiki locally