「我們公司形象站想加個 AI Chat widget — 客戶可以直接問問題,AI 回答介紹我們服務、給聯絡資訊。會不會被亂打?」
這是上個月一個客戶問我們的。我們自己 gomylife.tw 也有一樣的 widget — 公開、無登入、任何人可用。會不會被打?會。所以我們做了 5 層防護。
這篇拆完整架構:從 Apache vhost 強制覆寫 X-Forwarded-For 防 IP 偽造、到 in-memory rate limit、到 max_tokens 的 cost cap、到 SSE pass-through 串流。
實際效果:單一惡意攻擊者一天造成的成本上限壓在 NT$ 個位數(具體數字依設定而異)。
為什麼 AI Chat widget 是高價值攻擊目標 #
跟一般 Web 服務不同,AI Chat endpoint 有兩個獨特風險:
| 風險 | 一般 API | AI Chat API |
|---|---|---|
| 攻擊成本 | DoS 才有意義 | 每個 request 可以燒掉真實 API 費用 |
| 攻擊代價放大 | 1:1 | 數量級的不對稱(攻擊者付的流量費 ≪ 你付的 API 費) |
| 防護重點 | 阻止 request | 阻止 + 限制每 request 成本 |
這就是為什麼只做 rate limit 不夠 — 還要限制「每次 request 最多花多少」。
我們的整體架構 #
[browser]
↓ POST /api/chat (JSON, expect SSE)
[Apache reverse proxy] ← 第 1 層
↓ ProxyPass
[Next.js /api/chat] ← 第 2-4 層
↓ POST /chat/stream
[claude-proxy backend] ← 內網 only
↓ SSE
[Anthropic API]
↑ 逐字回串
5 層防護分布在這 4 個元件裡。
Layer 1:Apache 強制覆寫 X-Forwarded-For(IP 偽造防護) #
問題:攻擊者送 X-Forwarded-For: 8.8.8.8 偽造 IP,繞過 rate limit。
解法:在 Apache vhost 把 client 送來的 client-IP header 全部清掉,由 Apache mod_proxy 自己帶真實連線 IP:
<VirtualHost *:443>
ServerName your-site.example.tw
# 防 IP 偽造:清掉訪客送來的 client-IP headers
RequestHeader set X-Forwarded-For "expr=%{REMOTE_ADDR}"
RequestHeader unset X-Real-IP early
RequestHeader unset CF-Connecting-IP early
ProxyPass / http://nextjs-internal:3000/
ProxyPassReverse / http://nextjs-internal:3000/
</VirtualHost>
關鍵點:
RequestHeader set X-Forwarded-For "expr=%{REMOTE_ADDR}"— 用 Apache expression 把 client 送的 X-Forwarded-For 覆寫成真實連線 IP(%{REMOTE_ADDR})unset X-Real-IP early跟unset CF-Connecting-IP early—early意思是「在 ProxyPass 之前清掉」,後面 Apache 自己重新帶- 沒 Cloudflare 在前面時這 3 行才是真的「不能被偽造的 IP」
如果你站台有 Cloudflare 在前面:用 mod_remoteip + RemoteIPHeader CF-Connecting-IP 信任 CF IPs,不要 unset CF-Connecting-IP(那個就是真實 IP)。
Layer 2:In-memory Rate Limit #
單實例場景用 in-memory Map 就夠(多實例後再上 Redis):
// lib/rate-limit.ts
// 具體數值依站台流量規模調整 — 範例用 N 代表
const MINUTE_LIMIT = N; // 每 IP 每分鐘合理上限(個位數~雙位數)
const DAY_LIMIT = N * M; // 每 IP 每日上限
const minuteMap = new Map<string, Bucket>();
const dayMap = new Map<string, Bucket>();
export function checkRateLimit(ip: string) {
const m = check(minuteMap, ip, MINUTE_LIMIT, 60_000);
if (!m.ok) return { ok: false, scope: "minute", resetAt: m.resetAt };
const d = check(dayMap, ip, DAY_LIMIT, 86_400_000);
if (!d.ok) return { ok: false, scope: "day", resetAt: d.resetAt };
return { ok: true };
}
OPSEC 提醒:實際數值不要公開(包含這篇 blog 沒寫具體數字)。攻擊者拿到精確數值會「打到剛好不觸發」長期消耗。設定要對應你站台的真實流量規模。
抓 IP 的邏輯,順序很重要 — 先看 X-Forwarded-For(已被 Apache 覆寫成真實 IP),再 fallback:
export function getClientIp(req: Request): string {
const xff = req.headers.get("x-forwarded-for");
if (xff) return xff.split(",")[0]!.trim(); // 第一個 = 最近的 trusted proxy 帶的真實 IP
return req.headers.get("x-real-ip")?.trim()
?? req.headers.get("cf-connecting-ip")?.trim()
?? "unknown";
}
加 sweep 機制避免長時間運行 memory 一直漲:
let lastSweep = Date.now();
function sweep() {
const now = Date.now();
if (now - lastSweep < 5 * 60_000) return;
lastSweep = now;
for (const [k, v] of minuteMap) if (now >= v.resetAt) minuteMap.delete(k);
for (const [k, v] of dayMap) if (now >= v.resetAt) dayMap.delete(k);
}
Layer 3:Body Size + Message Length Guard #
防止「丟一個 100MB 的 message 過來」這種類 DoS:
const MAX_INPUT_LEN = 1500; // 訊息上限 1500 字
if (message.length > MAX_INPUT_LEN) {
return new Response(JSON.stringify({
error: `訊息太長(上限 ${MAX_INPUT_LEN} 字)`
}), { status: 400 });
}
配合 req.json() 預設的 1MB body 上限,雙保險。
為什麼設四位數上限:實測一般人問問題很少超過幾百字,多給足夠 buffer。攻擊者就算把每次都拉到頂,input tokens 也是有限的 — 對應 cost 很低。
Layer 4:max_tokens 限制 — 最關鍵的 cost cap #
這是最關鍵的一層 — 限制 Anthropic 回應 token 數:
await fetch(`${PROXY_URL}/chat/stream`, {
body: JSON.stringify({
model: "haiku", // 公開 endpoint 用 haiku 控成本
message,
max_turns: 1, // 單回合,不開放對話 multi-turn
max_tokens: TOKEN_CAP, // ← 設定合理上限(如四位數)→ 防無限產文 → 控 DoS 成本
system_prompt: SYSTEM_PROMPT,
session_id: body.sessionId,
}),
});
max_tokens cap 是關鍵 cost cap。算法:
單 IP 一天上限成本
= 模型每 token 單價 × max_tokens × 每日 rate limit 上限
依目前 Anthropic Haiku 定價跟我們的設定,單個攻擊者打滿一天,成本壓在 NT$ 個位數。攻擊者付的流量費 vs 我們付的 API 計算費,量級不對稱 — 但實際金額不大,經濟上不划算。
具體數字隨模型 / 設定 / 站台不同,重點是有 cap、cap 算得出來。
如果要更小心可以再加:
max_turns: 1, // 不允許 multi-turn 對話(避免上下文累積到上千 tokens)
Layer 5:SSE Pass-through(讓使用者體驗好 + 不重 buffer) #
讓 Claude 的回應逐字串流到瀏覽器,使用者一邊看一邊打字:
// 直接 pass-through 上游 SSE body,不要在 Next.js 端 buffer
return new Response(upstream.body, {
status: 200,
headers: {
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-cache, no-transform",
"x-accel-buffering": "no", // ← 告訴 Apache / Nginx 別 buffer
connection: "keep-alive",
},
});
Apache vhost 也要配合:
ProxyTimeout 300
SetEnv proxy-sendchunked 1
# 關 ModSecurity(如果有開)— 它會 buffer 完整 response 才送
<IfModule security2_module>
SecRuleEngine Off
</IfModule>
ModSecurity 不關掉的話 SSE 變一次性吐完,使用者體驗破壞 — 也是常見坑。
整體攻擊面評估 #
做完 5 層後的攻擊向量分析:
| 攻擊向量 | 我們的防護 |
|---|---|
| IP 偽造繞 rate limit | Apache RequestHeader set X-Forwarded-For "expr=%{REMOTE_ADDR}" — 攻擊者送的 header 被清掉 |
| 同一 IP 暴力呼叫 | in-memory rate limit(每分鐘 + 每日雙窗口) |
| 多 IP 分散攻擊(botnet) | max_tokens cap 限單次成本,總成本可控 |
| 大 body / prompt injection | message ≤ 1500 字 + system prompt 固定在 server 端 |
| 無限產文 token burning | max_tokens cap + max_turns=1 |
| 越過 Next.js 直接打 backend | claude-proxy 內網 only、公網 reach 不到 |
| Prompt leak | system_prompt 在 server 端注入,從不從 client 帶 |
Cost 攻擊評估 #
成本上限算式(不公開具體數字):
每次 cost ≈
(input tokens × 模型 input 單價)
+ (max_tokens cap × 模型 output 單價)
每日上限 ≈ 每次 cost × 每日 rate limit
依目前我們的設定 + Anthropic Haiku 定價,攻擊者單日 cost 壓在 NT$ 個位數。
Anthropic 每月額度給的話,這量級完全可吸收。
要再保險可以申請 Anthropic API 設 monthly spend cap,到頂就停 — 但實務上沒必要,我們上線數月還沒看過接近上限。
監控 #
我們在 Mattermost 頻道接 alert,三個觸發條件:
# 1. rate limit 觸發數
docker logs your-app 2>&1 | grep -E "rate limit|429" | wc -l
# 異常 = >100/小時
# 2. honeypot 觸發數(contact form 那邊)
grep "honeypot triggered" | wc -l
# 3. Anthropic API 帳單監測
curl https://anthropic.com/api/usage # 假設有這個 endpoint
我們刻意沒做的 #
| 沒做的 | 為什麼 |
|---|---|
| Cloudflare Turnstile | 5 層防護應該擋掉大部分 bot,等實際被打爆再加 |
| reCAPTCHA v3 | 同上 + 隱私顧慮(會打 Google) |
| Cloudflare WAF | 我們架構是直接 server,沒過 CF |
| Anthropic monthly spend cap | max_tokens 已 cap,雙保險過頭了 |
重點總結 #
| Layer | 元件 | 防什麼 | 工 |
|---|---|---|---|
| 1 | Apache vhost RequestHeader | IP 偽造 | 5 分鐘 |
| 2 | in-memory rate limit | 暴力打 | 30 分鐘 |
| 3 | Body / message length guard | 巨大 payload | 5 分鐘 |
| 4 | max_tokens cost cap | Token burning | 1 行設定 |
| 5 | SSE pass-through | UX + buffer 防護 | 15 分鐘 |
總工 1 小時左右。換來「攻擊者一天造成的成本壓在 NT$ 個位數」— 經濟上不值得攻擊。
想做類似的 AI Chat 整合 #
我們做 Web 應用整合 Claude / Anthropic API,包含:
- 公司形象站 AI Chat widget(hero 加 AI 對話)
- 客服自動回覆系統(接 LINE OA / 表單)
- 內部知識庫問答(接公司資料 + Claude RAG)
- AI 助手嵌入既有 Web 系統
聯絡:0912852835 / henryccy@icloud.com / LINE @3q3tw / 線上表單
延伸閱讀: