📫

[next.js 14 - app router] 問い合わせフォーム実装

2024/03/15に公開

概要

  • nodemailer を使ってサーバー側メール送信 → route.ts
  • xss を使ってサーバー側のXSS攻撃対応 → route.ts
  • edge-csrf を使ってCSRF対策 → middleware.ts
  • next-recaptcha-v3 を使ってスパム対策 → app/contact/page.tsxなど

サンプルリポジトリーはこちら、間違いや過不足のご指摘大歓迎です
https://github.com/shomtsm/nextjs14-app-contact

nodemailerでメール送信(next.jsサーバー → メールサーバー)

nodemailerをインストール

npm i nodemailer

nodemailerでメール送信の最小サンプル

  • 送信側のメールサーバー、ユーザー名、パスワードを環境変数に設定
  • route.tsはサーバー側なのでNEXT_PUBLIC_のプレフィックスをなしで設定
route.ts
import nodemailer from 'nodemailer'

export async function POST() {
  const host = process.env.CONTACT_MAIL_HOST
  const user = process.env.CONTACT_MAIL_USER
  const pass = process.env.CONTACT_MAIL_PW

  const transporter = nodemailer.createTransport({
    host,
    port: 465,
    secure: true,
    auth: { user, pass },
  })

 await transporter.sendMail({
  from: "from-email@sample.com",
  to: "to-email@sample.com",
  subject: "mail subject",
  text: "mail content",
})
}

xssでユーザー入力の無害化(next.jsサーバー)

xssをインストール

npm i xss

xssを使った最小サンプル

  • 基本は文字列をxssに通すだけ
  • メールアドレスは個別のバリデーションかけても、完全にxssを防げられるわけではないのでバリデーション後もxssを通す
  • 一応xssを通したあと空文字になってしまったらエラーレスポンス返す
    • 例えばnameの値は <script>alert('XSS')</script>の場合
    • xssを通したあとのname_safeの値は空になるので(xssライブラリの設定によっては、HTMLエンティティにエンコードされる場合もある)
route.ts
import xss from 'xss'
import { NextRequest } from 'next/server'
import { isValidEmail } from '@/components/form-contact.utils'

export async function POST(req: NextRequest) {
  const requestBody = await req.json()
  const { data } = requestBody
  const { name, email, message } = data

  const email_trimmed = email.trim()
  if (!isValidEmail(email_trimmed))
    return new Response('Invalid email address.', { status: 400 })

  const email_safe = xss(email_trimmed)
  if (email_safe === '')
    return new Response('Email is required', { status: 400 })
  
  const name_safe = xss(name)
  if (name_safe === '') return new Response('Name is required', { status: 400 })

  const message_safe = xss(message)
  if (message_safe === '')
    return new Response('Message is required', { status: 400 })

  // 残りのメール送信処理など
}

エンドポイント実装詳細(next.jsサーバー)

環境変数

.env
CONTACT_MAIL_HOST=<メールサーバホスト>
CONTACT_MAIL_USER=<問い合わせありがとうのメールの送信メアド>
CONTACT_MAIL_PW=<上記送信メアドのパスワード・メールサーバログイン用>
CONTACT_MAIL_DISPLAY_NAME=<送信者の表示名>
CONTACT_MAIL_BCC=<ありがとうメールのBCCメアド>
  • ユーザーに自動送信する「お問い合わせありがとう」のメールの送信実装である
  • ありがとうメールを管理者のメアドにもBCCで送信する(工数削減)
  • email(ユーザーのメアド)は自作関数でのバリデーションチェック
  • name(ユーザーの名前)とmessage(問い合わせ内容)はxssを通すだけ

エンドポイント実装

src/app/api/contact/route.ts
import nodemailer from 'nodemailer'
import xss from 'xss'
import { NextRequest } from 'next/server'
import { isValidEmail } from '@/components/form-contact.utils'

export async function POST(req: NextRequest) {
  const requestBody = await req.json()
  const { data } = requestBody
  const { name, email, message } = data

  const email_trimmed = email.trim()
  if (!isValidEmail(email_trimmed))
    return new Response('Invalid email address.', { status: 400 })

  const name_safe = xss(name)
  if (name_safe === '') return new Response('Name is required', { status: 400 })

  const message_safe = xss(message)
  if (message_safe === '')
    return new Response('Message is required', { status: 400 })

  const transporter = nodemailer.createTransport({
    host: `${process.env.CONTACT_MAIL_HOST}`,
    port: 465,
    secure: true,
    auth: {
      user: `${process.env.CONTACT_MAIL_USER}`,
      pass: `${process.env.CONTACT_MAIL_PW}`,
    },
  })

  const serviceName = process.env.CONTACT_MAIL_DISPLAY_NAME
  try {
    await transporter.sendMail({
      from: `"${serviceName} Team" <${process.env.CONTACT_MAIL_USER}>`,
      to: email_trimmed,
      bcc: `${process.env.CONTACT_MAIL_BCC}`,
      subject: `Thank You for Reaching Out to ${serviceName}`,
      text: `${name_safe} 様\n\nお問い合わせありがとうございます。\n\nお客様からのメッセージを受け取りました。現在、内容を確認しておりますので、詳細な回答をご用意でき次第、改めてご連絡いたします。\n\nお送りいただいた情報は以下の通りです:\n\n---\n名前:${name_safe}\nメールアドレス:${email_trimmed}\nお客様のメッセージ:\n\n${message_safe}\n---\n\n今後とも、${serviceName}をよろしくお願いいたします。\n\n${serviceName} チーム`,
    })
    return new Response('Successful mail transmission', { status: 200 })
  } catch (error) {
    return new Response('Mail Sending Error', { status: 500 })
  }
}

