✉️

訪問者の手を煩わせないメールフォームのSPAM対策(Nuxt 3 実装例付き)

2022/05/07に公開

メールがSPAMの標的にされるようになり随分経ちました。
メールアドレスを素の状態で mailto: しているサイトってどれくらいあるのでしょうね。

そんな状況でも、まだまだメールで連絡がほしい場面は多々あります。
この記事では、訪問者の手を煩わせない SPAM 対策を、可能な限り短いコードで行おうという主旨で(突っ込まれることを承知で)書きました。

とくにこだわりがなければ reCAPTCHA や Akismet を導入して終了ですが、それだと面白くないのでそれがいくつかの理由により選択肢から外れる場合もあるので実装方法を検討してみます。

基本的なSPAM対策

まず前提として、パーフェクトなSPAM対策というのは不可能ということを認識する必要があります。
最終的にひとがいることですから、最悪手作業でSPAM行為をすることを防ぐことはできません。

一方で。スパマーの目的は様々ですが、何かしらの(主に金銭的な)メリットがなければそれをすることはありません。
対策にかけるコストに対し、スパマーがそれを突破するための費用対効果が見合わなければ、対策としては充分かと思います。

原則としてメールフォームにする

メールアドレスを実体参照にするという古典的な方法は、実はいまでもクローラーの収集に対して相当程度有効です。
しかしながら一度(手作業でも)リスト化されてしまうと、次から次へ名簿業者を渡り歩くので、なるべく避けたいところです。
(ドメインに一律に info@ をつけてランダムにメールを送る違法な営業行為をする事業者もちらほらあります)

ユーザビリティを損なわず、スパマーだけを排除したい

贅沢を言えば、ユーザビリティを損ないたくありません。
現在主流の reCAPTCHA は「わたしはロボットではありません」というチェックを入れるだけで、そのユーザー操作をトラッキングしスパム判定をしています。
(以前のように「横断歩道を選んでください」といって出された画像がどれもこれも微妙で選択しづらいということはなくなりました)

それでも可能であれば、「わたしはロボットではありません」という(本来不要な)チェックを入れることすら、しなくてすむように実装したいところです。

効果がありそうな対策例

調べてみると、ほかにもいくつかの手法が見つかりました。

■ かんたんな質問

「5 + 3 は?」のような人間にとってはかんたんな質問を出し、それを答えられない場合は排除する手法。
これもスパム対策のために訪問者の手を借りる手法ではありますが、費用対効果は充分でしょう。

■ ハニーポット

スパマーは必須項目に漏れがあると送信ができなくなるため、すべてのフィールドを埋めて送信する傾向にあります。
ハニーポットはそれを逆手に取り、人間の目には見えないテキストフィールド(やチェックボックス)を用意し、それに値が入っていたら排除する手法です。
おそらく現状もっとも手軽でかつ合理的な対策だと思います。
訪問者に余計な作業をさせることはありませんし、display: none; な要素であればアクセシビリティの問題もありません。

懸念があるとすれば、表示されているかどうかを確認することはかんたんなので、スパマー側も対策が可能と思われること。
とはいえ width: 0px; のような「表示しているけど見えない」状態にすると、その場合はまたアクセシビリティの問題がでてきてしまいそうです。
このあたり詳しいかたぜひ教えてほしいです。

■ 位置情報を取得する

かわったところでは Geolocation API を使用するというのもありました。
スパマーは自分の位置情報を晒したくない心理がある一方で、通常の問合せ利用においては(不思議に思うことはあっても)問題はありません。

それでも位置情報は偽装することが可能なので、自動送信する過程で位置情報のセットをし、突破できてしまうスパマーはいるのではないかと思います。

■ 数秒の時間を必須にする

機械的に送信作業を行う場合、ながくても1秒以内にすべての操作が完了するでしょう。
一方で実際にフォームを送信する場合、(入力内容をすべて事前に用意していたとしても)数秒から10秒以上の時間がかかります。
フォーム送信するためには、少なくとも10秒程度の時間をかけなければ送れないようにすることで、場合によっては充分な対策となりえます。

スパマーとしては長い時間で、実際の送信者にはほぼ意識する必要のない時間(秒数)であれば有効ですが、その時間待てる bot があれば意味をなさないかもしれません。

■ その他

ほかにも「メッセージにURL文字列を許可しない」とか「いちどメールを送り記載のURLのフォームを利用する」などの対策はありました。
しかしながらいずれも、何かしら余計な手間ひまを「正常利用する訪問者」に対して課しているのがいまひとつなところです。

組み合わせるのは "あり"

これらを組み合わせて、ユーザビリティを損なうことなく、効果的な対策を行うというのはおそらく可能かと思います。
良い子のみんなは、ぜひ世の中のベストプラクティスにしたがって対策をとるのがよいでしょう。

しかし僕は。
悪い子なので、あまり手をかけずに、それでいて訪問者の手を一切煩わせることなく、費用対効果高くSPAM対策をしたいなと思いました。
それもバックエンドをもたず Netlify のような環境で。

対策方法

基本的な対策は、次のようにしました。

  1. フォームにハニーポットを用意する
  2. フォームのページに直接アクセスすることはできない(別のページからの遷移だけを許可する)
  3. フォームのページへの遷移時には、ユーザーが意識する必要のないページを経由する
  4. 途中に踏ませる経由ページも直接アクセスすることはできない

シンプルに次のようなサイトとします。

  • / ・・・ TOPページ
  • /contact ・・・フォーム
  • /success ・・・送信完了ページ
  • /auth ・・・経由ページ(ユーザーは知らないページ)

TOPページの「メールフォーム」へのリンクをクリックすると、途中で /auth を経由してから /contact を表示する想定です。
/success は今回はとくに説明に用いません。このページも同様に直接のアクセスができないようにしておくとよいでしょう)

単純すぎない?

ここまでで「突破されそう」というイメージをもったかたもいるでしょう。
実際 bot がリンクを踏みさえすれば、結局のところ /contact への遷移が可能であれば、それに対応するスパマーはいると思います。

しかし、この手法の良いところは、途中で経由するページにさまざまな対策を施すことで、容易にアップデートできる点です。
ざっとあげるだけで

  • 経由ページのURLを毎回変える
  • フォームのURLを毎回変える
  • フォームへのリンク(文字列やid, class等)を定期的に(もしくはランダムに)変える

今回は、単に「フォームへのリンクが画面内にある場合だけ遷移できるリンク」にします。
(おそらくこれだけでも自動化している bot への対策にはなりそうです)

Nuxt 3 で実装する

今回は短いコード量で実現したかったため Nuxt 3 を選択しています。

基本的な実装内容は次のとおりです。

  • ハニーポットを送信ボタンの後に display: none で設置する
  • 実際のメール送信はサーバー側で行う(Nuxt 3 の API Routes を使用)
  • RouteMiddleware でフォームへの遷移を制御する
  • 経由ページ /auth を実装する
  • /contact へのリンクが画面内にあるかどうかを判定して遷移させる

フォームにハニーポット「わたしはロボットです」を設置

まずはじめに「わたしはロボットです」というハニーポットを送信ボタン以降の場所に display: none で設置します。

  <!-- 抜粋(送信ボタンよりあとに記述) -->
  <label for="term" style="display: none;">
    <input v-model="imrobot" type="checkbox" name="term" class="required">
    わたしはロボットです
    ※もしあなたがロボットではない場合は✓を入れてはいけません
  </label>

ロボットが好きそうな attribute name="term" class="required" を設定してみました。

これを送信ボタンクリック時にチェックします。
ハニーポットにひっかかっても送信成功として扱ってよいでしょう。

pages/contact.vue
<script setup lang="ts">
const {
  name,
  email,
  message,
  valid, // バリデーション結果の Computed プロパティ
  send, // 送信
} = useSender()
const imrobot = ref(false) // ハニーポット用

