スパムメール踏み台を防ぐお問い合わせフォーム設計 Angular + Cloudflare Turnstile実装ガイド

公開日: 更新日:

目次

この記事のポイント

  • お問い合わせフォーム開発で直面した課題
  • スパムメール踏み台対策のための設計方針
  • Angular + Cloudflare Turnstileによる実装アプローチ
  • セキュリティとユーザビリティを両立する6桁確認コード認証

1. はじめに

弊社のWebサイトは、お問い合わせフォームとして、Googleフォームを使っていました。今回タイトルの通り、お問い合わせフォームを作成し、Googleフォームを置き換えることができました。

Angularを使って、簡単なWebアプリを作成したいと思っておりましたので、お問い合わせフォームという簡易的なWebアプリがちょうど良いと思いました。

TODOアプリは、フロントエンドやAPIサーバー、CRUD処理、DBなど幅広い技術を学習できる定番の題材ですが、お問い合わせフォームも実用的なWebアプリケーション開発の良い練習になると考えました。

実際に作ってみて、考えることや考慮することは多く、想像していたよりも大変でした。

2. 一般的なお問い合わせフォームの実装方法と課題

作成するにあたり、一般的なお問い合わせフォームを想像してみました。

2.1 従来方式の処理フロー

一般的なお問い合わせフォームは以下の流れで動作しますが、特に確認メール送信の部分でセキュリティリスクが発生します。

  1. フォーム入力・送信
  2. サーバー側でデータ検証・保存
  3. ユーザーに確認メール送信 ← スパム踏み台のリスク
  4. 管理者への通知
  5. 完了画面表示

2.2 課題

実際に検討してみると、このようなお問い合わせフォームには多くの課題や考慮すべき点があることが分かります。

従来方式の課題

  • スパムメールの踏み台にされる
  • botが無関係なメールアドレスを入力し、本文に無関係なURLを入力することで、弊社がスパムメールを送信することになる
  • APIサーバーの保護が不十分
  • バックエンドのAPIサーバーが保護されていないため、悪意のあるユーザやイタズラによる攻撃を受けてしまう

以下の記事のように、スパムの踏み台にされてしまいます。

この問い合わせですが、実はスパムメールの踏み台に使用されていました。

問い合わせフォームのメールアドレス欄に攻撃対象者のメールアドレスを記載すると、その対象者に我々のメールアドレスからロシア語の文章+URL付きでメールが送られてしまうのです。

問い合わせフォームを実装しただけなのにスパムメールの加害者になっていた話 #Security - Qiita

昨今、Webサイトのお問い合わせフォームを狙った大量メール送信攻撃が急増しています。

手口は非常に古典的で、フォームから問い合わせをした際に送信される「お問い合わせありがとうございました」メールを悪用します。

Webサイトのフォームで悪用被害急増中!加害者にならないためのフォーム設置講座 | さくらのホームページ教室

3. 実装方針を検討する

仮に、Google reCAPTCHA v3や、Cloudflare Turnstileを追加し、ある程度の対策をしても、メールを送信する実装は避けたいと思いました。

今後、AIの進化でスパムやbot側も、賢くなるはずです。またユーザがイタズラで、他人のメールを指定することはできてしまいます。

できるかぎり、不要なメールは送信したくないので、メール送信をするのではなく、問い合わせ毎に、確認用URLを発行する仕組みを検討しました。

3.1 そもそも、メールを送信する必要があるのか?

お問い合わせをしたユーザが、自分でどのような情報を送信したかという内容を確認する目的だと思います。この場合、問い合わせ毎に確認用URLを発行し、後日確認用URLにアクセスしてもらうことで、自身の問い合わせ内容を確認できる方法を検討しました。

確認用URLを発行するだけだと、確認用URLが分かってしまったら、第三者がアクセスできてしまいます。このリスクに対応するために、ランダムな6桁の数字の確認コードを表示するようにしました。

確認用URLにアクセスし、確認コードを入力することで、問い合わせ内容を確認できるようにしたいと思いました。

当初は、パスワードという表現で導入しようと思っておりました。しかし、問い合わせするだけなのに、パスワードを決める必要があるのはおかしいと思い、確認コードという表現で実装する方針にしました。

3.2 バックエンドAPIの保護

バックエンドのAPIは、ブラウザの開発者コンソールからすぐに確認できますので、curlやプログラムからリクエストされることを想定する必要があります。そのため、以下のようなセキュリティ対策を検討しました。

  • Cloudflare Turnstileによる認証
  • 地理的制限:日本国内からのアクセスに限定
  • カスタムヘッダーによるアクセス制御
  • レート制限:過度なリクエストを防止

4. 実現したいことを整理

具体的な要件を以下のように整理しました。

機能要件

  • お問い合わせフォーム送信後に、確認用URLと確認コードを発行する
  • 確認用URLの有効期限は、7日間にする
  • 確認用URLにアクセスし、お問い合わせ内容をダウンロードできる
  • 確認用URLにアクセスし、お問い合わせ内容を削除できる
  • お問い合わせ送信時、確認用URLアクセス時ともに、Cloudflare Turnstile のトークンを必須にする

セキュリティ要件

  • Cloudflareのセキュリティルールで、国内のみに絞る
  • Cloudflareのセキュリティルールで、ヘッダーに任意の名称の値の有無でブロックする
  • ドメインのブロック機能

非機能要件

  • 大量のリクエストが来ないことを想定
  • バックエンドは、セルフホスティングによる運用コストの最小化
  • Proxmox VEによる仮想化環境で、必要に応じたスペック調整が可能
  • Proxmox Backup Serverによる自動バックアップとリストア機能

5. 全体像とスクリーンショット

以下のような構成を検討しています。

5.1 技術スタック

フロントエンド

  • Angular v20:大好きなフレームワーク
  • Angular Material:統一されたUIコンポーネント
  • Cloudflare Workers 静的アセット:高速配信とキャッシュ
  • Cloudflare Turnstile:bot対策

バックエンド

  • Cloudflare Tunnel:セキュアな接続
  • Debian 12 on Proxmox:仮想化基盤
  • Golang:APIサーバー
  • PostgreSQL:データベース

5.2 スクリーンショット

新規問い合わせ

新規問い合わせ・初期表示
新規問い合わせ・初期表示
新規問い合わせ・入力後
新規問い合わせ・入力後
新規問い合わせ・「お問い合わせを送信する」をクリック後
「お問い合わせを送信する」をクリック後
新規問い合わせ・送信完了ページ
新規問い合わせ・送信完了ページ

送信完了後に自動的に送信完了ページに移動します。スクリーンショットにあるように、忘れずにコードのコピーURLのコピーをします。

お問い合わせ内容の確認

問い合わせ内容の確認・初期表示
お問い合わせ内容の確認・初期表示

先ほどコピーしたURL(送信完了ページのURL)にアクセスし、確認コードを入力します。

問い合わせ内容の確認・「内容を確認する」をクリック後
お問い合わせ内容の確認・「内容を確認する」をクリック後

確認ページにアクセスして頂くと、お問い合わせ内容の削除お問い合わせ内容のダウンロードができます。

6. 考慮すべきこと

6.1 フロントエンド開発での課題

Cloudflare Turnstileの実装の難しさ

Cloudflareログイン画面のTurnstileの振る舞い
Cloudflareログイン画面のTurnstileの振る舞い

フロントエンド開発において最も困難だったのは、Cloudflare Turnstileの制御でした。理想的には、Cloudflareのログイン画面のように、ユーザーが能動的にチェックを行う仕組みを実装したかったのですが、標準的な方法では実現が困難でした。

具体的には、以下のドキュメントにて、ダミーのサイトキーについての記載があります。具体的には、パスするサイトキーブロックされるサイトキーインタラクティブチャレンジを強制するサイトキーなどの記載があります。

Testing · Cloudflare Turnstile docs
Sitekey Description Visibility
1x00000000000000000000AA Always passes visible
2x00000000000000000000AB Always blocks visible
1x00000000000000000000BB Always passes invisible
2x00000000000000000000BB Always blocks invisible
3x00000000000000000000FF Forces an interactive challenge visible

Cloudflareのログイン画面の振る舞いは、インタラクティブチャレンジを強制するサイトキーに近く、インタラクティブチャレンジを強制するサイトキーと同様のサイトキーを発行したかったのですが、実際は、Turnstileの設計思想として「正当なユーザーの負担を軽減する」ことが優先されるため、強制的なインタラクティブチャレンジは実装できません。

私と同じような振る舞いを実装したい開発者がコミュニティに投稿されていますが、どちらも実装は難しいようです。

I don't think there is a way for you to force an interactive challenge.

The proposed "managed" challenge will use visitor data to determine whether an interactive challenge should be used.

If so, they will be requested to click; otherwise, it will be resolved automatically.

インタラクティブチャレンジを強制する方法はないと思います。

提案されている「管理型」チャレンジでは、訪問者データに基づいてインタラクティブチャレンジを使用するかどうかを判断します。

インタラクティブチャレンジを使用する場合はクリックを求め、使用しない場合は自動的に解決されます。

Turnstile always verify - Application Security / Turnstile - Cloudflare Community

Q

I want to ensure that every user, every single time they visit the page, goes through an interactive challenge.

To clarify further, whenever I try to log in to the Cloudflare dashboard, the Turnstile shows an interactive challenge every single time. That's exactly what I'm trying to achieve.

Is there any way to do that?

A

No. The goal of Turnstile is to reduce the amount of time legitimate users waste clicking the box. It can only be Managed, Non-Interactive, or Invisible.

質問

すべてのユーザーがページにアクセスするたびにインタラクティブなチャレンジを体験できるようにしたいと考えています。

さらに詳しく説明すると、Cloudflareダッシュボードにログインしようとすると、毎回Turnstileにインタラクティブなチャレンジが表示されます。まさにそれが私が実現しようとしていることです。

それを実行する方法はありますか?

回答

いいえ。Turnstileの目的は、正当なユーザーがボックスをクリックする時間を無駄にすることを減らすことです。設定できるのは、「管理」、「非対話型」、「非表示」のいずれかです。

Force turnstile to show "Verify you are a human" on page load - Application Security / Turnstile - Cloudflare Community

Angularへの組み込み

Angularの場合、以下のライブラリを使うことで、Cloudflare Turnstileを簡単に組み込むことができます。私は仕組みを理解したかったので、自分で実装を試みました。実際のコードは、別の記事で公開します。

pangz-lab/ng-cloudflare-turnstile: Cloudflare turnstile component for Angular

Playground - ng-cloudflare-turnstile playground

6.2 バックエンド設計での考慮点

メール送信回避による設計の工夫

従来のお問い合わせフォームでは確認メールを送信するのが一般的ですが、スパムメールの踏み台になるリスクを避けるため、確認用URL + 6桁確認コード方式を採用しました。

確認用URL + 確認コード方式の利点

  • スパム踏み台の防止:メール送信そのものを避けることで対策
  • セキュリティ:確認用URLと、6桁の確認コードの2要素でアクセス可能
  • 運用面:メール配信トラブルを回避

Cloudflare セキュリティルール

以下のスクリーンショットのように、バックエンドのAPIを保護するようにしました。

Cloudflareセキュリティルールの設定画面
Cloudflareセキュリティルールの設定画面

バックエンドAPIの保護機能

実装済みセキュリティ機能

  • リクエストボディのサイズ制限:過大なペイロード攻撃を防止
  • Cloudflare Turnstileの検証:bot対策の実装
  • JSONのパース検証:不正な形式のデータを拒否
  • 各フィールドの検証:入力値の妥当性確認
  • JWTトークンによる改ざん検証:データの整合性保証
  • 有効期限の概念:7日間でのデータ自動削除
  • 削除機能:ユーザー主導でのデータ削除

7. 技術実装の詳細

7.1 JWT実装によるデータ整合性の確保

確認用URLには、JWTトークンを含めることで改ざんを防止しています。以下は実際の実装コードです。

JWT生成処理

