📫
[next.js 14 - app router] 問い合わせフォーム実装
概要
-
nodemailer を使ってサーバー側メール送信 →
route.ts
-
xss を使ってサーバー側のXSS攻撃対応 →
route.ts
-
edge-csrf を使ってCSRF対策 →
middleware.ts
-
next-recaptcha-v3 を使ってスパム対策 →
app/contact/page.tsx
など
サンプルリポジトリーはこちら、間違いや過不足のご指摘大歓迎です
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
について
csrfProtect
のcookie.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]
)
サンプルリポジトリー
以上。
過不足や間違いの指摘は大歓迎です!!
Discussion