const check = async () => {
  if (!valid) {
    throw new Error('Invalid data should not be sent')
  }
  const data = {
    name: name.value,
    email: email.value,
    message: message.value,
  }
  !imrobot.value && await send(data, location?.origin)
  console.log('Successfully sent')
  navigateTo('/success', { replace: true })
}
</script>

Composables ファンクションで /api/send に POST する send() を実装します。
Nuxt 3 は composables 内で export すると import 不要で使用できます。
上述のとおり .vue 内では const {} = useSender() のようにリアクティブな値や関数を呼び出せます。

composables/useSender.ts
const send = async (body: SendData, origin = '') => {
  const method = 'POST'
  const headers = { 'X-From': origin }
  const result = await $fetch('/api/send', { method, headers, body })
  return result
}

export const useSender = () => {
  const name = ref('')
  const email = ref('')
  const message = ref('')
  const valid = computed(() => name.value.length && email.value.length && message.value.length)
  return { name, email, message, valid, send }
}

今回は ref() を使っていますが、たとえば確認ページを挟むなど別ページでも使用する場合は useState() を使います。
const name = useState('form-name', () => '') とすれば name の値をどのページでも使い回せます。

サーバー側でメール送信

/api/send に POST された場合に、バックエンド側でメールを送信します。
今回は詳解しませんが、参考までに SendGrid による送信は次のような実装が考えられます。

server/api/send.post.ts
import sgMail from '@sendgrid/mail'

export default defineEventHandler(async (event) => {
  // オリジンのチェック
  const headers = event.req.headers
  if (headers['x-from'] !== headers.origin) {
    return createError({
      statusCode: 405,
      statusMessage: 'Not Allowed',
    })
  }
  // メール送信
  const { sendgridApiKey } = useRuntimeConfig() // nuxt.config.ts に記述しておく
  sgMail.setApiKey(sendgridApiKey)

  const body = await useBody(event)
  const data = {
    to: 'info@example.jp',
    from: 'no-reply@example.jp',
    replyTo: body.email,
    subject: 'お問い合わせメールを受信しました',
    text: `${body.name}さまよりお問い合わせをいただきました。\nお送りいただいた内容は以下のとおりです。\n\n${body.message}`,
  }
  try {
    // 必要に応じてバリデーションを実施する
    const result = await sgMail.send(data) // 送信
    return { result }
  } catch (error) {
    return 500
  }
})

このようにサーバー側でメール送信する api を用意し、 $fetch('/api/send', { method, headers, body }) のようにPOSTすればよいでしょう。
Nuxt 3 なら Netlify だけでメール送信可能ですね。
(この例では SendGrid を使用していますが Nodemailer などを使用することももちろん可能です)

RouteMiddleware を実装する

RouteMiddleware は、サイト内の遷移時(もしくは直接リクエストした際)に、ページをレンダリングする前に呼ばれるもの。ログイン済みかどうかなどでルーティングを制御する際に使用されますね。

RouteMiddleware の仕様としては

  • /contact にサーバー側でアクセスしたら(直接アクセスしたら)TOPページへ遷移する
  • /contact の遷移元が /auth じゃなければ、経由ページ /auth に遷移する

だけです。
すべてのページの遷移で関わるのでファイル名を .global.ts にし、自動的に呼ばれる RouteMiddleware にします。
nuxt.config.ts への記述はありません)

middleware/form.global.ts
export default defineNuxtRouteMiddleware(async (to, from) => {
  // コンタクトフォームをspamから守るためのページ遷移
  if (to.path === '/contact') {
    const replace = true
    // 直接コンタクトフォームへアクセスした場合はTOPへ誘導
    if (process.server) {
      return navigateTo('/', { replace })
    }
    // サイト内遷移の場合
    // /auth を挟んでいなければ一旦リダイレクト
    if (from.path !== '/auth') {
      const path = 'auth'
      const now = Date.now()
      const query = { to: to.fullPath, now }
      return navigateTo({ path, query }, { replace }) // /auth?to=/contact&now=... にリダイレクト
    }
    //  /auth から戻ってきた場合は正常遷移
  }
})

