Cloudflare WAFでNext.jsのServer Actionsを遮断しcurlで検証する

CloudFlareのWAFでServer Actionsのリクエストを遮断する
Next.js(App Router)の Server Actions を使っていないサイトで、
Cloudflare の WAF を使って Server Actions へのリクエストを入口で丸ごと遮断します。
そもそもnextjsのServer Actions はどう呼び出されるのか
Server Actions は、ブラウザから `Next-Action` ヘッダーを付けた POST リクエストとして呼び出されます。ヘッダーの値は Action を識別する ID です。
つまり、`Next-Action` ヘッダーを持つリクエストを入口で止めれば、Server Actions はオリジン(Next.js サーバー)に届かなくなります。
使っていないなら、ファイアウォールで遮断しておく
作っているウェブアプリが Server Actions を使っていないのであれば、WAF で遮断しておくのがおすすめです。
Server Actions を使っていなくても、Next-Action ヘッダー付きのリクエストは何もしなければそのまま Next.js サーバーまで届き、Next.js 側で処理されます。使っていない機能の入口を開けたままにしておく理由はありません。
Cloudflare 側で止めておけば、
- 将来Server Actionsまわりに脆弱性が見つかっても、オリジンまでリクエストが届かない
- 不審なリクエストがアプリのログやリソースを消費しない
というメリットがあります。
Cloudflare WAF の設定
Cloudflare のダッシュボードで対象のドメインを選び、次の場所からカスタムルールを作成します。
Security → Security Rules から Create Rules → Custom Ruleルールの設定内容は以下のとおりです。
・Rule name : Block Next.js Server Actions
・設定
Field: Header
Operator: exists
Value: next-action
・Choose Action : Block
next-action ヘッダーが「存在するかどうか」だけを見ているので、ヘッダーの値(Action ID)が何であってもブロックされます。また HTTP メソッドを条件にしていないため、POST だけでなく GET でもブロックされます。
curl で効いているか確かめる
ルールを作ったら、ヘッダーを付けたリクエストと付けないリクエストを比べて検証します。https://example.com/ は対象サイトの URL に置き換えてください。
URL="https://example.com/"
# 1. 対照用:ヘッダーなし(WAF前後とも通常どおり応答するはず)
curl -s -o /dev/null -w "normal : %{http_code}\n" "$URL"
# 2. Server Actions を模したPOST(WAF後は 403 になるはず)
curl -s -o /dev/null -w "next-action : %{http_code}\n" \
-X POST "$URL" \
-H "Next-Action: 0000000000000000000000000000000000000000" \
-H "Content-Type: text/plain;charset=UTF-8" \
--data '[]'
# 3. GETでもヘッダーがあればブロックされるか(ルールはメソッドを見ていないため 403 のはず)
curl -s -o /dev/null -w "GET+header : %{http_code}\n" \
-H "Next-Action: test" "$URL"実在する Action ID を使う必要はありません。ルールはヘッダーの存在だけを見ているので、ダミー値で検証できます。
判定の目安
| WAF導入前 | WAF導入後 | |
|---|---|---|
| 1. ヘッダーなし | 200など通常 | 200など通常(変わらない) |
| 2. 3. next-action付き | 403以外 | 403 |
①まで 403 になっていたら、ルールが広すぎて正常なアクセスまで止めているということなので、そちらも確認が必要です。
Cloudflare にブロックされたことを確実に見分ける
403 はオリジン側が返す可能性もあるので、本文と Ray ID も確認すると確実です。
curl -s -D - -X POST "$URL" -H "Next-Action: test" --data '[]' | grep -iE "^HTTP|cf-ray|blocked"Cloudflare のブロック画面であれば、本文に「Sorry, you have been blocked」と表示されます。出力された cf-ray の値でダッシュボードの Security → Events を検索すると、どのルール(Block Next.js Server Actions)が発動したかを確認できます。
実際に2つのサイトで試した結果
サイトA: WAFのルールが動作している
normal : 200
next-action : 403
GET+header : 403
HTTP/2 403
cf-ray: a51000e09e27d1c8-KIX
<h1 data-translate="block_headline">Sorry, you have been blocked</h1>
...①は 200 のまま、②③は 403 になり、本文も Cloudflare のブロック画面です。ルールが意図どおり発動していて、正常なアクセスも巻き込んでいません。
サイトB: ルールが未設定の場合
normal : 200
next-action : 404
GET+header : 200
HTTP/2 404
cf-ray: a510012d2b2ad3ed-KIXcf-ray ヘッダーが返っているので、Cloudflare は経由しています(プロキシはオン)。それでも②が 404、③が 200 で通っているので、リクエストは Cloudflare を素通りしてオリジンまで届いています。②の 404 は、オリジンの Next.js が「その Action ID は存在しない」と応答したものと考えられます。
判定を自動化するシェルスクリプト
ここまでのコマンドを1本にまとめ、URLを渡すだけで判定できるシェルスクリプト check-next-waf.sh を作りました。
できること
- Next.js かどうかの判定:
x-powered-by、x-nextjs-*ヘッダー、Vary: RSC、HTML 内の/_next/static/・self.__next_f・__NEXT_DATA__を確認します。さらにRSC: 1ヘッダーを付けたリクエストにtext/x-componentが返れば、App Router と判定します。 - CDN / WAF の検出:レスポンスヘッダーから、Cloudflare・CloudFront・Vercel・Akamai のどれを経由しているかを調べます。
- Server Actions がオリジンに届くかの判定:
Next-Actionヘッダー付きの POST と GET を送り、ブロックされるかを確認します。 - WAF の応答の表示:ブロックされた場合は、ステータス、
server・cf-rayなどのヘッダー、本文の抜粋を表示します。
使い方
chmod +x check-next-waf.sh
./check-next-waf.sh https://example.com/実行例(WAF が効いている場合)
対象: https://example.com/
[1] 通常アクセス(ヘッダーなし GET)
ステータス: 200
[2] CDN / WAF の検出
Cloudflare 経由 (cf-ray: xxxxxxxxxxxxxxxx-KIX)
[3] Next.js 判定
+ x-powered-by: Next.js
+ Vary に RSC / Next-Router-State-Tree
+ HTML に /_next/static/
+ HTML に self.__next_f (App Router の痕跡)
=> Next.js (App Router) と判定 [スコア 7]
[4] Server Actions を模したリクエスト
POST + Next-Action : 403
GET + Next-Action : 403
[5] 判定
POST + Next-Action はブロックされました
---- WAF 応答 ----
HTTP ステータス : 403
server : cloudflare
cf-ray : xxxxxxxxxxxxxxxx-KIX
本文(抜粋):
Sorry, you have been blocked ...
----------------
GET + Next-Action はブロックされました
==== 結論 ====
Next.js : はい (App Router)
CDN/WAF : Cloudflare
WAF ブロック : WAFによりブロックされました ✅
Server Actions: オリジンに到達しない
cf-ray (POST) : xxxxxxxxxxxxxxxx-KIX ← Cloudflare の Security Events で検索可WAF が効いていない場合は、結論が次のようになります。
WAF ブロック : 効いていない (Next-Action ヘッダー付きでも 404/200 を返した)
Server Actions: オリジンに到達する (POST に対しオリジンが 404 を返した)スクリプト全文
#!/usr/bin/env bash
#
# check-next-waf.sh
# 指定URLについて以下を判定する
# 1. Next.js サーバーかどうか(App Router / Pages Router の推定含む)
# 2. Server Actions 宛てのリクエスト(Next-Action ヘッダー付き)がオリジンまで到達するか
# 3. WAF/CDN が入っているか、ブロックされた場合はその応答内容
#
# 使い方:
# ./check-next-waf.sh https://example.com/
#
# macOS 標準の bash 3.2 でも動くように書いています。
set -u
if [ $# -lt 1 ]; then
echo "使い方: $0 <URL>" >&2
exit 1
fi
URL="$1"
UA="Mozilla/5.0 (check-next-waf.sh)"
TIMEOUT=15
FAKE_ACTION_ID="0000000000000000000000000000000000000000"
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
# ---------- 共通関数 ----------
# req <name> [curl追加オプション...]
# ヘッダーを $TMP/<name>.h、本文を $TMP/<name>.b に保存し、ステータスコードを出力
req() {
local name="$1"; shift
curl -s --max-time "$TIMEOUT" -A "$UA" \
-D "$TMP/$name.h" -o "$TMP/$name.b" \
-w "%{http_code}" "$@" "$URL" 2>/dev/null || echo "000"
}
# hdr <name> <header-name> : ヘッダー値を取得(最初の1件)
hdr() {
grep -i "^$2:" "$TMP/$1.h" 2>/dev/null | head -n1 | cut -d: -f2- | sed 's/^ *//; s/\r$//'
}
# has_hdr <name> <header-name>
has_hdr() {
grep -qi "^$2:" "$TMP/$1.h" 2>/dev/null
}
# body_has <name> <pattern>
body_has() {
grep -qiE "$2" "$TMP/$1.b" 2>/dev/null
}
# WAF のブロック応答らしいか
is_blocked() {
local name="$1" code="$2"
case "$code" in
403|406|429|501|503) ;;
*) return 1 ;;
esac
# ステータスだけでなく本文・ヘッダーの特徴も見る
if body_has "$name" "Sorry, you have been blocked|Attention Required|cf-error-details|Request blocked|Access Denied|The requested URL was rejected|ModSecurity|Forbidden"; then
return 0
fi
if has_hdr "$name" "cf-mitigated" || has_hdr "$name" "x-amzn-waf-action"; then
return 0
fi
# 特徴がなくても 403 ならブロックの可能性ありとして扱う
[ "$code" = "403" ]
}
# ブロック応答の中身を表示
show_block_response() {
local name="$1" code="$2"
echo " ---- WAF 応答 ----"
echo " HTTP ステータス : $code"
for h in server cf-ray cf-mitigated x-amz-cf-id x-amzn-waf-action x-amzn-requestid x-cache x-iinfo; do
v="$(hdr "$name" "$h")"
[ -n "$v" ] && printf " %-16s: %s\n" "$h" "$v"
done
echo " 本文(抜粋):"
sed -e 's/<script[^>]*>.*<\/script>//g' -e 's/<style[^>]*>.*<\/style>//g' -e 's/<[^>]*>/ /g' "$TMP/$name.b" \
| tr -s ' \t\r\n' ' ' | fold -w 100 | head -n 8 | sed 's/^/ /'
echo
echo " ----------------"
}
# ---------- 実行 ----------
echo "対象: $URL"
echo
# 1. 通常アクセス
echo "[1] 通常アクセス(ヘッダーなし GET)"
C_BASE="$(req base)"
echo " ステータス: $C_BASE"
if [ "$C_BASE" = "000" ]; then
echo " 接続できませんでした。URL・ネットワークを確認してください。"
exit 2
fi
echo
# 2. CDN / WAF の検出(通常アクセスのヘッダーから)
echo "[2] CDN / WAF の検出"
CDN=""
SERVER_HDR="$(hdr base server)"
if has_hdr base cf-ray || echo "$SERVER_HDR" | grep -qi cloudflare; then
CDN="Cloudflare"
echo " Cloudflare 経由 (cf-ray: $(hdr base cf-ray))"
fi
if has_hdr base x-amz-cf-id || hdr base via | grep -qi cloudfront; then
CDN="${CDN:+$CDN, }CloudFront"
echo " CloudFront 経由 (AWS WAF が付いている可能性あり)"
fi
if has_hdr base x-vercel-id; then
CDN="${CDN:+$CDN, }Vercel"
echo " Vercel 経由"
fi
if has_hdr base x-akamai-transformed || echo "$SERVER_HDR" | grep -qi akamai; then
CDN="${CDN:+$CDN, }Akamai"
echo " Akamai 経由"
fi
[ -z "$CDN" ] && echo " 既知の CDN/WAF のヘッダーは見つかりませんでした (server: ${SERVER_HDR:-なし})"
echo
# 3. Next.js 判定
echo "[3] Next.js 判定"
NEXT_SCORE=0
APP_ROUTER=0
if hdr base x-powered-by | grep -qi "next.js"; then
echo " + x-powered-by: Next.js"
NEXT_SCORE=$((NEXT_SCORE + 2))
fi
for h in x-nextjs-cache x-nextjs-prerender x-nextjs-stale-time x-nextjs-matched-path; do
if has_hdr base "$h"; then
echo " + $h ヘッダーあり"
NEXT_SCORE=$((NEXT_SCORE + 2))
fi
done
if hdr base vary | grep -qi "next-router-state-tree\|rsc"; then
echo " + Vary に RSC / Next-Router-State-Tree"
NEXT_SCORE=$((NEXT_SCORE + 2))
APP_ROUTER=1
fi
if body_has base "/_next/static/"; then
echo " + HTML に /_next/static/"
NEXT_SCORE=$((NEXT_SCORE + 1))
fi
if body_has base "self\.__next_f"; then
echo " + HTML に self.__next_f (App Router の痕跡)"
NEXT_SCORE=$((NEXT_SCORE + 2))
APP_ROUTER=1
fi
if body_has base "__NEXT_DATA__"; then
echo " + HTML に __NEXT_DATA__ (Pages Router の痕跡)"
NEXT_SCORE=$((NEXT_SCORE + 2))
fi
# RSC リクエストを送り、text/x-component が返れば App Router
C_RSC="$(req rsc -H "RSC: 1")"
if hdr rsc content-type | grep -qi "text/x-component"; then
echo " + RSC: 1 に text/x-component で応答 (App Router)"
NEXT_SCORE=$((NEXT_SCORE + 3))
APP_ROUTER=1
fi
if [ "$NEXT_SCORE" -ge 2 ]; then
IS_NEXT=1
if [ "$APP_ROUTER" -eq 1 ]; then
echo " => Next.js (App Router) と判定 [スコア $NEXT_SCORE]"
else
echo " => Next.js (Pages Router の可能性が高い。Server Actions は App Router の機能) [スコア $NEXT_SCORE]"
fi
else
IS_NEXT=0
echo " => Next.js の痕跡は見つかりませんでした [スコア $NEXT_SCORE]"
fi
echo
# 4. Server Actions を模したリクエスト
echo "[4] Server Actions を模したリクエスト"
C_POST="$(req post -X POST \
-H "Next-Action: $FAKE_ACTION_ID" \
-H "Content-Type: text/plain;charset=UTF-8" \
--data '[]')"
echo " POST + Next-Action : $C_POST"
C_GETH="$(req geth -H "Next-Action: test")"
echo " GET + Next-Action : $C_GETH"
echo
# 5. 判定
echo "[5] 判定"
BLOCKED=0
if is_blocked post "$C_POST"; then
BLOCKED=1
echo " POST + Next-Action はブロックされました"
show_block_response post "$C_POST"
fi
if is_blocked geth "$C_GETH"; then
BLOCKED=1
echo " GET + Next-Action はブロックされました"
[ "$C_GETH" != "$C_POST" ] && show_block_response geth "$C_GETH"
fi
if is_blocked base "$C_BASE"; then
echo " 注意: ヘッダーなしの通常アクセスもブロック系の応答 ($C_BASE) です。"
echo " ルールが広すぎるか、curl 自体が Bot 判定されている可能性があります。"
fi
echo
echo "==== 結論 ===="
if [ "$IS_NEXT" -eq 1 ]; then
echo "Next.js : はい$( [ "$APP_ROUTER" -eq 1 ] && echo " (App Router)")"
else
echo "Next.js : 判定できず / いいえ"
fi
echo "CDN/WAF : ${CDN:-検出なし}"
if [ "$BLOCKED" -eq 1 ]; then
echo "WAF ブロック : WAFによりブロックされました ✅"
echo "Server Actions: オリジンに到達しない"
else
echo "WAF ブロック : 効いていない (Next-Action ヘッダー付きでも ${C_POST}/${C_GETH} を返した)"
if [ "$IS_NEXT" -eq 1 ] && [ "$APP_ROUTER" -eq 1 ]; then
echo "Server Actions: オリジンに到達する (POST に対しオリジンが $C_POST を返した)"
if has_hdr post x-nextjs-action-not-found; then
echo " x-nextjs-action-not-found ヘッダーあり = Next.js が Action を処理しようとした"
fi
else
echo "Server Actions: リクエストはオリジンに到達するが、App Router と確認できないため対象外の可能性"
fi
fi
[ -n "$(hdr post cf-ray)" ] && echo "cf-ray (POST) : $(hdr post cf-ray) ← Cloudflare の Security Events で検索可"注意
- Next.js の判定は推定です。 ヘッダーや HTML に残る痕跡から判断しているため、
poweredByHeader: falseを設定していて、さらに静的エクスポートに近い構成のサイトでは、Next.js であっても見逃すことがあります。 - 403 以外を返すルールでは誤判定することがあります。 WAF のアクションを Block ではなく Managed Challenge などにしている場合、チャレンジ画面の応答が「ブロック」と判定されず、「通った」と表示されることがあります。
ServerActionsを使う予定がない場合はぜひ設定しておきましょう !