Skip to content

Plugin Security

worable edited this page Oct 20, 2026 · 2 revisions

信任模型、权限与 RBAC

StackPanel 使用基于角色的访问控制(RBAC)。插件可以声明权限并定义角色权限模板。

信任模型(务必先读)

插件是受信代码,不是沙盒对象。 理解这一点是用好、也是安全运营本平台的前提。

  • 执行模式取决于 execution 与运行环境。 manifest 的 execution 取 'trusted' | 'isolated'。 生产环境(NODE_ENV=production)下,非 builtin 插件一律以 isolated 执行:内核 fork() 一个独立子进程,插件代码跑在子进程里,与内核不同进程、不同事件循环,能力调用经受限 RPC 往返 (RPC_MAX_IN_FLIGHT=64、RPC_MAX_BYTES=1MiB、RPC_TIMEOUT_MS=10s、RPC_MAX_RAW_STREAMS=256, 超限报错拒绝而非排队)。开发环境未声明 execution 时回退 trusted(同进程 import())。 生产环境拒绝第三方插件声明 trusted。
  • 隔离是「故障与资源边界」,不是「安全沙盒」。 isolated 子进程与内核同一 OS 身份、同一文件系统、 同一环境变量,仍可触达该身份能触达的一切;它提供的是内存护栏(STACKPANEL_PLUGIN_MAX_RSS_MB 默认 512,超限 exit 137;--max-old-space-size 默认 256)与崩溃隔离(子进程被拆除不会带走内核), 以及 RPC 限额带来的资源上界。它不构成对抗恶意代码的安全边界。
  • 无 OS 级权限边界。 无论哪种执行模式,插件都以内核进程相同的操作系统身份运行,可触达该身份 能触达的一切:文件系统、环境变量、网络、内核已连接的 PostgreSQL / Redis / 对象存储,以及内核注入的服务对象。
  • permissions 不是安全边界。 manifest 的 permissions、roleTemplates 与路由 permission 字段,是HTTP 路由准入与前端可见性控制——它们约束「哪个已认证用户能调用哪条路由」,不约束插件代码本身。一旦插件被激活,它的代码即拥有内核授予该进程的全部能力。
  • 注入即授信。 PluginContext 暴露的能力(payments、wallet、fx、auth、notifications、jobs、state、secrets 等)是内核主动授信;插件可直接调用,无需额外授权。因此插件的可信任程度等同于内核本体。
  • 数据边界清晰,但非隔离。 PluginContext 不提供数据库句柄(ADR-0008 §1:ctx.db 已物理删除)。插件只能经 Extension 引擎(ctx.extensions / ctx.tx)读写自己声明的模型,拿不到 PrismaClient、也访问不到内核表。这是数据访问边界,用于防止插件代码无意破坏内核 schema;它不是安全沙盒——插件代码仍可通过其他途径间接影响进程。
  • 签名是来源校验,不是隔离。 插件包签名验证的是来源与完整性(provenance),用于确认包来自可信发布者且未被篡改。生产环境应开启包签名强校验并配置 STACKPANEL_SIGNING_PUBLIC_KEY。签名不提供运行时隔离。

运营含义

安装并激活一个插件,等价于在该服务器上以服务进程身份运行这段代码。因此:

  • 只安装可信来源的插件;第三方插件应按「引入了一段任意服务器端代码」来评估风险。
  • isolated 子进程降低了故障半径与资源失控风险(崩溃不带走内核、RSS 有硬顶),但不降低插件代码本身的信任要求——不可信/多租户插件仍不在当前能力范围。若未来需要对抗性隔离,须引入 Node Permission Model、容器或 VM 等 OS 级边界,这是一次架构变更,不是配置项。
  • 删除插件只是停止加载其代码;已写入的数据与已声明的权限由运维自行清理。

内置角色

角色 说明
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