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

ブログ
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
image.png


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-KIX

cf-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を使う予定がない場合はぜひ設定しておきましょう !

一覧へ戻る