你的 processPayment 函式能夠存取資料庫、payment gateway與審計紀錄,只因為它恰好執行在同一個程序裡。如果攻擊者在某個 HTTP handler 中發現注入漏洞,他們就能繼承這一切權限。這個函式從未要求過這些能力,它僅僅因為被部署在這裡,就理所當然地預設擁有。
這就是 ambient authority,也是我們幾乎在所有系統中預設採用的模型。Capability-based security 則提出了一個截然不同的問題:如果函式只能執行它被明確賦予的事項,會怎樣?
Ambient authority 的問題
大多數應用程式在系統邊界檢查 access control list。請求進入 API gateway,JWT 被驗證,角色被確認,接著請求便進入一個信任區域,裡面的每個函式都隱含地擁有對一切的存取權。資料庫連線是全域單例。S3 client 從共用模組匯入。任何在這個程序內執行的程式碼都能呼叫這些資源。
這樣運作沒問題,直到出問題為止。
工具函式中的 deserialization 漏洞、遭到入侵的相依套件,或是 confused deputy attack,都會讓一個小小的缺口演變成對整個系統的完全存取。爆炸半徑並非受限於該特定操作所需的權限,而是受限於整個程序所擁有的一切。
Capability-based security 顛覆了這個模型。權限不再取決於你是誰,而是取決於你持有什麼。
什麼是 capability-based security?
Capability-based security 是一種將存取權表示為不可偽造的 token(稱為 capability)的模型,這些 token 必須被明確傳遞給需要它們的函式。Capability 同時是資源的參考與使用它的許可。你無法透過名稱請求存取權,只能使用你已經持有的 capability。
這不是加了額外步驟的 role-based access control。在 RBAC 中,使用者擁有角色,系統在存取時檢查該角色。在 capability-based security 中,沒有中央檢查機制。如果你持有 capability,就能使用它;沒有,就不能。Capability 本身就是授權的證明。
這個模型可追溯至 1970 年代的作業系統研究,但隨著我們建構更多隔離化的系統,它愈發重要。WebAssembly 模組、微服務、瀏覽器 API 與sandboxed外掛都使用了類似 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 的實作遭到入侵,攻擊者也只能竊取 orders,無法刪除資料表或讀取密碼雜湊。Capability 就是行為的邊界。
在具備更強型別系統的語言中,你可以將這個概念推得更遠。在 Rust 中,capability 可以擁有 file descriptor。型別系統確保它無法在未經許可的情況下被複製,而 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 確實有其成本。最明顯的是人體工學。每個函式簽名都會變長。你必須將 capability 貫穿整個呼叫鏈,這感覺就像把 dependency injection 推到了極致。在大型程式碼庫中,這可能變得相當繁瑣。
錯誤處理也變得更複雜。在 ambient authority 模型中,資料庫連線失敗是初始化時處理的全域問題。在 capability 模型中,每個接收 capability 的函式都必須考慮:如果 capability 在操作中途被撤銷或失效,該怎麼辦。
除錯也有額外成本。在 ACL 系統中存取被拒時,你檢查政策。在 capability 缺失時,你必須回溯整個呼叫鏈,找出誰應該傳遞它。這需要一套不同的技能,而大多數開發者並不熟悉。
這些權衡解釋了為什麼 capability-based security 大多僅限於作業系統、瀏覽器與高安全性環境。它不是免費的。
Capability-based security 在現今的應用
你已經使用過 capability-based security,即使你不知道這個名稱。在瀏覽器中,fetch 是一個全域函式,但它受到 same-origin policy 與 CORS 的限制。Service Worker 接收特定的事件 capability。WebAssembly 模組必須被明確授予對記憶體與宿主函式的存取權。
在雲端基礎設施中,AWS IAM policy conditions 與 scoped token 都具有 capability 的特性。Presigned S3 URL 就是一種 capability:一個不可偽造的 token,在特定時間內授予對特定資源執行特定操作的權限。具備嚴格範圍限制 RBAC binding 的 Kubernetes service account 也朝這個方向發展。
趨勢是朝向更小的隔離區域與更少的 ambient authority。Container 從作業系統中剝離了它。WebAssembly 從瀏覽器程序中剝離了它。下一步,就是從我們自己的函式中剝離它。
如何開始在程式碼中使用 capability
你不需要重寫整個應用程式。從爆炸半徑最關鍵的邊界開始。
將你的資料存取層隔離成 capability object。與其匯出全域的資料庫連線池,不如匯出回傳受範圍限制 handle 的函式:
// 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]);
}
};
}
然後只傳遞 handler 所需的 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 無法寄送電子郵件。它無法刪除帳號。它只能執行被賦予的操作。如果這個 handler 存在 deserialization 漏洞,攻擊者也無法轉向支付系統,因為該 capability 從未被傳遞給它。
Capability-based security 常見問題
這不就是 dependency injection 嗎?
看起來相似,但意圖不同。Dependency injection 著重於可測試性與模組化。Capability 則著重於安全性與最小權限原則。DI container 可能仍然注入一個全域資料庫連線,而 capability 則被精確限制在呼叫者應被允許執行的操作範圍內。
這會取代 authentication 嗎?
不會。Authentication 回答的是「你是誰?」Capability 回答的是「你能做什麼?」你仍然需要在邊界驗證身份。驗證之後,capability 會限制已認證程式碼能夠觸及的範圍。
哪些語言對此支援良好?
任何具備型別系統的語言都可以將 capability 表示為 interface 或 trait。Rust 與 TypeScript 都運作良好。在 Python 或沒有嚴格 interface 的 JavaScript 等動態型別語言中,你會失去編譯時期的強制執行,但這個模式仍然能提升程式碼清晰度並限制意外的濫用。
Ambient authority 是一種習慣,而非定律。Capability-based security 採用起來比理解它更困難,但產業的趨勢很明確:更小的邊界、更少的隱含權力、函式只能執行被明確賦予的操作。
從一個 handler 開始。傳遞一個 capability 給它,而不是資料庫連線。看看什麼會出錯。大多數時候,出錯的東西是你從未察覺的程式碼隱藏假設。