你的 processPayment 函数可以访问数据库、支付网关和审计日志,仅仅因为它碰巧运行在同一个进程里。如果攻击者在某个 HTTP 处理程序中发现了注入漏洞,他就会继承这一切。这个函数从未主动请求这些权力,它只是凭借部署位置就默认拥有了它们。
这就是 ambient authority,它几乎是我们构建的每一个系统的默认模型。Capability-based security 提出了一个不同的问题:如果函数只能做它被显式赋予的事情,会怎样?
环境权限问题
大多数应用在系统边界处检查访问控制列表(ACL)。请求到达 API 网关,JWT 被验证,角色被检查,然后请求进入一个信任区域,在那里每个函数都隐式地拥有对一切的访问权。数据库连接是一个全局单例。S3 客户端从共享模块导入。任何在进程内部运行的代码都可以调用它们中的任何一个。
这种模式在出问题之前都能正常工作。
工具函数中的反序列化漏洞、被攻陷的依赖项,或者 confused deputy attack,都能把一个小缺口变成对整个系统的完全访问。爆炸半径不受特定操作所需权限的限制,而是受整个进程所拥有权限的限制。
Capability-based security 翻转了这一范式。权限不再是「你是谁」的属性,而是「你持有什么」的属性。
什么是 capability-based security?
Capability-based security 是一种将访问权表示为不可伪造的令牌——称为 capability——的模型,这些令牌必须显式传递给需要它们的函数。Capability 既是对资源的引用,也是使用它的许可。你不能通过名称请求访问,只能使用你已经拥有的 capability。
这不是加了额外步骤的基于角色的访问控制(RBAC)。在 RBAC 中,用户拥有一个角色,系统在访问时检查该角色。在 capability-based security 中,不存在中央检查。如果你持有 capability,你就可以使用它;如果没有,你就不能。Capability 本身就是授权的凭证。
这一模型可以追溯到 1970 年代的操作系统研究,但随着我们构建越来越多的隔离化系统,它正变得越来越相关。WebAssembly 模块、微服务、浏览器 API 和沙箱化插件都使用了类似 capability 的模式,即使它们没有使用这个名称。
Capability 在实践中如何运作
下面是它在代码中的样子。在 ambient authority 模型中,任何函数都可以调用数据库:
// Ambient authority: any code in this module can use db
import { db } from './db';
async function getUser(id: string) {
return db.query('SELECT * FROM users WHERE id = ?', [id]);
}
async function processOrder(orderId: string) {
const order = await db.query('SELECT * FROM orders WHERE id = ?', [orderId]);
// What if a bug here lets an attacker query arbitrary tables?
return order;
}
两个函数共享一个具有完全访问权限的全局 db 连接。在 capability 模型中,你传递的是一个限定范围的 capability:
// A capability is just a constrained handle
interface UserReadCap {
getUser(id: string): Promise<User>;
}
interface OrderReadCap {
getOrder(id: string): Promise<Order>;
}
async function getUser(id: string, db: UserReadCap) {
return db.getUser(id);
}
async function processOrder(orderId: string, orders: OrderReadCap) {
return orders.getOrder(orderId);
}
getUser 无法触碰 orders。processOrder 无法触碰 users。编译器会强制执行这一点。即使 processOrder 的实现被攻陷,攻击者也只能外泄订单数据,无法删表或读取密码哈希。Capability 就是对行为的边界约束。
在具有更强类型系统的语言中,你可以进一步推进这一点。在 Rust 中,capability 可以拥有一个文件描述符。类型系统确保它无法在未经许可的情况下被复制,而借用检查器(borrow checker)确保它不会超出其有效生命周期:
use std::fs::File;
use std::io::{self, Write};
fn write_log(file: &mut File, msg: &str) -> io::Result<()> {
// This function can ONLY write to the file it was handed.
// It cannot open new files. It cannot read the filesystem.
writeln!(file, "{}", msg)
}
&mut File 就是 capability。这个函数无法凭空变出另一个来。
为什么 capability-based security 不是默认选择
Capability-based security 有真实的成本。最明显的是人体工程学(ergonomics)问题。每个函数签名都会膨胀。你必须把 capability 贯穿整个调用链,这感觉像是把依赖注入(dependency injection)推到了逻辑极端。在大型代码库中,这可能变得繁琐。
错误处理也变得更复杂。在 ambient authority 模型中,连接数据库失败是一个在初始化阶段处理的全局问题。在 capability 模型中,每个接收 capability 的函数都必须考虑:如果 capability 在操作中途被撤销或失效,会发生什么。
还有调试成本。在 ACL 系统中访问被拒绝时,你检查策略(policy)即可。在 capability 缺失时,你必须回溯整个调用链,找出原本应该传递它的人。这是一种不同的技能集,大多数开发者并不习惯。
这些权衡解释了为什么 capability-based security 主要局限于操作系统、浏览器和高安全环境。它不是免费的。
Capability-based security 如今出现在哪里
你已经使用过 capability-based security,即使你不知道它的名字。在浏览器中,fetch 是一个全局函数,但它受到同源策略(same-origin policy)和 CORS 的约束。Service Worker 接收特定的事件 capability。WebAssembly 模块必须被显式授予对内存和宿主函数的访问权。
在云基础设施中,AWS IAM 策略条件和限定范围令牌(scoped token)都具有 capability 的特征。预签名 S3 URL 就是一种 capability:一个不可伪造的令牌,在特定时间内授予对特定资源的特定操作。具有窄范围 RBAC 绑定的 Kubernetes 服务账号也在朝这个方向迈进。
趋势是走向更小的隔离舱,更少的 ambient authority。容器从操作系统中剥离了它。WebAssembly 从浏览器进程中剥离了它。下一步是从我们自己的函数中剥离它。
如何开始在代码中使用 capability
你不需要重写整个应用。从最关乎爆炸半径的边界开始。
将你的数据访问层隔离成 capability 对象。不要导出一个全局的数据库连接池,而是导出返回限定范围句柄的函数:
// capabilities.ts
export interface UserStore {
getById(id: string): Promise<User | null>;
updateEmail(id: string, email: string): Promise<void>;
}
export interface AuditLog {
record(event: AuditEvent): Promise<void>;
}
// Hand out capabilities at the application boundary
function createUserStore(db: Pool): UserStore {
return {
async getById(id) {
const row = await db.query('SELECT * FROM users WHERE id = ?', [id]);
return row ? mapUser(row) : null;
},
async updateEmail(id, email) {
await db.query('UPDATE users SET email = ? WHERE id = ?', [email, id]);
}
};
}
然后只传递处理程序所需的 capability:
async function handleProfileUpdate(
req: Request,
users: UserStore,
audit: AuditLog
) {
const user = await users.getById(req.userId);
await users.updateEmail(req.userId, req.body.email);
await audit.record({ type: 'email_changed', userId: req.userId });
}
handleProfileUpdate 无法发送邮件,无法删除账户。它只能做它被赋予的事情。如果这个处理程序存在反序列化漏洞,攻击者也无法转向支付系统,因为那个 capability 从未被传递过。
关于 capability-based security 的常见问题
这不就是依赖注入吗?
它看起来相似,但意图不同。依赖注入关注的是可测试性和模块化。Capability 关注的是安全性和最小权限原则(least privilege)。DI 容器可能仍然会注入一个全局数据库连接。而 capability 被精确限定在调用者应该被允许做的事情上。
这会取代身份验证吗?
不会。身份验证(authentication)回答的是「你是谁?」Capability 回答的是「你能做什么?」你仍然需要在边界处验证身份(identity)。在此之后,capability 约束经过身份验证的代码能够触碰什么。
哪些语言能很好地支持这一模式?
任何具有类型系统的语言都可以将 capability 表示为接口(interface)或特质(trait)。Rust 和 TypeScript 都能很好地工作。在动态类型语言中,比如没有严格接口的 Python 或 JavaScript,你会失去编译时强制(compile-time enforcement),但这一模式仍然能提高代码清晰度并限制意外滥用。
Ambient authority 是一种习惯,而非定律。Capability-based security 比理解更难采纳,但行业的方向是明确的。更小的边界。更少的隐式权力。函数只能做它们被显式赋予的事情。
从一个处理程序开始。传递给它一个 capability,而不是数据库连接。看看什么会崩掉。大多数时候,崩掉的是你从未意识到代码正在做出的隐藏假设。