iT邦幫忙

2025 iThome 鐵人賽

DAY 12
0
Security

AI都上線了,你的資安跟上了嗎?系列 第 15

📍 Day 12:API 與 Webhook 安全——簽章、重放、租戶隔離

  • 分享至 

  • xImage
  •  

—— 外部介接是生命線,也是攻擊主動脈。

對象:平台工程、資安、後端、LLMOps / Integration
核心:簽章驗證(Signature)→ 重放防護(Replay Guard)→ 租戶隔離(Tenant Boundary)


💬 開場:一條未驗證的 Webhook,足以毀掉整天

LLM/Agent 系統幾乎一定會跟外部服務互動:付款、CRM、票務、資料索引…
問題是:誰都能打你的 Endpoint?重送一次會不會又執行?來的是誰的資料?
今天把三件事一次講清楚:簽章、重放、租戶隔離


🧠 威脅模型(Threat Model)

類型 攻擊手法 影響
偽造請求(Spoofing) 攻擊者直接呼叫你的 Webhook 未授權下單/寫入/觸發 Job
篡改(Tampering) 中間人修改 Body 錯誤資料入庫
重放(Replay) 攻擊者重送同一請求 重複扣款/重複下單/重複寫入
租戶混淆(Tenant Confusion) Tenant A 的事件被當成 Tenant B 資料越租戶污染
過量/反序列化 巨大 Payload / 注入惡意 JSON 資源耗盡 / RCE(不當反序列化)

🧱 控制目標(三支柱)

  1. 簽章驗證:保證「誰發的」「內容沒被改」
  2. 重放防護:同一事件只執行一次,過期拒收
  3. 租戶隔離:事件與租戶關係在密碼學層級鎖死

🔐 一、簽章驗證(HMAC / Ed25519)

Header 建議格式

X-Signature: v1,hmac-sha256,ts=1700000123,kid=acme-tenant-A,mac=HeXBaSe64==
  • v1:簽名版本(可升級協議)
  • algohmac-sha256ed25519
  • ts:Unix 秒級時間戳(允許 ±300s)
  • kid:key id(對應租戶或金鑰)
  • mac:對 原始 Body 与簽名字串做 MAC

簽名字串(Canonical String)

{method}\n{path}\n{ts}\n{body_raw_sha256}

一定要用 raw body(字節序),避免 JSON 重新序列化導致驗證失敗。

Node/TypeScript 驗證(HMAC)

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");
});

🔁 二、重放防護(Replay Guard + Idempotency)

  • 時間窗:只接受過去 5 分鐘內的 ts
  • Nonce / Event ID:訊息必帶 id;用 {tenant}:{id} 當鍵值
  • 去重:Redis/DB 換存 id → seen_at,重複即拒收(或只回 2xx 並忽略)
  • 冪等鍵:對「會改變狀態」的 API 要求 Idempotency-Key;同鍵僅執行一次
  • 回應策略:Webhook 建議 先 202,改為非同步處理與重試

Redis 去重示意

// 接續上例:在 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();
}

🏷️ 三、租戶隔離(Tenant Boundary)

  • 每租戶密鑰kid 對應每租戶的 key;永不共用
  • MAC 綁租戶:將 tenant_id 納入簽名字串或放 Header 並二次檢核
  • 路徑隔離/tenants/{id}/webhooks/events(API Gateway 層再校驗)
  • 資料域隔離:事件處理只寫入對應租戶的資源 namespace
  • mTLS(選配):高敏場景對關鍵事件要求 mTLS + 客製憑證

OPA(Rego)策略片段

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
}

📦 事件封包規格(Envelope)

{
  "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
  • 沒有時鐘同步 → 誤判過期 → 打 NTP、允許 ±300s
  • 多次重試導致重複寫入 → 使用 Idempotency-Key + 唯一索引
  • 單一金鑰全租戶共用 → 無法追責/輪替 → 改為 per-tenant key
  • 租戶標頭可被偽造 → 必須與簽章資訊/路徑雙重比對

📊 指標與告警(SLO)

  • Signature Fail Rate(按原因:stale/bad_mac/unknown_kid)
  • Replay Blocked / day(去重擋下數量)
  • Idempotency Skip Ratio(跳過比例)
  • Tenant Mismatch(越租戶事件)
  • MTTKR(Mean Time To Key Rotation)

✅ 落地檢核清單

  • [ ] X-Signature(版本/演算法/ts/kid/mac)上線;原文驗簽
  • [ ] 時間窗 ±300s;NTP 同步
  • [ ] Redis/DB 去重;要求 Idempotency-Key
  • [ ] per-tenant key;OPA/路徑雙重校驗租戶
  • [ ] Payload 大小/Schema 驗證;拒絕未知欄位
  • [ ] 接收端 202 + 非同步處理與重試策略
  • [ ] 指標與告警接入 SIEM / 監控面板

🎭 工程師小劇場

PM:為什麼我們的 Webhook 會出現「replayed」?
你:因為對方重送了三次;我們吃一次、跳過兩次。這叫「可靠,而不重複」。


🎯 小結

簽章保證來源、重放保證唯一、隔離保證邊界。
AI 介接的安全,不是靠幸運,而是靠這三把鎖。


🔮 明日預告:Day 13|Secrets Management 與金鑰輪替

談 Vault/KMS、短期憑證(STS)、密鑰分級與自動輪替實務。


上一篇
📍 Day 11:Tool / Function Gate——別讓模型亂按按鈕
系列文
AI都上線了,你的資安跟上了嗎?15
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言