// JWT生成
func (s *Service) GenerateToken(uuid string) (string, time.Time, string, error) {
    // 7日後の23:59:59を設定
    now := time.Now()
    expiresAt := time.Date(
        now.Year(), now.Month(), now.Day()+7,
        23, 59, 59, 0, now.Location(),
    )

    // クレームの作成
    claims := jwt.MapClaims{
        "uuid": uuid,
        "iat":  time.Now().Unix(),
        "exp": expiresAt.Unix(),
    }

    // トークンの作成
    token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)

    // 署名してトークン文字列を生成
    // `s.jwtSecretKey` には `configs.GetEnv("JWT_SECRET_KEY", "")` の値がセットされている
    tokenString, err := token.SignedString(s.jwtSecretKey)
    if err != nil {
        log.Error().Err(err).Msg("トークンの署名処理に失敗しました")
        return "", time.Time{}, ErrCodeTokenSignError, errors.New("トークンの署名処理に失敗しました")
    }

    return tokenString, expiresAt, "", nil
}

JWT検証処理

// JWT検証
func (s *Service) VerifyToken(tokenString string) (string, string, error) {
    token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
        // 署名方式の検証
        if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
            return nil, errors.New("予期しない署名方式です")
        }
        return s.jwtSecretKey, nil
    })

    if err != nil {
        log.Error().Err(err).Msg("JWTトークンの解析に失敗しました")
        return "", ErrCodeTokenParseError, errors.New("無効なトークンです")
    }

    if !token.Valid {
        return "", ErrCodeInvalidToken, errors.New("無効なトークンです")
    }

    // クレームの取得
    claims, ok := token.Claims.(jwt.MapClaims)
    if !ok {
        return "", ErrCodeInvalidClaimsFormat, errors.New("クレーム形式が無効です")
    }

    // UUIDの取得
    uuid, ok := claims["uuid"].(string)
    if !ok {
        return "", ErrCodeUUIDClaimInvalid, errors.New("UUIDクレームが見つからないか無効です")
    }

    return uuid, "", nil
}

JWT検証前の事前バリデーション

パフォーマンス向上とセキュリティ強化のため、JWT署名検証前に基本的なフォーマットチェックを実装しています。

func (h *Handler) validateToken(c *gin.Context, token string) bool {
    if token == "" {
        h.respondWithError(c, http.StatusBadRequest, ErrCodeTokenRequired, "認証トークンが必要です", nil)
        return false
    }

    // JWTの基本的なフォーマット検証(a.b.c 形式)
    parts := strings.Split(token, ".")
    if len(parts) != 3 {
        h.respondWithError(c, http.StatusBadRequest, ErrCodeTokenInvalidFormat, "認証トークンの形式が無効です" nil)
        return false
    }

    // JWTヘッダー部分の固定値チェック
    expectedHeader := "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
    if parts[0] != expectedHeader {
        h.respondWithError(c, http.StatusBadRequest, ErrCodeTokenInvalidHeader, "認証トークンが無効です", nil)
        return false
    }

    // 各部分が空でないことを確認
    for i, part := range parts {
        if part == "" {
            log.Error().Int("part", i).Msg("JWTトークン・パートがNULなためエラー")
            h.respondWithError(c, http.StatusBadRequest, ErrCodeTokenInvalidFormat, "認証トークンの形式が無効です", nil)
            return false
        }
        // Base64URLの基本的な文字チェック(英数字、-、_のみ許可)
        base64URLPattern := `^[A-Za-z0-9_-]+$`
        matched, err := regexp.MatchString(base64URLPattern, part)
        if err != nil {
            log.Error().Err(err).Int("part", i).Msg("トークン検証中に内部エラーが発生しました")
            h.respondWithError(c, http.StatusInternalServerError, ErrCodeUnexpectedError, "認証処理中にエラーが発生しました", err)
            return false
        }
        if !matched {
            log.Error().Int("part", i).Msg("トークンに無効な文字が含まれています")
            h.respondWithError(c, http.StatusBadRequest, ErrCodeTokenInvalidFormat, "認証トークンの形式が無効です", nil)
            return false
        }
    }

    return true
}

事前バリデーションの効果

  • パフォーマンス向上:不正なトークンを早期検出し、重い署名検証処理を回避
  • 攻撃対策:明らかに不正な形式のトークンによるDoS攻撃を防止
  • ログ品質向上:具体的なエラー要因を特定し、トラブルシューティングを効率化
  • セキュリティ強化:期待されるヘッダー形式(HS256 + JWT)のみを許可

