# =============================================================================
# wp2shell virtual patch for ModSecurity (Apache mod_security2 / libmodsecurity3)
# -----------------------------------------------------------------------------
# Blocks the unauthenticated WordPress REST "batch/v1" route/handler-confusion
# SQL-injection -> RCE chain (wp2shell):
#   CVE-2026-63030  REST API batch route/handler confusion   (WP 6.9 - 7.0.1)
#   CVE-2026-60137  WP_Query author__not_in SQLi (facilitator) (WP 6.8 - 7.0.1)
# Fixed in WordPress 7.0.2 / 6.9.5 / 6.8.6 (released 2026-07-17).
#
# THIS IS A STOPGAP, NOT A PATCH. It buys time for un-upgraded sites; it does not
# make an unpatched site safe. Push customers to update; retire these rules after.
#
# Design goal: block every currently-known reach vector AND bypass while causing
# no collateral damage to legitimate REST batch traffic. It does that by NOT
# blocking "a batch request" outright. It only blocks a batch request whose
# sub-request "path" is not single-slash-rooted (an absolute URL, protocol-
# relative //, leading backslash, or bare relative path). That non-rooted path is
# the "http://...:bad/" seed the exploit REQUIRES to trigger the index desync.
# Legitimate batch clients always send rooted paths (/wp/v2/...), so they pass.
#
# Covers: pretty-permalink (/wp-json/.../batch/v1), plain-permalink query
# (?rest_route=/batch/v1), plain-permalink POST body (rest_route=/batch/v1), the
# multipart "rest.route" name-normalization bypass (PHP folds . space [ -> _),
# case/trailing-slash/backslash/encoding variants, and (defense-in-depth) the
# nested-batch form used to reach the vulnerable dispatch depth.
#
# PREREQUISITES (verify on the fleet before relying on these rules):
#   - SecRequestBodyAccess On            (bodies must be parsed)
#   - SecRuleEngine On                   (start in DetectionOnly for burn-in)
#   - The body must be FULLY inspected. The reported Cloudflare bypass pads
#     ~50-60 KB of junk ahead of the payload to slip under a body-inspection cap.
#     ModSecurity's default SecRequestBodyLimit is 13107200 (~12.5 MB), so a
#     50-60 KB body is inspected in full by default -- but ONLY if the site is not
#     configured with a low SecRequestBodyLimit + SecRequestBodyLimitAction
#     ProcessPartial. Confirm:
#         SecRequestBodyLimit 13107200
#         SecRequestBodyLimitAction Reject     # (not ProcessPartial)
#     These are GLOBAL directives with fleet-wide impact -- verify current values,
#     do not blindly override. See the deployment runbook.
#   - Rule IDs 210005-210009 must not collide with existing custom rules. Renumber
#     if they do (keep 210005 in phase:1 and the rest in phase:2).
#
# NOTE ON THE ARGS PATH SELECTORS: ModSecurity flattens JSON arrays into arg names
# like json.requests.array_0.path and multipart/form into requests[0][path]. The
# [^.]+ / [^\]]* wildcards make the selectors resilient to array-naming
# differences between ModSecurity v2 and v3/Coraza, but CONFIRM against your engine
# with the oracle test in the runbook before switching to blocking mode.
#
# NGINX LIMITATION: On Nginx with fastcgi_pass, the request body is consumed before
# ModSecurity phase 2, preventing body inspection. Rules 210006/210008 will NOT fire
# on Nginx. Rule 210020 (phase 1, at end of file) provides fallback protection by
# blocking unauthenticated batch endpoint access.
# =============================================================================


# ---- 210005 (phase:1) -------------------------------------------------------
# WordPress treats a broader set of media types as JSON than ModSecurity's stock
# JSON trigger does (see wp_is_json_media_type()). Force JSON body parsing for
# every media type WordPress would accept, so the phase:2 selectors below can see
# json.requests.*.path in the body. pass/nolog: this only sets the body processor.
SecRule REQUEST_HEADERS:Content-Type "@rx (?i)(?:^|[\s,])application/(?:[\w!#\$&-\^\.\+]+\+)?json(?:\+oembed)?(?:$|[\s;,])" \
    "id:210005,phase:1,pass,nolog,t:none,\
    ctl:requestBodyProcessor=JSON"


# ---- 210006 (phase:2) -------------------------------------------------------
# Pretty-permalink reach: /wp-json/.../batch/v1 (any depth, subdir, index.php
# PATHINFO, case, trailing / or \, raw or %-encoded). DENY only when a top-level
# batch sub-request "path" is NOT single-slash-rooted. If no such arg exists (a
# normal request, or a legit batch with rooted paths) the chain does not match.
# NOTE: Uses REQUEST_URI_RAW for Nginx - try_files rewrites REQUEST_URI to /index.php
SecRule REQUEST_URI_RAW "@rx (?i)/.*batch/v1[/\x5c]*(?:\?|$)" \
    "id:210006,phase:2,deny,status:400,log,t:none,\
    msg:'wp2shell: WordPress REST batch sub-request path not single-slash-rooted',\
    logdata:'%{MATCHED_VAR_NAME}=%{MATCHED_VAR}',\
    tag:'attack-injection-sql',tag:'wp2shell',tag:'CVE-2026-63030',tag:'CVE-2026-60137',chain"
    SecRule ARGS:/^json\.requests\.[^.]+\.path$/|ARGS:/^requests\[[^\]]*\]\[path\]$/ "!@rx ^/(?![/\x5c])" "t:none"