csrf攻撃対策(ミドルウェア)

edge-csrfをインストール

npm i edge-csrf

ミドルウェアでcsrf tokenを検証

src/middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import csrf from 'edge-csrf'

const csrfProtect = csrf({
  cookie: {
    secure: process.env.NEXT_PUBLIC_NODE_ENV === 'production',
  },
})

export async function middleware(req: NextRequest) {
  const res = NextResponse.next()

  // Check CSRF token
  const csrfError = await csrfProtect(req, res)
  if (csrfError) return new NextResponse('invalid csrf token', { status: 403 })

  return res
}

export const config = {}

cookie.secureについて

csrfProtectcookie.secureはHTTPS接続のときのみ送信するかどうかの設定で、ローカル開発はhttp://localhost:3000などhttpの状態でも送信テストできるように、プロダクション環境のみtrueにする

cookie.secureがfalseの場合: クッキーはHTTPSおよびHTTPを含むあらゆるタイプの接続経由で送信されます。これは開発環境など、セキュリティがそれほど重要でない状況や、HTTPSを使用できない状況で便利です。

つまり、ローカル開発環境では

.env.local
NEXT_PUBLIC_NODE_ENV='local'

にして、cookie.secureがfalseで、httpでもオッケーにする

cookie.secureがtrueの場合: クッキーはHTTPSプロトコルを使用している安全な接続経由でのみ送信されます。もしHTTPなどの非安全な接続を使用している場合、このクッキーは送信されません。これは本番環境で推奨される設定です。なぜなら、データの盗聴や改ざんを防ぐために通信が暗号化されるべきだからです。

つまり、プロダクション環境では

.env
NEXT_PUBLIC_NODE_ENV='production'

にして、cookie.secureがtrueとなり、httpを許さずhttpsのみ送信可能になる

リクエストheadersのX-CSRF-Token情報渡す

src/components/form-contact.tsx
// そのたのimport省略
import { headers } from 'next/headers'

export default function ContactForm() {
  // ...
  // headersからcsrf tokenを取得
 const csrfToken = headers().get('X-CSRF-Token') || 'missing'

  const handleSubmit = useCallback(
    async (e: React.FormEvent<HTMLFormElement>) => {
      // ...

      const response = await fetch('/api/contact', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          'X-CSRF-Token': csrfToken, // リクエストheadersにわたす
        },
        body: JSON.stringify({
          data: { name, email, message },
          token, 
        }),
      })
     
      // 以降省略
    },
    [executeRecaptcha, name, email, message]
  )
  // ...
}

csrf対策効いているかテスト

% curl -XPOST http://localhost:3000/api/contact
invalid csrf token%

invalid csrf tokenと出たのでオッケー。これでcsrf tokenの検証を通ったリクエストのみ送信されるようになる

スパム対策: ReCaptcha(クライアント)

next-recaptcha-v3をインストール

npm i next-recaptcha-v3

ReCaptchaProviderでラップする

.env
# env情報追加
NEXT_PUBLIC_RECAPTCHA_SITE_KEY=<あなたのReCaptcha サイトキー>
src/app/contact/page.tsx
import { ReCaptchaProvider } from 'next-recaptcha-v3'
import ContactForm from '@/components/form-contact'

export default function PageContact() {
  return (
    <ReCaptchaProvider
      reCaptchaKey={process.env.NEXT_PUBLIC_RECAPTCHA_SITE_KEY}
      useEnterprise={true}
    >
        <ContactForm />
    </ReCaptchaProvider>
  )
}

クライアント側でreCaptchaのtokenを取得して渡す

src/components/form-contact.tsx
'use client'
import { useReCaptcha } from 'next-recaptcha-v3'

export default function ContactForm() {
  const { executeRecaptcha } = useReCaptcha() // reCAPTCHA token取得

  const handleSubmit = useCallback(
    async (e: React.FormEvent<HTMLFormElement>) => {
      e.preventDefault()
      const token = await executeRecaptcha('form_submit')

      const response = await fetch('/api/contact', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          data: { name, email, message },
          token, // reCAPTCHA token渡す
        }),
      })
     
      // 以降省略
    },
    [executeRecaptcha, name, email, message]
  )

  return (
    <div>
      <form onSubmit={handleSubmit} noValidate={false} autoComplete='on'>
        {/* フォーム省略 */}
      </form>
    </div>
  )
}

リクエスト送信(next.jsクライアント→next.jsサーバー)

src/components/form-contact.tsx
<form onSubmit={handleSubmit} noValidate={false} autoComplete='on'>
{/* フォーム省略 */}
</form>
src/components/form-contact.tsx
// フォームバリデーション省略
 const handleSubmit = useCallback(
    async (e: React.FormEvent<HTMLFormElement>) => {
      e.preventDefault()
      const response = await fetch('/api/contact', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          data: { name, email, message },
        }),
      })
      if (response.ok) {
        alert('Successful mail transmission')
      } else {
        const errorMessage = await response.text()
        alert(`Mail Sending Error. (${response.status}) ${errorMessage}`)
      }
    },
    [executeRecaptcha, name, email, message]
  )

サンプルリポジトリー

https://github.com/shomtsm/nextjs14-app-contact

以上。
過不足や間違いの指摘は大歓迎です!!

Discussion