定数とエラーメッセージの定義

// エラーコード定数
const (
    ErrCodeTokenRequired        = "TOKEN_REQUIRED"
    ErrCodeTokenInvalidFormat   = "TOKEN_INVALID_FORMAT"
    ErrCodeTokenInvalidHeader   = "TOKEN_INVALID_HEADER"
    ErrCodeTokenSignError       = "TOKEN_SIGN_ERROR"
    ErrCodeTokenParseError      = "TOKEN_PARSE_ERROR"
    ErrCodeInvalidToken         = "INVALID_TOKEN"
    ErrCodeInvalidClaimsFormat  = "INVALID_CLAIMS_FORMAT"
    ErrCodeUUIDClaimInvalid     = "UUID_CLAIM_INVALID"
    ErrCodeUnexpectedError      = "UNEXPECTED_ERROR"
)

JWTトークン設計のポイント

  • 有効期限の工夫:7日後の23:59:59に設定し、日単位での管理を実現
  • 署名方式の検証:HMAC-SHA256以外の署名方式を拒否してセキュリティを確保
  • エラーハンドリング:各段階で詳細なエラーコードを返却し、デバッグを容易に
  • ログ出力:structured logging(zerolog)によるトラブルシューティング対応
  • クレーム最小化:UUIDのみを格納し、機密情報の漏洩リスクを最小化

ヘッダー固定値チェックの意義

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9{"alg":"HS256","typ":"JWT"}をBase64URLエンコードした値です。

  • アルゴリズム強制:HS256以外の署名方式を事前に拒否
  • 攻撃手法の制限:alg=noneやRS256切り替え攻撃を防止
  • 予期しないペイロード拒否:JWT以外の形式を早期検出

JWT検証フローの例

// JWTトークンの完全な検証処理
func (h *Handler) authenticateRequest(c *gin.Context) (string, error) {
    // 1. トークン取得
    token := c.Query("token")

    // 2. 事前バリデーション
    if !h.validateToken(c, token) {
        return "", errors.New("事前バリデーション失敗")
    }

    // 3. JWT署名検証
    uuid, errorCode, err := h.service.VerifyToken(token)
    if err != nil {
        h.respondWithError(c, http.StatusUnauthorized, errorCode, err.Error(), nil)
        return "", err
    }

    return uuid, nil
}

7.2 インフラ自動化とデプロイメント

Ansibleによるデプロイ自動化

開発からプロダクション環境への継続的デプロイを実現するため、Ansibleを活用したインフラ自動化を実装しています。

# playbook.yml - お問い合わせフォームAPIのデプロイ自動化
---
- name: Deploy Contact Form API
  hosts: contact_form_servers
  become: yes
  vars:
    app_name: contact-form-api
    app_user: api
    app_group: api
    app_dir: /opt/api
    app_backup_dir: /opt/api/old
    log_dir: /var/log/api
    local_build_path: /Users/user/Desktop

  tasks:
    - name: Create application directories
      file:
        path: "{{ item }}"
        state: directory
        owner: "{{ app_user }}"
        group: "{{ app_group }}"
        mode: '0755'
      loop:
        - "{{ app_dir }}"
        - "{{ app_backup_dir }}"
        - "{{ log_dir }}"

    - name: Backup current binary with rotation
      shell: |
        if [ -f {{ app_dir }}/{{ app_name }} ]; then
          timestamp=$(date +%Y%m%d%H%M%S)
          mv {{ app_dir }}/{{ app_name }} {{ app_backup_dir }}/{{ app_name }}-$timestamp
        fi
        # Keep only 10 most recent backups
        ls -1t {{ app_backup_dir }}/{{ app_name }}-* 2>/dev/null | tail -n +11 | xargs -r rm --
      args:
        executable: /bin/bash

    - name: Deploy application binary
      copy:
        src: "{{ local_build_path }}/{{ app_name }}"
        dest: "{{ app_dir }}/{{ app_name }}"
        owner: "{{ app_user }}"
        group: "{{ app_group }}"
        mode: '0755'

    - name: Deploy configuration files
      copy:
        src: "{{ item.src }}"
        dest: "{{ item.dest }}"
        owner: "{{ app_user }}"
        group: "{{ app_group }}"
        mode: "{{ item.mode }}"
      loop:
        - { src: ".env", dest: "{{ app_dir }}/.env", mode: "0600" }
        - { src: "deny_domains.txt", dest: "{{ app_dir }}/deny_domains.txt", mode: "0600" }

    - name: Set production environment
      lineinfile:
        path: "{{ app_dir }}/.env"
        regexp: '^GIN_MODE=.*'
        line: 'GIN_MODE=release'
        owner: "{{ app_user }}"
        group: "{{ app_group }}"
        mode: '0600'

    - name: Deploy systemd service
      template:
        src: templates/contact-form-api.service.j2
        dest: /etc/systemd/system/contact-form-api.service
        mode: '0644'
      notify: restart service

    - name: Reload systemd daemon
      systemd:
        daemon_reload: yes

  handlers:
    - name: restart service
      systemd:
        name: contact-form-api
        state: restarted
        enabled: yes
