—— 外部介接是生命線,也是攻擊主動脈。
對象:平台工程、資安、後端、LLMOps / Integration
核心:簽章驗證(Signature)→ 重放防護(Replay Guard)→ 租戶隔離(Tenant Boundary)
LLM/Agent 系統幾乎一定會跟外部服務互動:付款、CRM、票務、資料索引…
問題是:誰都能打你的 Endpoint?重送一次會不會又執行?來的是誰的資料?
今天把三件事一次講清楚:簽章、重放、租戶隔離。
類型 | 攻擊手法 | 影響 |
---|---|---|
偽造請求(Spoofing) | 攻擊者直接呼叫你的 Webhook | 未授權下單/寫入/觸發 Job |
篡改(Tampering) | 中間人修改 Body | 錯誤資料入庫 |
重放(Replay) | 攻擊者重送同一請求 | 重複扣款/重複下單/重複寫入 |
租戶混淆(Tenant Confusion) | Tenant A 的事件被當成 Tenant B | 資料越租戶污染 |
過量/反序列化 | 巨大 Payload / 注入惡意 JSON | 資源耗盡 / RCE(不當反序列化) |
X-Signature: v1,hmac-sha256,ts=1700000123,kid=acme-tenant-A,mac=HeXBaSe64==
v1
:簽名版本(可升級協議)algo
:hmac-sha256
或 ed25519
ts
:Unix 秒級時間戳(允許 ±300s)kid
:key id(對應租戶或金鑰)mac
:對 原始 Body 与簽名字串做 MAC{method}\n{path}\n{ts}\n{body_raw_sha256}
一定要用 raw body(字節序),避免 JSON 重新序列化導致驗證失敗。
import crypto from "node:crypto";
import express from "express";
import type { Request, Response, NextFunction } from "express";
const app = express();
// 重要:保留 raw body 以供簽章驗證
app.use(express.json({
verify: (req: any, _res, buf) => { req.rawBody = buf; }
}));
const KEYS: Record<string, { secret: string; tenant: string }> = {
"acme-tenant-A": { secret: process.env.ACME_A!, tenant: "acme" },
"acme-tenant-B": { secret: process.env.ACME_B!, tenant: "acme" },
};
function safeEqual(a: Buffer, b: Buffer) {
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}
function verifySig(req: any): { ok: boolean; tenant?: string; reason?: string } {
const hdr = req.get("X-Signature") || "";
const parts = Object.fromEntries(hdr.split(",").map(s => s.split("=", 2)));
const v = hdr.split(",")[0]; // v1,hmac-sha256
const ts = Number(parts["ts"]);
const kid = parts["kid"];
const mac = parts["mac"];
if (!v?.startsWith("v1") || !ts || !kid || !mac) return { ok: false, reason: "bad_header" };
// 時間窗 ±300s
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - ts) > 300) return { ok: false, reason: "stale" };
const key = KEYS[kid];
if (!key) return { ok: false, reason: "unknown_kid" };
const bodyHash = crypto.createHash("sha256").update(req.rawBody).digest("hex");
const canonical = `${req.method}\n${req.path}\n${ts}\n${bodyHash}`;
const expected = crypto.createHmac("sha256", key.secret).update(canonical).digest("base64");
if (!safeEqual(Buffer.from(mac), Buffer.from(expected))) return { ok: false, reason: "bad_mac" };
return { ok: true, tenant: key.tenant };
}
function sigMiddleware(req: any, res: Response, next: NextFunction) {
const r = verifySig(req);
if (!r.ok) return res.status(401).json({ error: "invalid_signature", reason: r.reason });
// 後續中繼資訊
(req as any).tenant = r.tenant;
next();
}
app.post("/webhooks/events", sigMiddleware, (req, res) => {
res.status(202).send("accepted");
});
ts
id
;用 {tenant}:{id}
當鍵值id → seen_at
,重複即拒收(或只回 2xx 並忽略)Idempotency-Key
;同鍵僅執行一次// 接續上例:在 sigMiddleware 之後
import { createClient } from "redis";
const redis = createClient({ url: process.env.REDIS_URL });
async function replayGuard(req: any, res: Response, next: NextFunction) {
const evt = req.body?.id;
if (!evt) return res.status(400).json({ error: "missing_event_id" });
const key = `webhook:${req.tenant}:${evt}`;
const ok = await redis.set(key, "1", { NX: true, EX: 60 * 10 }); // 10 分鐘
if (!ok) return res.status(409).json({ error: "replayed" });
next();
}
kid
對應每租戶的 key;永不共用
tenant_id
納入簽名字串或放 Header 並二次檢核
/tenants/{id}/webhooks/events
(API Gateway 層再校驗)package webhook.authz
default allow = false
allow {
input.verified == true
input.header.kid == data.tenants[input.path_params.tenant].kid
input.context.tenant == input.path_params.tenant
}
{
"id": "evt_01HRN...",
"type": "doc.indexed",
"tenant": "acme",
"occurred_at": "2025-09-02T10:05:00Z",
"data": {
"doc_id": "D123",
"hash": "sha256:..."
},
"idempotency_key": "acme:D123:2025-09-02",
"version": "2025-09-01"
}
version
用於事件綱要演進;idempotency_key
幫助下游只處理一次。
req.body
重序列化驗簽 → 全部失敗 → 一律使用 rawBody
Idempotency-Key
+ 唯一索引Idempotency-Key
PM:為什麼我們的 Webhook 會出現「replayed」?
你:因為對方重送了三次;我們吃一次、跳過兩次。這叫「可靠,而不重複」。
簽章保證來源、重放保證唯一、隔離保證邊界。
AI 介接的安全,不是靠幸運,而是靠這三把鎖。
談 Vault/KMS、短期憑證(STS)、密鑰分級與自動輪替實務。