AIが作ったフォームは、動くことと安全なことが別だった

relmea.com は、Cloudflare Pages の静的サイトに Pages Functions を組み合わせて作っています。フォームの裏側は次の4系統で、メールの送信には Resend を使っています。

  • 問い合わせ
  • 構築代行の事前ヒアリング
  • メルマガ登録
  • 無料診断と無料本のリード登録

これらはAIエージェントが実装しました。試験も書かれていて、普通に使う分には正しく動きます。

問題は「普通に使わない人」が来たときです。実装を書いたのと同じ目線で読み返しても、この種の穴は見つかりにくいものです。そこで relmea では、実装とは別に、攻撃者の立場でコードを読むレビューを入れています。指摘は重さで「高・中・低」に分けて返ってきます。

2026-10-24 に、無料診断と無料本のリード導線を本番に投入しました。検索に出さない設定のままで、人が来るリンクはまだ閉じていた状態です。導線を開く前にこのレビューを通したところ、「開く前に直す」ものが3件出ました。

開く前に出た3件:制限が「登録者」単位だったメール爆撃の穴

確認メールの制限を、別名ですり抜けられた(中)

確認メールには「同じ登録者に10分に1通」という制限がありました。一見、連打は防げています。

ところが、Gmail には +タグやドットを付けた書き方があり、見た目が違っても同じ受信箱に届きます。別名で登録者を増やせば、制限を何度でもすり抜けられました。第三者の受信箱へ確認メールを大量に送る「メール爆撃の口」になり得た、ということです。

診断の口に回数の制限が無かった(中)

診断の結果を受け取る口には回数の制限がなく、データベース(Cloudflare D1)の書き込み枠を食い潰せる状態でした。メールとは別の話ですが、同じ「入口が無制限」という問題です。

ボット判定の照合が足りなかった

ボット判定に Cloudflare Turnstile を使っていましたが、トークンを「どのサイトで、どの操作のために発行されたか」まで照合していませんでした。hostname と action を見ないと、別の場所で取ったトークンを流用できてしまいます。

同じ日に入れた直し

直しの中心は、数える単位を「登録者」から「受信箱」に変えたことです。

  • 受信箱の鍵は、+タグを落とし、Gmail はドットも除き、googlemail.com を gmail.com に寄せて作る
  • この鍵は数えるためだけに使い、送り先は入力どおりのアドレスのままにする
  • 同じ受信箱への確認メールは1日3通まで
  • 配信停止した受信箱へは、別名で申し込まれても送らない
  • メールの形式チェックを締める
  • 診断は登録者ごとに1行を上書きする形にする
  • Turnstile は hostname(relmea.com)と action を照合する

