きっかけは、社内の棚卸しでフォームまわりの送信経路を洗い出していたときでした。
自社サイトの問い合わせフォームと、広告用のLP3本。合計4つの経路が同じバックエンドに向かっています。配信は S3 + CloudFront の静的配信なので、フォーム送信だけはブラウザから API Gateway のエンドポイントを直接叩く構成です。ここまでは以前から把握していました。
引っかかったのは、送信処理のエラーハンドリングを読んでいたときです。スパム判定で弾かれたときのメッセージが、ちゃんと用意されている。用意されているのに、送っているデータのどこにも、その判定に使うためのものが入っていない。
正直なところ、そこで一度手が止まりました。
静的サイトのフォームは「公開エンドポイントを直接叩いている」
まず前提の整理からです。うちの構成では、フォームの送信先とAPIキーを環境変数から読んで、そのままブラウザ側の fetch に渡しています。
---
// src/components/sections/ContactForm.astro
const contactEndpoint = import.meta.env.PUBLIC_CONTACT_ENDPOINT ?? '';
const contactApiKey = import.meta.env.PUBLIC_CONTACT_API_KEY ?? '';
---
<script>
const headers = { 'Content-Type': 'application/json' };
if (contactApiKey) headers['x-api-key'] = contactApiKey;
const response = await fetch(contactEndpoint, {
method: 'POST',
headers,
body: JSON.stringify(payload),
});
</script>
Astro では PUBLIC_ 接頭辞の環境変数だけがクライアントに渡ります。裏返すと、PUBLIC_CONTACT_API_KEY はビルド成果物のJSに文字列として焼き込まれ、配信された時点で誰でも読めます。DevTools を開けば1分もかかりません。
ここは前から分かっていた話ではあります。ただ「分かっていた」と「設計に反映されている」は別物でした。API Gateway の x-api-key は、AWSのドキュメントの立て付けとしても呼び出し元を識別して使用量を計測するためのもので、秘密として守れる前提の認証情報ではありません。公開されるキーで守れるのは「誰が呼んだか」ではなく、せいぜい「どのプランの枠を消費させるか」だけです。
そして公開キーということは、全訪問者がひとつの枠を共有しているということでもあります。つまり、誰か1人が大量に叩けば、そのしわ寄せは正規のお客様の送信に返ってきます。運用を考えると、ここが一番まずい。
想定外だったのは、防御が「クライアントにしか無かった」こと
エラーハンドリングはこうなっていました。
const data = await response.json().catch(() => ({}));
if (!response.ok) {
if (data.code === 'VALIDATION_ERROR' && data.errors) {
// 入力内容のエラーを項目ごとに表示する
} else if (data.code === 'RECAPTCHA_FAILED') {
this.errors.submit = 'スパム判定されました。時間を置いて再送してください。';
} else if (response.status === 429) {
this.errors.submit = 'アクセスが集中しています。しばらく時間をおいて再度お試しください。';
} else {
this.errors.submit = '送信に失敗しました。しばらく時間をおいて再度お試しください。';
}
return;
}
RECAPTCHA_FAILED と 429 の分岐が、4つの送信経路すべてに入っていました。ContactForm と、LP3本。きれいに揃っています。
一方で、送信している payload の中身を見ると、CAPTCHA のトークンに相当するフィールドがどこにもありません。つまり、判定結果を受け取る側の作法だけが4箇所に整備されていて、判定してもらうための材料を誰も送っていない状態でした。
今思えば、これは「以前の実装から分岐だけを引き継いだ」典型だと思います。LPを増やすときに送信処理をコピーして持っていったので、分岐も一緒に4箇所へ増えた。増えたけれど、元になった仕組みのほうは繋がっていない。
これを見て、自分の中の整理が変わりました。クライアント側にある if は防御ではありません。あれは、サーバ側で防御が成立していることを前提に、その結果をユーザーに伝えるための表示ロジックです。攻撃者はそもそもブラウザを使いません。curl で直接エンドポイントを叩けば、こちらのJSは1行も実行されないんですよね。
同じことは入力チェックにも言えます。うちのフォームはステップごとに必須チェックとメール形式チェックを持っていますが、あれもすべて表示側の親切機能で、防御としては数に入れられません。
どの層で止めるかを整理した
「対策する」と言い出すと、つい一番目立つCAPTCHAの話から始めてしまいがちです。ただ層ごとに向き不向きがはっきり違うので、先に表にして考えました。
| 層 | 止められるもの | 止められないもの | 効かせどころ |
|---|---|---|---|
| API Gateway 使用量プラン | 総量の暴走。1日/1か月あたりのクォータ超過 | 送信元の切り分け。公開キーだと全員が同じ枠を共有する | 課金と下流(メール送信)の暴発を止める最終ライン |
| WAF レートベースルール | 同一IP・同一条件からの短時間の連打 | IPを分散させた低速な投稿。閾値ちょうどでの厳密制御 | アプリに届く前にエッジで削る |
| アプリ層(ハニーポット・滞在時間・サーバ側バリデーション) | 素朴な自動投稿。フォーム構造を読まないボット | 人手による投稿。ブラウザを実際に操作する高度なボット | 追加コストゼロで効く一番安い層 |
| CAPTCHA(サーバ側でトークン検証) | スコアの低い自動化アクセス | 検証をサーバでやらない場合は何も止まらない | 最後の判定。ただしスコア閾値の調整が要る |
この整理で自分の中ではっきりしたのは、どの層も単独では穴があるということです。だから優先順位は「安くて確実に効く順」にしました。アプリ層 → 使用量プラン → WAF → CAPTCHA、の順です。
WAF については、期待しすぎない前提を持っておくのが大事だと考えています。レートベースルールは設定した閾値ぴったりで止まる仕組みではなく、直近のリクエストに重みを置いた推定でカウントされます。評価の反映にも数十秒の遅れが入ります。閾値を低めに設定しても、実際にブロックされるまでに設定値の倍近く通ってしまうことがある、というつもりで設計しておかないと「効いていないのでは」と疑うことになります。
実際に入れた設定
まず使用量プランです。ここは Lambda に一切手を入れずに総量を縛れるのが利点でした。
# 使用量プランを作成し、レート・バースト・月次クォータを設定する
aws apigateway create-usage-plan \
--name contact-public-plan \
--throttle "rateLimit=5,burstLimit=10" \
--quota "limit=3000,period=MONTH" \
--api-stages "apiId=xxxxxxxxxx,stage=v1"
# 既存の公開キーをプランに紐付ける
aws apigateway create-usage-plan-key \
--usage-plan-id yyyyyyy \
--key-id zzzzzzz \
--key-type API_KEY
数字の根拠は、直近の実績から取りました。うちの問い合わせは4経路合わせても月に数十件で、瞬間的に増えるのは広告を回している期間だけです。月3,000件のクォータは実績の2桁上ですが、ここは「正常系を絶対に巻き込まない」を優先しました。総量規制は事故を止めるためのもので、スパムを止めるためのものではない、という割り切りです。
次にWAFのレートベースルールです。
{
"Name": "contact-endpoint-rate-limit",
"Priority": 10,
"Statement": {
"RateBasedStatement": {
"Limit": 100,
"EvaluationWindowSec": 60,
"AggregateKeyType": "IP",
"ScopeDownStatement": {
"ByteMatchStatement": {
"FieldToMatch": { "UriPath": {} },
"PositionalConstraint": "STARTS_WITH",
"SearchString": "/v1/send",
"TextTransformations": [{ "Priority": 0, "Type": "NONE" }]
}
}
}
},
"Action": { "Block": {} }
}
ポイントは ScopeDownStatement で送信パスに限定しているところです。これを付けないと、同じWebACLを共有しているリソースのリクエストがまとめて数えられてしまいます。サイト全体の閲覧まで同じカウンタに乗ると、閾値の意味が変わってしまうんですよね。
そしてアプリ層です。ここが一番安く、一番早く効きました。
// Lambda 側。フォーム構造を読まないボットを先に落としてから、CAPTCHA検証に進む
export async function guard(body, headers) {
// 1. ハニーポット:画面上は隠しているフィールド。人間は絶対に埋めない
if (body.company_url) return { ok: false, code: 'SPAM_REJECTED' };
// 2. 滞在時間:フォーム表示から送信までが短すぎる送信を弾く
const elapsedMs = Date.now() - Number(body.rendered_at || 0);
if (!Number.isFinite(elapsedMs) || elapsedMs < 3000) {
return { ok: false, code: 'SPAM_REJECTED' };
}
// 3. サーバ側バリデーション:クライアントのチェックは信用しない
if (!body.email || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(body.email)) {
return { ok: false, code: 'VALIDATION_ERROR' };
}
// 4. CAPTCHA:トークンをサーバから検証APIへ投げ、スコアで判定する
const verified = await verifyToken(body.captcha_token, headers['x-forwarded-for']);
if (!verified.success || verified.score < 0.5) {
return { ok: false, code: 'RECAPTCHA_FAILED' };
}
return { ok: true };
}
ここは改善の余地があると自覚しているところです。滞在時間の3秒とスコアの0.5は、どちらも一般的な目安をそのまま置いただけで、うちの実データで検証した値ではありません。特にスコア閾値は、上げすぎると本物のお客様を弾いてしまう性質のものなので、本来は一定期間ログだけ取ってから決めるべきでした。今は弾いた件数と内容を記録しておいて、後から閾値を動かせるようにしています。
なお、CAPTCHAは必ずサーバ側でトークンを検証するところまでやらないと、防御としては成立しません。クライアントでトークンを取得しただけで安心してしまう構成をたまに見かけますが、それは今回自分が見つけた「分岐だけある」状態と本質的に同じです。
コストで見るとどうだったか
コスト比較を入れておかないと落ち着かないので、執筆時点の公開価格をもとにした東京リージョンの概算です。
| 層 | 追加の月額(概算) | 導入の手間 | 判断 |
|---|---|---|---|
| アプリ層(ハニーポット・滞在時間) | 0円 | フォームとLambdaに数十行 | 最優先で入れる |
| API Gateway 使用量プラン | 0円(リクエスト課金のみ) | CLI数行。コード変更なし | 入れる。総量の保険として |
| AWS WAF | 1,000円前後(WebACL+ルール+リクエスト従量) | ルール設計とログ確認の運用が必要 | 入れる。ただし過信しない |
| CAPTCHA | 0円(無料枠の範囲内) | フロントとサーバ両方の実装が必要 | 入れる。閾値調整は継続課題 |
月1,000円前後で、送信経路4本ぶんのエッジ防御と総量規制が揃うなら安いという判断です。ここは好みが分かれるところですが、問い合わせフォームは営業導線そのものなので、止まったときの損失を考えると迷う金額ではありませんでした。
何をもって「守れている」と言えるか
対策を入れたあと、受け入れの基準を決めておかないと運用に乗りません。うちでは次の4つを確認事項にしました。
ひとつめは、ブラウザを経由しない送信が落ちること。curl で直接エンドポイントを叩いて、ハニーポットも滞在時間もCAPTCHAトークンも無い状態のリクエストが、想定どおりのコードで弾かれるかを見ます。
ふたつめは、正規の送信が通ること。当たり前のようでいて、対策を足したあとに一番壊れやすいのはここです。4経路すべてから実際に送って、管理側にメールが届くところまで確認します。
みっつめは、429 を受けたときの画面表示が意図どおりであること。今回はじめて、あのエラー分岐が「本当に到達しうる分岐」になりました。使用量プランのクォータをテスト用に極端に下げて、実際に429を出して表示を確認しています。
よっつめは、弾いた記録が残っていること。ブロック数だけを見ていると、効いているのか効きすぎているのかが分かりません。弾いた理由の内訳(ハニーポット、滞在時間、スコア)を出せる状態にしておくのが、閾値を動かすための前提だと考えています。
振り返って
一番の学びは、技術的な穴そのものより「防御のつもりのコードが、実は表示ロジックだった」という認識のずれのほうでした。4箇所にきれいに揃った if があると、レビューでも整っているように見えてしまいます。実際には、その分岐が到達しうるのかを一度も確認していませんでした。
だからチェックの観点として、エラー分岐を追加するときは「この分岐に到達するリクエストを自分で作れるか」を確認する、というのを追加しました。作れないなら、それは実装が繋がっていないか、そもそも要らない分岐かのどちらかです。
しきい値まわりは、まだ経験則の置き値です。ここは実データが溜まってから決め直します。あとは今回CLIで作った設定をテンプレート化して、プレビュー環境と本番で同じ形に揃えられるようにしておきたいと考えています。手で作った設定は、作った本人がいなくなった瞬間に運用から外れるので。