MCP協(xié)議安全邊界缺陷分析與Supabase漏洞加固指南

MCP協(xié)議安全邊界缺陷解析與加固指南:Supabase漏洞啟示錄
Supabase漏洞暴露了什么
Hacker News上熱議的“Supabase MCP漏洞致全庫SQL裸奔導(dǎo)出”事件,本質(zhì)不是Supabase寫錯了代碼,而是MCP Server在默認配置下把數(shù)據(jù)庫當成了公共讀取器——任何能通過基礎(chǔ)認證的用戶,都能直接觸發(fā)/export端點,拿到整個PostgreSQL實例的SQL dump。
這個漏洞不依賴SQL注入、不靠服務(wù)端模板渲染,純粹是權(quán)限模型在協(xié)議層塌方的結(jié)果。它提醒我們:當AI Agent能自由調(diào)用MCP Server時,協(xié)議本身必須守住第一道門,而不是把所有信任都押在應(yīng)用層的if語句上。
安全邊界在哪塌了
1. 默認配置等于開放大門
MCP Server啟動時不強制要求聲明資源策略。它的默認行為是:只要JWT簽名有效,就放行所有GET /resources/*和POST /actions/*請求。這不是疏忽,是協(xié)議設(shè)計選擇——但這個選擇在多租戶場景下立刻失效。
- 權(quán)限粒度缺失:MCP規(guī)范里沒有
resource_id、operation_type、context_scope等字段的強制校驗邏輯。Server實現(xiàn)通常只校驗sub(用戶ID)和exp(過期時間),剩下的全交給上層應(yīng)用。 - 認證 ≠ 授權(quán):Server完成身份認證后,直接把原始請求轉(zhuǎn)發(fā)給后端處理函數(shù)。如果那個函數(shù)沒做二次鑒權(quán),或者鑒權(quán)邏輯被繞過(比如傳入
resource_id=*或table_name=public.*),數(shù)據(jù)就裸奔了。
Supabase案例中,攻擊者發(fā)送的請求類似:
POST /v1/export HTTP/1.1
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Content-Type: application/json
{"format": "sql", "schema": "public", "tables": ["*"]}MCP Server驗證完JWT就轉(zhuǎn)發(fā),而Supabase的導(dǎo)出Handler沒對tables字段做白名單過濾,也沒檢查當前用戶是否有pg_dump權(quán)限。
2. 協(xié)議層零鑒權(quán)邏輯
MCP協(xié)議文檔明確將“授權(quán)決策”劃歸應(yīng)用層責(zé)任。這導(dǎo)致兩個現(xiàn)實問題:
- 鑒權(quán)邏輯分散:同一個資源可能在API網(wǎng)關(guān)、MCP Server中間件、業(yè)務(wù)Handler里被校驗三次,也可能一次都沒被校驗——取決于開發(fā)者當天的心情。
- 請求可篡改無感知:MCP不強制簽名請求體,也不校驗
Content-MD5或X-Signature頭。攻擊者能輕松修改user_id、tenant_id、scope等關(guān)鍵參數(shù),而Server照單轉(zhuǎn)發(fā)。
三步堵住協(xié)議層缺口
1. 在MCP Server層做鑒權(quán),別甩鍋給應(yīng)用
RBAC必須下沉到協(xié)議入口。不是加個中間件,而是讓MCP Server在解析完JWT后,立即查策略引擎,決定是否允許本次請求抵達業(yè)務(wù)Handler。
# mcp_server/middleware/authz.py
from policy_engine import evaluate
def enforce_mcp_authz(jwt_payload, method, path, body):
# 提取策略上下文
context = {
"user_id": jwt_payload["sub"],
"role": jwt_payload.get("role", "user"),
"method": method,
"path": path,
"resource": extract_resource_from_path(path),
"action": infer_action_from_method_and_path(method, path),
"body_keys": list(body.keys()) if isinstance(body, dict) else []
}
# 同步調(diào)用策略引擎(不走網(wǎng)絡(luò),本地加載OPA/WASM)
if not evaluate("mcp_authz.rego", context):
raise PermissionDenied(f"Policy denied for {context}")
# 在請求路由前調(diào)用
@app.route("/<path:path>", methods=["GET", "POST", "PUT", "DELETE"])
def handle_mcp_request(path):
jwt = parse_jwt(request.headers.get("Authorization"))
body = request.get_json() or {}
enforce_mcp_authz(jwt, request.method, path, body)
return dispatch_to_handler(path, body)2. 權(quán)限控制必須精確到操作+資源+上下文
“能訪問用戶表”不等于“能導(dǎo)出用戶表”。權(quán)限模型要區(qū)分:
read:user:profile(讀個人資料)read:table:users(讀users表所有行)export:database:public(導(dǎo)出整個public schema)
# 權(quán)限檢查邏輯必須嵌入業(yè)務(wù)Handler內(nèi)部,而非僅依賴路由
def export_database_handler(user_id, schema, tables):
# 檢查用戶是否有該schema的導(dǎo)出權(quán)限
if not has_permission(user_id, f"export:schema:{schema}"):
raise Forbidden("Missing export:schema permission")
# 檢查每個table是否在用戶授權(quán)范圍內(nèi)
allowed_tables = get_allowed_tables(user_id, schema)
for table in tables:
if table != "*" and table not in allowed_tables:
raise Forbidden(f"Table {table} not in allowed list")
# 執(zhí)行導(dǎo)出(此時已確保安全)
return pg_dump(schema, tables)
# 權(quán)限數(shù)據(jù)存在獨立策略服務(wù)里,不和業(yè)務(wù)DB混用
def get_allowed_tables(user_id, schema):
return requests.get(
f"https://policy.internal/allowed-tables?user={user_id}&schema={schema}"
).json()3. Agent調(diào)用必須沙箱化,且沙箱由MCP Server管理
Agent不是可信執(zhí)行體。MCP Server收到/agent/run請求后,不能直接subprocess.run(),而應(yīng):
- 啟動隔離容器(gVisor或Firecracker輕量VM)
- 掛載只讀的代碼目錄 + 臨時內(nèi)存盤
- 設(shè)置
rlimit硬限制(CPU 5s、內(nèi)存 256MB、網(wǎng)絡(luò)禁止外連) - 超時強殺,返回
SIGKILL狀態(tài)碼而非SIGTERM
# mcp_server/agent_runner.py
import firecracker
from tempfile import mkdtemp
def run_agent_sandboxed(agent_code, timeout=5):
# 創(chuàng)建臨時工作區(qū)
workdir = mkdtemp()
with open(f"{workdir}/main.py", "w") as f:
f.write(agent_code)
# 啟動Firecracker microVM
vm = firecracker.MicroVM(
kernel="/boot/vmlinux",
initrd="/rootfs.ext4",
cpu_count=1,
mem_size_mb=256,
network="none", # 禁止網(wǎng)絡(luò)
drives=[firecracker.Drive(workdir, readonly=True)]
)
try:
vm.start()
result = vm.execute("python3 /mnt/main.py", timeout=timeout)
return {"status": "success", "output": result.stdout}
except firecracker.TimeoutError:
vm.kill()
return {"status": "timeout", "error": "Execution exceeded 5s"}
finally:
vm.cleanup()安全是可交付的模塊,不是PPT里的形容詞
用戶不會為“高可用”付錢,但會為“導(dǎo)出數(shù)據(jù)前必須二次確認+審計日志+72小時追溯”付費。安全能力直接對應(yīng)三個變現(xiàn)點:
- 企業(yè)版強制策略引擎:把OPA策略編譯成WASM,在MCP Server內(nèi)聯(lián)執(zhí)行,按策略條數(shù)收費
- 沙箱運行時即服務(wù):按Agent調(diào)用次數(shù)和資源配額計費(如:100次/月免費,超量0.02美元/次)
- 合規(guī)審計包:自動生成SOC2、ISO27001所需日志視圖,附帶簽名報告
這些不是附加功能,是MCP Server的默認行為開關(guān)。關(guān)掉它們?可以,但得簽免責(zé)協(xié)議。
下一步:現(xiàn)在就做這五件事
- 停用所有
*通配符權(quán)限:檢查MCP Server配置,刪掉allow_all_authenticated這類flag - 在
/health端點旁加/policy-status:返回當前生效的策略版本、最后更新時間、未覆蓋路徑列表 - 給所有Agent調(diào)用加沙箱包裝器:哪怕只是
unshare -r -f --mount-proc也比裸跑強 - 重寫導(dǎo)出類API:強制要求
tables字段為非空數(shù)組,禁用["*"],改用list_tables接口分頁獲取 - 在CI里加策略測試:用真實JWT token跑
curl -X POST /export -d '{"tables":["users"]}',斷言返回403而非200