systemdサービステンプレート
# templates/contact-form-api.service.j2
[Unit]
Description=Contact Form API Server
After=network.target postgresql.service
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
Group={{ app_group }}
User={{ app_user }}

# ログディレクトリの準備
ExecStartPre=/bin/mkdir -p {{ log_dir }}
ExecStartPre=/bin/chown {{ app_user }}:{{ app_group }} {{ log_dir }}
ExecStartPre=/bin/sleep 10

# アプリケーション実行
ExecStart={{ app_dir }}/{{ app_name }}
WorkingDirectory={{ app_dir }}/
Restart=always
RestartSec=5

# 環境変数の設定
Environment=GIN_MODE=release
EnvironmentFile={{ app_dir }}/.env

# ログ出力設定(journald + ファイル両方)
StandardOutput=append:{{ log_dir }}/api.log
StandardError=append:{{ log_dir }}/api-error.log

[Install]
WantedBy=multi-user.target

デプロイ自動化の特徴

  • ゼロダウンタイム:systemdによるサービス管理で迅速な切り替え
  • 設定の統一:テンプレート使用で環境間の一貫性を確保
  • セキュリティ設定:systemdのセキュリティ機能でプロセス保護
  • ログ管理:journaldによる構造化ログの自動収集
デプロイ実行例
# 本番環境へのデプロイ
ansible-playbook -i inventory/production playbook.yml

# ステージング環境での動作確認
ansible-playbook -i inventory/staging playbook.yml --check

# 特定のタスクのみ実行
ansible-playbook -i inventory/production playbook.yml --tags="deploy-binary"

Ansible自動化の効果

  • デプロイの高速化:手動作業から数分の自動処理へ短縮
  • ヒューマンエラーの排除:設定ミスや手順漏れを防止
  • 環境の統一:開発・ステージング・本番環境の一貫性
  • ロールバック機能:問題発生時の迅速な復旧

8. まとめ

お問い合わせフォームという一見シンプルなWebアプリケーションを開発する過程で、予想以上に多くのセキュリティ課題や設計上の考慮点があることを学びました。

特に、スパムメールの踏み台対策は単なる技術的な問題ではなく、企業の信頼性に直結する課題だと思います。従来の「確認メール送信」という一般的なアプローチを見直し、確認用URL + 6桁確認コード方式という設計を採用することで、安全な問い合わせフォームを実装できました。

今回の開発で学んだ重要なポイント

  • セキュリティを優先:機能追加よりもリスク排除を優先
  • Cloudflareエコシステムの活用:Turnstile + Workers + Tunnelによる包括的セキュリティ

今後はこのシステムを運用しながら、実際の攻撃パターンやユーザーフィードバックを収集し、さらなる改善を図っていく予定です。Angular + Cloudflareという技術スタックは、個人開発から企業システムまで幅広く応用できる可能性を感じています。

小巻旭洋のプロフィール写真

小巻 旭洋

2014年からフリーランスとして活動し、2016年に株式会社フルーデンスを設立する。FileMaker開発歴は約10年。多数の企業システム構築を手がけ、特にデータベース設計と、JavaScript連携、Web連携、パフォーマンス最適化を専門としています。2025年から、Web業務システム・アプリ開発をメインに開発をしています。