RouteMiddleware は遷移先(to)と遷移元(from)のふたつのパラメーターが利用できます。
どちらも useRoute() で取得できる内容と同じで、ここでは to.path, to.fullPathfrom.path のみ使用します。
fullPath にはクエリーパラメータ等の情報も付加されています)

今回は念の為、リダイレクト時点のタイムスタンプを取得し /auth にて経過ミリ秒をチェックします。
(なくても問題ないはずです)

サーバー側での送信時にチェックをし、(逆に)かかった秒数が短すぎる場合はスパムとして扱うのもよいでしょう

経由ページ /auth を実装する

仕様としては

  • サーバー側でアクセスしたら(直接アクセスしたら)エラーとして処理する
  • 経過ミリ秒をチェックし問題があればTOPページへ返す
  • とくに問題がなければ useRoute().query.to に遷移する(/contact に遷移する)
pages/auth.vue
<script setup lang="ts">
if (process.server) {
  throwError('😱 Oh no, something wrong...')
}
const route = useRoute()
const current = Date.now()
const { to, now: _now } = route.query
const now = _now ? Number((_now as string)) : 0
// 1秒経過後、もしくは、未来の日時が渡ってきたとき
if (current > (now + 1000) || current < now) {
  navigateTo('/')
}
// 正常遷移
navigateTo((to as string), { replace: true })
</script>

<template>
  <div>
    <NuxtLink to="/">TOP</NuxtLink>
  </div>
</template>

process.server だと(直接アクセス時の)サーバーサイドでの動作です。
本来ありえない(そもそも訪問者は知らない)ので、単純にアクセスを禁止します。

一方で、経過ミリ秒のほうは何かしらの予想しない事態も考えられるので、念の為TOPページに返します。
(もういちどリンクを踏んでもらうことになります)

基本的な対策はこれだけです。
効果があるとよいのですが。

/contact へのリンクが画面内にあるかどうかを判定する

bot は賢いので、どんなリンクがあるかを事前に知っていれば、TOPページのアクセス直後にそのリンクをクリックする操作をすることもできるでしょう。
VueUse の useElementVisibility() を使用すると、ターゲットの要素が画面内にあるかどうかを短いコードで判定することができます。

もちろんこれだけでは「ターゲットの要素の位置までスクロールさせてからリンクをクリックする」ことができるはずで不完全です。
とはいえ、「その周辺の要素が画面内に入るまでリンクを表示しない」などの対策を重ねることもできるので、よほど巨大なサイトでない限り、スパマーが個別に攻略する手間暇をかけることはないのではないかと推測します。

components/ContactLink.vue
<script setup lang="ts">
import { useElementVisibility } from '@vueuse/core'
const target = ref<HTMLButtonElement>(null)
const targetIsVisible = useElementVisibility(target) // boolean
const gotoContact = () => targetIsVisible.value && navigateTo('/contact')
</script>

<template>
  <button ref="target" @click="gotoContact">フォームを表示</button>
</template>

Vue.js 3 では ref() を使用しHTML要素を扱うことができます。
const targetIsVisible = useElementVisibility(target) とするだけで画面内か否かをかんたんに確認可能です。

これで「リンクを踏まないとフォームを表示できず」「リンクを踏むにも一手間かけさせる」ことができました。

ハニーポットがあるため、チェックボックスを✓するロボットは送信成功と誤認させることもできています。

最後に

さて、ここまで書いてきて何ですが、上記の対策がどこまで有効かは、やってみないと分かりません。
うまくいくとよいのですけどね。

そんなことよりこの記事を書くきっかけになったサイトは、月に数件しかメールが送られてきません。
手間隙かける価値があるかどうかは、自分でも、疑問です。

でもいいのです。
せっかく調べたし、いろいろ考えたのですから。
実装するのがエンジニアですよね?!

ほかにも(訪問者の手を煩わせない対策に)こんなのがあるよ、って方はぜひお知らせください!

Discussion