# ---- 210007 (phase:2) -------------------------------------------------------
# Plain-permalink reach: rest_route=/batch/v1 in the query string OR the POST body
# (ARGS covers both). The name selector matches the raw aliases PHP normalizes to
# rest_route (underscore, dot, space/'+', unmatched '[', and leading spaces) --
# this is what closes the multipart "rest.route" bypass. Same rooted-path guard.
SecRule ARGS:/^[\x20]*rest[_\x2e\x20\x5b]route$/ "@rx (?i)^/batch/v1[/\x5c]*$" \
    "id:210007,phase:2,deny,status:400,log,t:none,\
    msg:'wp2shell: WordPress REST batch (rest_route) sub-request path not single-slash-rooted',\
    logdata:'%{MATCHED_VAR_NAME}=%{MATCHED_VAR}',\
    tag:'attack-injection-sql',tag:'wp2shell',tag:'CVE-2026-63030',tag:'CVE-2026-60137',chain"
    SecRule ARGS:/^json\.requests\.[^.]+\.path$/|ARGS:/^requests\[[^\]]*\]\[path\]$/ "!@rx ^/(?![/\x5c])" "t:none"


# ---- 210008 (phase:2) -- defense-in-depth -----------------------------------
# Core does NOT allow /batch/v1 as a batch sub-route, but custom code can opt it
# into allow_batch v1. Where it does, require every NESTED routing path to be
# single-slash-rooted. Scoped tightly (only fires when a sub-request path is itself
# /batch/v1) so ordinary requests[] data in normal endpoint bodies is not treated
# as a route. Pretty-permalink (REQUEST_URI_RAW) form.
# NOTE: Uses REQUEST_URI_RAW for Nginx - try_files rewrites REQUEST_URI to /index.php
SecRule REQUEST_URI_RAW "@rx (?i)/.*batch/v1[/\x5c]*(?:\?|$)" \
    "id:210008,phase:2,deny,status:400,log,t:none,\
    msg:'wp2shell: nested WordPress REST batch path not single-slash-rooted',\
    logdata:'%{MATCHED_VAR_NAME}=%{MATCHED_VAR}',\
    tag:'attack-injection-sql',tag:'wp2shell',tag:'CVE-2026-63030',tag:'CVE-2026-60137',chain"
    SecRule ARGS:/^json\.requests\.[^.]+\.path$/|ARGS:/^requests\[[^\]]*\]\[path\]$/ "@rx (?i)^/batch/v1[/\x5c]*$" "t:none,chain"
        SecRule ARGS:/^json\.requests\.[^.]+(?:\.body\.requests\.[^.]+)+\.path$/|ARGS:/^requests\[[^\]]*\](?:\[body\]\[requests\]\[[^\]]*\])+\[path\]$/ "!@rx ^/(?![/\x5c])" "t:none"


# ---- 210009 (phase:2) -- defense-in-depth -----------------------------------
# Plain-permalink twin of 210008 (rest_route incl. normalization aliases).
SecRule ARGS:/^[\x20]*rest[_\x2e\x20\x5b]route$/ "@rx (?i)^/batch/v1[/\x5c]*$" \
    "id:210009,phase:2,deny,status:400,log,t:none,\
    msg:'wp2shell: nested WordPress REST batch (rest_route) path not single-slash-rooted',\
    logdata:'%{MATCHED_VAR_NAME}=%{MATCHED_VAR}',\
    tag:'attack-injection-sql',tag:'wp2shell',tag:'CVE-2026-63030',tag:'CVE-2026-60137',chain"
    SecRule ARGS:/^json\.requests\.[^.]+\.path$/|ARGS:/^requests\[[^\]]*\]\[path\]$/ "@rx (?i)^/batch/v1[/\x5c]*$" "t:none,chain"
        SecRule ARGS:/^json\.requests\.[^.]+(?:\.body\.requests\.[^.]+)+\.path$/|ARGS:/^requests\[[^\]]*\](?:\[body\]\[requests\]\[[^\]]*\])+\[path\]$/ "!@rx ^/(?![/\x5c])" "t:none"


# =============================================================================
# NGINX FALLBACK RULES (Phase 1 - before body consumed by fastcgi_pass)
# -----------------------------------------------------------------------------
# On Apache: These are redundant (body inspection rules 210006-210008 work)
# On Nginx: These provide protection where body rules fail (can't inspect body)
#
# Strategy: Block UNAUTHENTICATED access to batch endpoints
# - wp2shell exploit is unauthenticated
# - Legitimate batch API usage typically requires authentication
# - This provides strong protection without body inspection
# =============================================================================

# ---- 210020 (phase:1) -------------------------------------------------------
# Block unauthenticated POST to /wp-json/batch/v1 with JSON content-type
# Checks for WordPress auth cookie OR Authorization header
SecRule REQUEST_URI_RAW "@rx (?i)/.*batch/v1[/\x5c]*(?:\?|$)" \
    "id:210020,phase:1,deny,status:403,log,t:none,\
    msg:'wp2shell-nginx: Unauthenticated POST to batch endpoint blocked',\
    logdata:'URI=%{REQUEST_URI_RAW}',\
    tag:'attack-injection-sql',tag:'wp2shell',tag:'CVE-2026-63030',tag:'CVE-2026-60137',chain"
    SecRule REQUEST_METHOD "@streq POST" "chain"
        SecRule REQUEST_HEADERS:Content-Type "@rx (?i)(?:^|[\s,])application/(?:[\w!#\$&-\^\.\+]+\+)?json" "chain"
            SecRule &REQUEST_HEADERS:Authorization "@eq 0" "chain"
                SecRule &REQUEST_COOKIES:/^wordpress_logged_in_/ "@eq 0"
