MCP協(xié)議安全配置指南:防范Supabase式數(shù)據(jù)庫暴露的實戰(zhàn)要點
摘要:MCP協(xié)議安全:避免Supabase式數(shù)據(jù)庫暴露的實戰(zhàn)要點Supabase事件復盤:不是“漏洞”,是配置與邊界失控Hacker News上那場關于Supabase MCP組件導致數(shù)據(jù)庫直連公網(wǎng)的討論,根源不在代碼里埋了后門,而在于兩處可避免的失控:默認配置放行了數(shù)據(jù)庫連接池:MCP服務啟動時未強制隔離數(shù)據(jù)庫訪問通道,DATABASE_URL 環(huán)境變量被直接注入到客戶端可觸達的連接上下文中;請...

MCP協(xié)議安全:避免Supabase式數(shù)據(jù)庫暴露的實戰(zhàn)要點
Supabase事件復盤:不是“漏洞”,是配置與邊界失控
Hacker News上那場關于Supabase MCP組件導致數(shù)據(jù)庫直連公網(wǎng)的討論,根源不在代碼里埋了后門,而在于兩處可避免的失控:
- 默認配置放行了數(shù)據(jù)庫連接池:MCP服務啟動時未強制隔離數(shù)據(jù)庫訪問通道,
DATABASE_URL環(huán)境變量被直接注入到客戶端可觸達的連接上下文中; - 請求解析層缺失數(shù)據(jù)范圍約束:MCP協(xié)議允許客戶端傳入原始SQL片段或表名參數(shù),但服務端未校驗這些輸入是否落在預設白名單內(nèi),也未綁定用戶身份與數(shù)據(jù)租戶(tenant)。
結(jié)果是:攻擊者用 curl -X POST https://your-app.supabase.co/mcp/query -d '{"table":"users","where":"1=1"}' 就能拉走全量用戶記錄。
這不是MCP協(xié)議本身的設計缺陷,而是實現(xiàn)時跳過了權限錨點和數(shù)據(jù)沙箱。
關鍵防御點:從協(xié)議層落地到代碼
1. 權限控制必須綁定租戶上下文
JWT認證只是起點。真正的權限控制發(fā)生在每次MCP請求進入時——必須將用戶身份、角色、租戶ID三者綁定,并在數(shù)據(jù)庫查詢前完成校驗。
不要只驗證token是否有效,要驗證token聲明中tenant_id是否匹配當前請求目標資源所屬租戶。
// ? 正確:在MCP handler中做租戶級攔截
app.post('/mcp/query', authenticateJWT, (req, res) => {
const { table, where } = req.body;
// 檢查該用戶是否有權訪問此表(基于租戶+角色策略)
if (!isTableAccessible(req.user.tenant_id, req.user.role, table)) {
return res.status(403).json({ error: 'Forbidden: table access denied' });
}
// 構(gòu)造查詢時強制注入租戶過濾條件
const safeWhere = { ...where, tenant_id: req.user.tenant_id };
db.query(table, safeWhere).then(data => res.json(data));
});2. 數(shù)據(jù)邊界校驗不能依賴客戶端輸入
MCP協(xié)議不禁止客戶端傳表名或字段名,但服務端必須用白名單機制兜底:
- 表名只允許出現(xiàn)在預定義列表中(如
['posts', 'comments', 'profiles']); - 字段名需映射到實體屬性,禁止原始SQL拼接;
where條件必須通過結(jié)構(gòu)化解析器(如objection.js的QueryBuilder或knex().where())生成,禁用字符串模板。
// ? 危險:拼接SQL
const query = `SELECT * FROM ${req.body.table} WHERE ${req.body.where}`;
// ? 安全:白名單 + 結(jié)構(gòu)化構(gòu)建
const allowedTables = new Set(['posts', 'comments']);
if (!allowedTables.has(req.body.table)) {
throw new Error('Invalid table name');
}
// 使用Knex構(gòu)建帶租戶約束的查詢
const result = await knex(req.body.table)
.where({ tenant_id: req.user.tenant_id })
.andWhere(req.body.where || {});3. 加密不是選項,是通信基線
MCP Server必須強制HTTPS,且所有Agent通信鏈路默認啟用TLS 1.3。別在開發(fā)環(huán)境留HTTP后門——.env里寫NODE_ENV=development不等于可以關掉證書校驗。
對敏感字段(如API key、token、PII),額外做應用層加密:
// 使用AES-GCM加密存儲敏感字段(非僅哈希)
const crypto = require('crypto');
const algorithm = 'aes-256-gcm';
const secretKey = Buffer.from(process.env.ENCRYPTION_KEY, 'hex');
function encrypt(text) {
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv(algorithm, secretKey, iv);
let encrypted = cipher.update(text, 'utf8', 'hex');
encrypted += cipher.final('hex');
return `${iv.toString('hex')}:${encrypted}:${cipher.getAuthTag().toString('hex')}`;
}實戰(zhàn):Yitb Server鑒權中間件與Agent設計原則
鑒權中間件要覆蓋MCP入口點
MCP請求常走獨立路由(如 /mcp/ 前綴),不能復用Web頁面的鑒權邏輯。中間件必須:
- 解析
Authorization頭中的Bearer token; - 校驗簽名、過期時間、租戶聲明;
- 將
user.tenant_id注入req,供后續(xù)handler使用。
// middleware/mcp-auth.js
const jwt = require('jsonwebtoken');
module.exports = (req, res, next) => {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing or invalid Authorization header' });
}
const token = authHeader.split(' ')[1];
try {
const payload = jwt.verify(token, process.env.JWT_SECRET);
if (!payload.tenant_id) {
throw new Error('Missing tenant_id in token');
}
req.user = payload;
next();
} catch (err) {
res.status(401).json({ error: 'Invalid or expired token' });
}
};在路由中顯式掛載:
const mcpAuth = require('./middleware/mcp-auth');
// ? 專用于MCP協(xié)議的入口
app.post('/mcp/query', mcpAuth, handleMCPQuery);
app.post('/mcp/insert', mcpAuth, handleMCPInsert);Agent安全設計三條鐵律
- 永遠不持有長期數(shù)據(jù)庫憑證
Agent運行時只獲取短期、作用域受限的訪問令牌(如PostgreSQL的pgbouncer動態(tài)用戶,或Supabase的service_role臨時token),用完即焚。 - 所有出站請求強制雙向TLS
Agent調(diào)用外部API時,不僅驗證服務端證書,還要用自己的客戶端證書發(fā)起請求(mTLS),防止中間人偽造響應。 - 日志脫敏是硬性要求
記錄MCP請求時,自動過濾password、api_key、token等字段,且不記錄原始SQL——只記操作類型、表名、影響行數(shù)、耗時。
// 日志中間件示例
app.use((req, res, next) => {
const start = Date.now();
res.on('finish', () => {
const duration = Date.now() - start;
const safeBody = { ...req.body };
delete safeBody.password;
delete safeBody.api_key;
console.log({
method: req.method,
path: req.path,
status: res.statusCode,
duration,
body: safeBody,
timestamp: new Date().toISOString()
});
});
next();
});