权限模型
权限这一页是整个插件系统里最要紧的一页。核心结论先放前面:插件无权修改游戏认证、UUID 或登录校验逻辑。这不是靠检查实现的,是靠「接口层面不暴露钩子」+「命令行参数第二道防线」两道防线锁死的。
白名单制
判定逻辑只有两行(PluginPermissions):IsAllowed 查白名单,IsForbidden 查黑名单。清单校验时先查黑名单(出现即拒绝加载),再查白名单(不命中即拒绝加载):
if (PluginPermissions.IsForbidden(p))
errors.Add($"权限被禁止开放:{p}(插件无权接管账号身份校验)");
else if (!PluginPermissions.IsAllowed(p))
errors.Add($"未知权限(白名单制):{p}");白名单和黑名单之外的一切都拒绝。新加一个权限必须改代码、重发启动器——不给「先放行以后再收紧」的机会。
1.x 的 24 项权限
白名单全集(PluginPermissions.Allowed):
launcher:game_arguments launcher:ui_panel launcher:notification launcher:file_access
launcher:instances_read launcher:instances_write launcher:game_launch
launcher:content_read launcher:content_write launcher:download launcher:network
launcher:settings_read launcher:settings_write launcher:accounts_read launcher:logs_read
launcher:saves_read launcher:saves_write launcher:multiplayer launcher:appearance
launcher:updates launcher:events launcher:clipboard launcher:shell
launcher:storage launcher:tasks粗粒度意味着「要读实例就得能写实例」——instances_read 和 instances_write 分开是 1.x 相对好的一点,但仅此而已。
黑名单三项,出现即拒绝加载:
public static readonly string[] Forbidden =
[
"launcher:account_verify", // 账号身份校验
"launcher:auth_override", // 认证流程接管
"launcher:uuid_override", // UUID 覆写
];还有一批能力根本不在白名单里,声明即被拒:插件管理(装卸别的插件)、启动器自更新安装(更新包会覆盖本体二进制)、账户写操作。它们没进黑名单是因为「不可申请」和「明令禁止」是两种事。
为什么插件不能管理插件
否则一个插件就能把用户依赖的插件停掉,或者把自己装成「被信任的那一份」。 :::## 身份:第一道防线是接口
PluginHost 里没有、也不允许有任何账号身份校验相关的钩子。本体只暴露两个:
public const string HookBeforeGameLaunch = "beforeGameLaunch";
public const string HookLauncherEvent = "onLauncherEvent";插件能碰到的账号能力只有 accounts:read,而且拿不到令牌——access token 只存在本体内存里。
身份:第二道防线是命令行参数
插件可以追加启动参数,但只有增量,没有全量覆写接口。GameArgsManager 只有 Append 和 RemoveKey,本体核心参数根本不在这个管理器里,物理上不可被移除。
追加进来的参数还要过一道身份黑名单:
private static readonly string[] IdentityArgKeys =
[
"--username", "--uuid", "--accessToken", "--userType", "--userProperties",
"--clientId", "--clientid", "--xuid", "--authlib-injector",
"--authServer", "--authserver",
];匹配忽略大小写,同时支持 --key value 和 --key=value 两种写法(--key=value 取 = 前的部分,空格分隔取第一个空白段)。命中就丢弃并记一条警告:
if (PluginPermissions.IsIdentityArg(arg, out var banned))
{
_logger?.Warn($"已拦截插件参数注入({kind}):\"{arg}\" 命中身份参数黑名单 {banned}");
return;
}--authlib-injector 也在名单里——它是外置认证注入器,能被插件塞进去就等于把认证流程接管了。
2.0 的细粒度能力
2.0 换了语法:<namespace>:<verb> 或 <namespace>:<verb>(k=v, flag)。
instances:read
instances:write(allow_delete)
network:host=api.example.com
network:host=*.example.com
storage:kv(quota=2mb)
ui:panel能力目录是代码里的硬表(CapabilityCatalog),namespace、verb、参数名、取值范围任一项不认识就拒绝加载。风险分三级(PluginRiskLevel),高风险能力要求宿主在首次调用时弹二次确认(RequiresConfirmation)。
带参数能升级风险:instances:write 是 Medium,instances:write(allow_delete) 是 High。省写形式 network:host=api.example.com 等价于 network:host(host=api.example.com),storage:quota=2mb 会被解析成 storage:kv(quota=2mb)。
2.0 的降级映射与 fail-closed
2.0 能力最终要落到 1.x 的 ctx 装配上。桥接由 BridgeApi2 完成,原则是只放行不放大权限的能力。已经带强制措施的两项:
network:host=/network:any→ 主机白名单。每次 HTTP 请求都校验目标主机:
private void EnsureHostAllowed(string host, string url)
{
if (AllowedHosts is null) return;
if (IsHostAllowed(AllowedHosts, host)) return;
throw new UnauthorizedAccessException(
$"插件只声明了 {string.Join("、", AllowedHosts)} 的网络访问权限,已拒绝访问 {host}({url})");
}通配符 *.example.com 匹配子域但不匹配裸域本身——否则 evil-example.com 这种后缀相近的域名会被误放。
storage:kv(quota=)→ 存储配额。写前校验,超了就拒:
if (_c.StorageQuotaKb is { } quotaKb &&
Encoding.UTF8.GetByteCount(json) > (long)quotaKb * 1024)
throw new UnauthorizedAccessException(
$"插件私有存储已超过声明配额 {quotaKb} KB,写入被拒绝(请清理旧键或提高 quota)");配额默认 256KB,上限 32MB,声明超上限的配额串本身就算非法。 剩下 6 项能力是「声明了也不给」,原因是它们依赖的机制还没实现:instances:write 和 saves:write(allow_delete 的运行时二次确认没做)、game:kill 和 settings:write(需要运行时二次确认)、ui:html(HTML 面板模式未实现)、tasks:spawn(后台作业未实现)。
这些会进 Api2Grant.Deferred,宿主写进插件日志:
logger.Warn($"以下 2.0 能力本次未开放(fail-closed):{string.Join(";", grant.Deferred)}");fail-closed 的意思是:说不清为什么能放行的,就不放行。
相关链接:插件清单格式 · JS 与 Python 运行时 · 声明式界面