形式チェックでは、宛先を割る記号(< > , ; " ( ) [ ] \ :)や空白、制御文字を拒みます。ドメインは ASCII の英数字とハイフンだけに限り、IP の直書き、末尾のドット、全角やゼロ幅の文字を拒みます。配送側の正規化で同じドメインに寄ってしまい、見た目の違う別名を増やせるためです。

なお、WAF による IP 単位の制限は入れていません。Cloudflare の無料プランではレート制限のルールが1本で、期間が10秒に固定されており、この用途には合わなかったためです。

確認メールの制限:「登録者」で数えると別名で抜けられ、「受信箱」で数えると同じ箱に集まる

直す前

  • 数える単位は登録者
  • 同じ登録者に10分に1通
  • +タグ・ドットなどの別名で登録者を増やせば、同じ受信箱へ何通でも送れた

直した後

  • 数える単位は受信箱
  • +タグを落とし、Gmail はドットも除いて googlemail.com を gmail.com に寄せた鍵で数える
  • 同じ受信箱へは1日3通まで。送り先は入力どおり
  • 配信停止した受信箱へは別名でも送らない

同じ穴がメルマガ登録にも残っていた

リード導線を直した後に再レビューを通すと、同じ対策がメルマガ登録の口に入っていないことが分かりました。リード導線だけ塞いでも、メルマガ登録のフォームから同じことができる、という迂回路です。

フォームが複数あるサイトでは、1つの入口を直しても、同じ処理を持つ別の入口が残ります。今回も、別の入口の穴は再レビューで初めて出てきました。

2026-10-25 の朝に、同じ対策を入れました。

  • Turnstile(action は subscribe)を必須にする
  • 登録フォームのある6ページすべてを対象にする
  • 確認メールはリード導線と合算で、受信箱ごとに1日3通まで
  • メール配信サービスに書き込む前に、枠を取る
  • 配信停止した受信箱へは送らない

ここで、受け入れた挙動が1つあります。配信停止した受信箱から申し込むと、成功画面は出ますが、確認メールは出ません。

成功画面を出さずにエラーを返すと、そのアドレスが停止中かどうかが外から読めてしまうためです。本人にとっては少し不親切ですが、他人のアドレスの状態を探られることのほうを重く見ました。

自動返信が「任意の宛先へ文面を送れる口」になっていた

同じ朝に、「高」の指摘が1件出ました。今回の中で一番重いものです。

何が起きていたか

問い合わせと事前ヒアリングの自動返信は、入力されたアドレスへ [email protected] から送っていました。そして、本文と件名に入力(お名前や自由記述)を載せていました。

この組み合わせだと、他人のアドレスと任意の文面を入れて送信するだけで、relmea のドメインからその宛先へ、入力した文面が届きます。受け取る側からは relmea からのメールに見えます。フォームが踏み台になる形です。

どう直したか

自動返信そのものはやめず、送れる中身と条件を絞りました。

  • Turnstile を必須にする(action は問い合わせ用と事前ヒアリング用で分ける)
  • 自動返信の本文と件名に、入力を載せない。載せるのは固定の文言と、プランなど選択肢の表示名だけ
  • 管理者宛には、入力の全文を送る
  • 自動返信は、受信箱ごと1日3通の「別枠」を取れたときだけ送る
  • 配信停止した受信箱へは送らない
  • 枠の台帳が読めないときは、自動返信だけ送らない。管理者宛は届け、応答は成功のままにする
  • 自動返信は、応答を返した後に送る

台帳が読めないときの倒し方は、設計で迷う部分です。「読めないから制限なしで送る」に倒すと、障害の最中に踏み台が開きます。そこで、制限を外す側ではなく、送らない側に倒しています。応答を返した後に送る理由は、次の節で書きます。

問い合わせフォームの自動返信が踏み台になった流れと、直した後のルール
  1. 問題他人のアドレスと任意の文面を入れて送信すると、relmea のドメインからその宛先へ文面が届いた
  2. なぜ起きる自動返信が、入力されたアドレス宛に、入力した文面を本文と件名に載せて送っていた
  3. relmeaで起きたこと問い合わせと事前ヒアリングの自動返信が、[email protected] からこの形で送られていた
  4. どう直したボット判定を必須にし、自動返信に入力を載せず、受信箱ごと1日3通の別枠を取れたときだけ送る
  5. 現在のルール自動返信は固定の文言と選択肢の表示名だけ。停止した受信箱と台帳が読めないときは送らない。管理者宛には全文を届ける

直した後にもう一度攻撃者として読むと、2件出た

最初の直しの後、攻撃者の立場のレビューをもう一度通しました。直した箇所が新しい穴を作っていないかを見るためです。ここで「中」が1件、「低」が1件出ました。

枠を共有していたため、本人の確認メールが止まる(中)

自動返信の枠を、登録の確認メールと共有していました。そのため、相談フォームを別名で3回送るだけで、本人の登録の確認メールをその日止められる状態でした。自動返信と確認メールの枠を分け、別枠にしました。

応答の速さの差で、停止の有無が読める(低)

自動返信を送る処理が応答の前にあると、「送る場合」と「送らない場合」で応答の速さが変わります。この差から、その受信箱が配信停止中かどうかが読めます。応答を返した後に送るようにして、差が出ないようにしました。

「直した後にもう一度読む」工程は、手間に見えます。ただ今回は、1回目の直し方に中程度の穴が1つ残っていました。この工程を省いていたら、そのまま出ていたことになります。

確かめたこと、まだ残っている穴、自分のフォームを点検する観点

確かめたこと

試験は167件すべてが通っています。わざと不具合を入れる変異を18通り試し、すべて試験が検出しました。実装とは別のコンテキストで動く検品役も、合格としています。試験の項目には、次のようなものがあります。

  • ボット判定の鍵が無ければ、エラーを返して何も送らない
  • トークン無し、別の操作や別サイトのトークンは拒否して、何も送らない
  • 宛先を割るメールアドレスは拒否する
  • 自動返信に自由記述を載せない(管理者宛には全文が届く)
  • 同じ受信箱(別名を含む)への自動返信は1日3通まで。4通目以降も管理者宛は届き、応答は同じ
  • 相談フォームを何度送っても、本人の確認メールの枠は減らない
  • 自動返信は応答の後に送る
  • 配信停止した受信箱と、台帳が読めないときは送らない

ただし、これは「見つけた穴を塞いだ」ことの確認です。穴がもう無いという保証ではありません。

受け入れた挙動

同じ日に4回以上相談した人と、配信停止した人には、自動返信が届きません。相談の本体は管理者に届くので、こちらからの返信で拾う前提にしています。

まだ残っている穴

2026-10-25 時点で、次の5つは未対応です。

  • 管理者宛のメールに回数の上限が無い(ボット判定を1回通るごとに1通届く)
  • IP 単位・サイト全体での1日の上限が無い
  • メール配信サービスの契約上の1日の上限を確かめていない
  • Gmail 以外の別名や catch-all(どのアドレスでも届く独自ドメイン)は、受信箱の枠をすり抜ける
  • メルマガで、確認済みの購読者の関心の切り替えや配信の開始を第三者が起こせる。応答で購読状態が読める

最後の1件は、「確認済みの人にも確認メールを送り、本人がリンクを押したときだけ切り替える」方針に決めました。実装はこれからです。

relmea のフォームで 2026-10-25 時点に未対応の穴5つ
  1. 管理者宛回数の上限が無い。ボット判定を1回通るごとに1通届く
  2. 全体の上限IP 単位・サイト全体での1日の上限を持っていない
  3. 配信サービス契約上の1日の上限をまだ確かめていない
  4. Gmail 以外の別名別名や catch-all のドメインは、受信箱の枠をすり抜ける
  5. 購読状態確認済みの購読者の切り替えを第三者が起こせる。本人がリンクを押したときだけ切り替える方針に決定済み

自分のフォームを点検する観点

AIで作ったフォームでも、手で作ったフォームでも、次の項目は同じように確かめられます。

  • 自動返信の本文と件名に、入力した文字列を載せていないか
  • 送信の制限を、登録者ではなく受信箱の単位で数えているか。別名で単位が増えないか
  • 配信停止した人に、別名や再申し込みで送ってしまわないか
  • 制限の台帳が読めないとき、「制限なしで送る」側に倒れていないか
  • 応答の中身や速さで、停止中か購読中かといった状態が外から読めないか
  • ボット判定のトークンを、発行されたサイト(hostname)と操作(action)まで照合しているか
  • 1つのフォームを直したら、同じ処理を持つ別のフォームにも同じ穴が無いか探したか
  • 直した後に、もう一度攻撃者の立場で読み直したか

全部を完璧に塞ぐのは難しく、relmea にも上に書いた穴が残っています。それでも、残っている穴を一覧にして、どれが未対応かを把握しておけば、次にどれから手を付けるかを決めやすくなります。