你的 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,而不是数据库连接。看看什么会崩掉。大多数时候,崩掉的是你从未意识到代码正在做出的隐藏假设。