「合言葉を5回間違えたら15分ブロック」という総当たり対策を実装しました。動作確認も済ませ、安全になったつもりでした。
しかし後日ログを読み返して、この対策には2つの穴があり、片方は攻撃者が私を締め出せる代物だと気づきました。実装した本人が気づくのに丸一日かかったので、同じ形のコードを書いている人向けに共有します。
前提の構成
- ローカルで動くNode.jsアプリ
- Tailscale Funnel で外部公開
- 外部からのアクセスには合言葉(トークン)を要求
- 合言葉を連続で間違えたIPは一定時間ブロック
const failures = new Map(); // ip -> { count, until }
function ipOf(req) {
return req.socket.remoteAddress || "?";
}
function isBlocked(ip) {
const f = failures.get(ip);
return f && f.until && Date.now() < f.until;
}
function noteFailure(ip) {
const f = failures.get(ip) || { count: 0, until: 0 };
f.count++;
if (f.count >= 5) {
f.until = Date.now() + 15 * 60 * 1000;
f.count = 0;
}
failures.set(ip, f);
}
一見よくある実装です。実際、ローカルでのテストでは期待どおりに動きました。
穴1: 全員が同じIPに見えるので、誰か1人の失敗で全員がブロックされる
トンネル経由のリクエストは、トンネルのクライアントがローカルへ転送します。したがって req.socket.remoteAddress は、どの利用者でも常に 127.0.0.1 です。
つまり failures のキーが全利用者で共有されます。
結果として起きること:
- 誰か1人が合言葉を5回間違えると、その後15分間、全員がブロックされる
- 攻撃者はこれを意図的に使える。わざと5回間違えるだけで、正規の利用者を締め出せる
- しかも攻撃者側のコストはほぼゼロで、15分ごとに繰り返せば実質的に締め出し続けられる
総当たりを防ぐつもりの機能が、サービス拒否の道具になっていたわけです。
対処
中継が付けるヘッダーから、実際の接続元を取り出します。
function ipOf(req) {
const fwd = req.headers["x-forwarded-for"];
if (fwd) return String(fwd).split(",")[0].trim(); // 最初がクライアント
const cf = req.headers["cf-connecting-ip"];
if (cf) return String(cf).trim();
return req.socket.remoteAddress || "?";
}
x-forwarded-for はカンマ区切りで連なるので、先頭を取ります。
なお、このヘッダーは信頼できる中継の背後でのみ意味を持ちます。誰でも直接接続できる構成では、クライアントが自由に詐称できるため、これだけでブロックの根拠にはできません。自分のトンネル経由でしか到達できない構成であることが前提です。
穴2: ブロック中でも、正しい合言葉なら通れてしまう
こちらは順序の問題です。私のコードはこうなっていました。
// 問題のあるコード
const key = url.searchParams.get("key") || "";
if (key !== TOKEN) {
const ip = ipOf(req);
if (isBlocked(ip)) return deny(429); // ← 鍵が違うときしか見ていない
noteFailure(ip);
return deny(403);
}
// 鍵が正しければ、ブロック中かどうかを一度も確認しないまま通過
ブロック判定が「鍵が違う」分岐の内側にあります。したがって総当たりの末に合言葉を当てられた場合、ブロックは何の役にも立ちません。
総当たり対策は「当てられるまでの時間を引き延ばす」ものなので、当てられた瞬間に無効化されるなら、対策としての意味が薄れます。
対処
判定を鍵の検証より前に出します。
function authorize(req, url) {
if (isLocal(req)) return { ok: true };
const ip = ipOf(req);
// ブロック中は鍵が正しくても通さない
if (isBlocked(ip)) return { ok: false, code: 429 };
const key = url.searchParams.get("key") || req.headers["x-app-key"] || "";
if (!TOKEN || key !== TOKEN) {
noteFailure(ip);
return { ok: false, code: 403 };
}
return { ok: true };
}
検証のしかた
修正後、攻撃者役と正規利用者役を分けて確認します。片方だけでは不十分です。
# 攻撃者(203.0.113.99)が5回失敗 → ブロックされるか
for i in $(seq 1 5); do
curl -s -o /dev/null -w "%{http_code} " \
-H "X-Forwarded-For: 203.0.113.99" \
"https://<公開URL>/secret.html?key=wrong"
done
# 403 403 403 403 403
# ブロック後、正しい鍵でも通らないか
curl -s -o /dev/null -w "%{http_code}\n" \
-H "X-Forwarded-For: 203.0.113.99" \
"https://<公開URL>/secret.html?key=正しい鍵"
# 429 ← 通ってはいけない
# 無関係の利用者は巻き込まれていないか
curl -s -o /dev/null -w "%{http_code}\n" \
-H "X-Forwarded-For: 198.51.100.5" \
"https://<公開URL>/secret.html?key=正しい鍵"
# 200 ← 通らなければいけない
最後の1つが重要です。防御は「止めたい相手を止められるか」だけでなく「止めたくない相手を止めていないか」の両方を満たして初めて成立します。前者だけ確認して安心すると、穴1のような可用性の問題を見落とします。
気づいたきっかけ
コードレビューではなく、ログを読み返したことでした。
[BAD-KEY(api read) ip=127.0.0.1] 14:36:44
[BAD-KEY(api read) ip=127.0.0.1] 14:36:59
[BAD-KEY(api read) ip=127.0.0.1] 14:37:14
[BAD-KEY(api read) ip=127.0.0.1] 14:37:29
[BLOCK 127.0.0.1 15min] 14:37:29
きれいに15秒間隔で並んでいます。これは攻撃ではなく、画面の自動更新が合言葉なしで叩き続けていた記録でした。つまり利用者が普通に使っているだけで自分をブロックできる状態だったわけです。
そこから「このIPは誰を指しているのか」を考え直して、穴1に行き着きました。
ログは攻撃を見つけるためだけのものではなく、自分の設計の歪みが形になって現れる場所でもあります。攻撃が来ていない時期にこそ、一度眺めてみる価値があります。