PHP "RCE via OOM corruption" (GHSA-w8hw-w9fx-j79w) and the session upload-progress variant (GHSA-6wmf-4rv5-p3rp), interim until patched PHP is on every customer image. HAProxy (hap_listener.tpl, every frontend render, WAF on or off): deny 400 multipart/form-data with no Content-Length (HTTP/1.1 chunked, HTTP/2-3 streamed bodies) -- vector (a), the post_max_size bypass. Measured: Chrome 155 (h2 and h3) and Firefox 153 send content-length on form submits, FormData fetch and XHR; only a ReadableStream fetch body (duplex:'half') has none. Coraza (coraza-spoa/overrides.conf, 209610-209616): 209610 ARGS_NAMES/FILES_NAMES with more than 16 '[' 209611-613 part-header budget (4/part, deny 32 below zero) 209614-615 more than 64 empty-filename file parts 209616 multipart PHP_SESSION_UPLOAD_PROGRESS field Ids sit in the engine range 200000-209999 that WHP's break-glass rule (991000001) already removes, so no WHP release is needed for break-glass to lift them. Each flips the engine On for itself, like the day-one promotions, so they deny in every mode the sidecar runs in. Limitation: Coraza sees only the first 16 KB of a body. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
229 lines
14 KiB
Plaintext
229 lines
14 KiB
Plaintext
# WHP day-one enforce overrides for coraza-spoa.
|
||
#
|
||
# Global mode in config.yaml is SecRuleEngine DetectionOnly. The rule ID
|
||
# ranges below are promoted to enforcement individually, chosen for very
|
||
# low false-positive rate on the kinds of customer traffic seen on WHP
|
||
# (WordPress, WooCommerce, Divi page builders).
|
||
#
|
||
# When bumping the upstream coraza-spoa pin (and thus the bundled CRS):
|
||
# 1. Skim the CRS CHANGELOG for new/changed rules in these ID ranges.
|
||
# 2. Verify they're still high-confidence before promoting the new image.
|
||
# 3. Smoke-test in staging detect-only mode for 24h before flipping enforce.
|
||
#
|
||
# Per-customer false-positive tuning lives in a future per-customer
|
||
# override mechanism; v1 is server-wide.
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 930120 — LFI: explicit traversal to sensitive system files
|
||
# (/etc/passwd, /proc/self/, /.ssh/, /etc/shadow, /etc/group, etc.)
|
||
# Unambiguous probe pattern; no legitimate site path leads here.
|
||
# Note: 930xxx as a whole includes broader traversal patterns that can FP
|
||
# on legitimate relative-path file browsers — keep those detect-only.
|
||
# ---------------------------------------------------------------------------
|
||
SecRuleUpdateActionById 930120 "ctl:ruleEngine=On"
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 932100-932160 — RCE: Unix shell command injection
|
||
# Patterns like `; cat /etc/passwd`, `|whoami`, backtick `\`uname\``,
|
||
# $(...) substitution, &&/|| chaining with shell builtins.
|
||
# Don't appear in normal POST bodies, URL params, or headers. Targeting
|
||
# these is unambiguous attempted command execution.
|
||
# ---------------------------------------------------------------------------
|
||
SecRuleUpdateActionById 932100-932160 "ctl:ruleEngine=On"
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 933170-933200 — PHP Webshell access patterns
|
||
# Direct requests to known webshell paths: c99.php, r57.php, b374k.php,
|
||
# wso.php, alfa.php, mini.php, etc. Almost universally reconnaissance
|
||
# scanning for post-exploitation. Even legitimate WordPress installs
|
||
# never serve these paths.
|
||
# ---------------------------------------------------------------------------
|
||
SecRuleUpdateActionById 933170-933200 "ctl:ruleEngine=On"
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 944100-944300 — Log4Shell / JNDI injection
|
||
# `${jndi:ldap://}`, `${jndi:rmi://}`, and obfuscated variants thereof
|
||
# in headers, query strings, or bodies. Even our PHP/Node stack isn't
|
||
# vulnerable, but blocking at the edge keeps logs clean and protects
|
||
# any future Java workloads.
|
||
# ---------------------------------------------------------------------------
|
||
# 944110 and 944120 are NOT promoted (changed 2026-10-09, 2026.10.2). Both are
|
||
# CHAINS whose first link matches bare words anywhere in the request --
|
||
# `runtime|processbuilder` and `getproperty|forclosure|filewriter|...` -- and
|
||
# Coraza runs an appended ctl:ruleEngine=On on that first link even when the
|
||
# chain then fails. Measured (Coraza v3.7.0 + CRS 4.25.0): in detect_only,
|
||
# `?q=runtime&id=1' or '1'='1` was 403 while the SQLi alone was 200. Same
|
||
# chain-starter defect as 920440. They still add to the anomaly score.
|
||
# Log4Shell (944150/944151/944152, ...) stays promoted. WHP mirrors this split
|
||
# in coraza_baked_promotion_ranges() (web-files/libs/coraza_blocking_set.php).
|
||
SecRuleUpdateActionById 944100 "ctl:ruleEngine=On"
|
||
SecRuleUpdateActionById 944130-944300 "ctl:ruleEngine=On"
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 920440 — URL file extension restricted by policy
|
||
# Catches probes for backup / config / dump files: .bak, .old, .save,
|
||
# .swp, .sql, .dist, .backup. Promoted to enforce after empirical
|
||
# observation on whp01 (2026-05-12, first ~30 min of detect-only):
|
||
# 124 events, all backup-file recon — `/wp-config.php.old`,
|
||
# `/db_backup.sql`, `/.env.save`, `/releases.sql`, etc. — from a
|
||
# single GCP-hosted scanner. Zero false positives observed; standard
|
||
# WP/WooCommerce/Divi/HPR URLs do not end in these extensions.
|
||
# ---------------------------------------------------------------------------
|
||
# HOW IT IS PROMOTED (changed 2026-10-09, haproxy-manager-base 2026.10.2).
|
||
# This used to be `SecRuleUpdateActionById 920440 "ctl:ruleEngine=On"`, which
|
||
# was WRONG: 920440 is a CHAIN whose first link is
|
||
# `REQUEST_BASENAME "@rx \.([^.]+)$"` -- it matches EVERY URL with a file
|
||
# extension -- and Coraza runs actions appended to a chain starter when that
|
||
# first link matches, even though the chain then fails. Measured (Coraza
|
||
# v3.7.0 + CRS 4.25.0, in-process and live): every *.php / *.css / ...
|
||
# request ran with the engine On, so the whole CRS anomaly score (949110) and
|
||
# 200002 enforced there -- in detect_only mode too, since SecRuleEngine
|
||
# DetectionOnly cannot override a runtime ctl.
|
||
#
|
||
# Now: 920440 itself is untouched (it still sets tx.extension in its first
|
||
# link and adds the anomaly score in its second), and this separate phase-1
|
||
# rule, which runs after it, flips the engine only when the extension really
|
||
# is in tx.restricted_extensions -- the original intent. Removing 920440
|
||
# (break-glass, a per-host exception) also removes tx.extension, so this rule
|
||
# goes quiet with it. Id 992920440 is outside WHP's generated range
|
||
# (990000000-991999999).
|
||
SecRule TX:EXTENSION "@within %{tx.restricted_extensions}" \
|
||
"id:992920440,phase:1,pass,nolog,t:none,t:lowercase,ctl:ruleEngine=On"
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 930130 — Restricted File Access Attempt
|
||
# Catches dotfile / VCS / config-disclosure probes: .env (and .env.local /
|
||
# .env.bak / .env.save variants), .git/config, config.php at root or under
|
||
# /admin /backend, etc. Distinct from 930120 (system file paths like
|
||
# /etc/passwd); this targets application secret files.
|
||
#
|
||
# Promoted to enforce on the same observation pass that justified 920440:
|
||
# 117 events split across joshuaknapp.net (136), cgdannyb.com (51),
|
||
# onlinesupplements.net (23) — all `.env`-class disclosure probes.
|
||
# Zero false positives observed. Notably, HPR's `/ccdn.php?filename=...`
|
||
# audio delivery path does NOT trigger this rule — verified empirically.
|
||
# ---------------------------------------------------------------------------
|
||
SecRuleUpdateActionById 930130 "ctl:ruleEngine=On"
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 209610-209616 -- PHP multipart "RCE via OOM corruption" mitigations
|
||
# CVE-2026-109618 (GHSA-w8hw-w9fx-j79w) and CVE-2026-109619
|
||
# (GHSA-6wmf-4rv5-p3rp). Interim edge mitigation until patched PHP is on every
|
||
# customer image; remove once it is. haproxy-manager-base 2026.10.6.
|
||
#
|
||
# The advisory's request-body vectors and what covers each:
|
||
# (a) multipart with no usable Content-Length (chunked / H2-H3 without
|
||
# content-length) streams past post_max_size -> denied 400 by HAProxy
|
||
# itself (templates/hap_listener.tpl), before any of this runs.
|
||
# (b) unlimited headers per multipart part -> 209611-209613 (budget)
|
||
# (c) empty file controls (do not count toward max_file_uploads)
|
||
# + deeply nested field names (a[][][]...) -> 209614-209615, 209610
|
||
# -619: session upload progress during multipart -> 209616
|
||
#
|
||
# WHY THESE IDS. 200000-209999 is the Coraza engine range, and WHP's site
|
||
# break-glass rule (991000001) removes that WHOLE range for a site in
|
||
# emergency mode (coraza_blocking_set_ranges(), WHP_WAF_ENGINE_RANGE_LO..HI in
|
||
# web-files/libs/coraza_blocking_set.php). Rules that deny on their own MUST be
|
||
# inside the derived blocking set or break-glass leaves them armed; inside the
|
||
# engine range they are covered without a WHP release. The engine itself uses
|
||
# only 200000-200006 (coraza.conf-recommended); CRS uses none of 2xxxxx. WHP
|
||
# also treats the range as staff-only for self-service exceptions.
|
||
#
|
||
# They switch the engine On for themselves (ctl:ruleEngine=On), exactly like
|
||
# the day-one promotions above, so they deny in every mode the sidecar runs in.
|
||
#
|
||
# LIMITATION, read before trusting them: HAProxy hands Coraza only the first
|
||
# buffer of the body (tune.bufsize 16 KB). A request that puts its abusive
|
||
# parts after ~16 KB of padding, or whose single part has a >16 KB header
|
||
# block, is not seen here. Vector (a) is the one enforced on the full request.
|
||
# Coraza's multipart parser (Go mime/multipart) also canonicalises part
|
||
# header names and stops at the first part it cannot read.
|
||
# ---------------------------------------------------------------------------
|
||
|
||
# (c) Field-name nesting. Counts '[' in every ARGS / FILES name (query,
|
||
# urlencoded and multipart names are all raw, PHP-shaped here; Coraza's JSON
|
||
# processor names args json.a.b and never produces '['). WordPress-ecosystem
|
||
# form names nest a few levels (menu-item-settings[12][title],
|
||
# variable_regular_price[0], input_3[]); PHP's own max_input_nesting_level
|
||
# is 64, so 16 is far above real forms and far below PHP's ceiling.
|
||
SecRule ARGS_NAMES|FILES_NAMES "@rx (?:[^\[]*\[){17}" \
|
||
"id:209610,phase:2,deny,status:403,log,t:none,\
|
||
msg:'Parameter name nested more than 16 levels deep (CVE-2026-109618 mitigation)',\
|
||
logdata:'%{MATCHED_VAR_NAME}',tag:'whp/cve-2026-109618',ctl:ruleEngine=On"
|
||
|
||
# (b) Part-header budget. A legitimate part carries 1-2 headers
|
||
# (Content-Disposition, usually Content-Type; a few clients add
|
||
# Content-Transfer-Encoding). Budget = 4 per part - 1 per part header, and
|
||
# the request is denied once it is 32 below zero: a single part needs 37+
|
||
# headers, and parts averaging 4 or fewer headers never trip it however many
|
||
# there are. Parts are counted by what they BECAME (each part is exactly one
|
||
# ARGS_POST or FILES entry), not by Content-Disposition lines, because a part
|
||
# can repeat Content-Disposition as easily as any other header.
|
||
# @unconditionalMatch runs setvar once PER VALUE (measured), and runs on
|
||
# empty values too, so empty fields and empty files are counted.
|
||
# No REQBODY_PROCESSOR chain on 209611: a chained link's setvar would run once
|
||
# per request, not once per part. On a non-multipart body it only RAISES the
|
||
# budget, and MULTIPART_PART_HEADERS (the only thing that lowers it) is empty.
|
||
SecRule ARGS_POST|FILES "@unconditionalMatch" \
|
||
"id:209611,phase:2,pass,nolog,t:none,setvar:tx.whp_cve109618_hdr_budget=+4"
|
||
SecRule MULTIPART_PART_HEADERS "@unconditionalMatch" \
|
||
"id:209612,phase:2,pass,nolog,t:none,setvar:tx.whp_cve109618_hdr_budget=-1"
|
||
SecRule TX:whp_cve109618_hdr_budget "@lt -32" \
|
||
"id:209613,phase:2,deny,status:403,log,t:none,\
|
||
msg:'Multipart part(s) with an excessive number of headers (CVE-2026-109618 mitigation)',\
|
||
logdata:'budget %{tx.whp_cve109618_hdr_budget}',tag:'whp/cve-2026-109618',ctl:ruleEngine=On"
|
||
|
||
# (c) Empty file controls: parts with an EMPTY filename (`filename=""` or
|
||
# `filename=`), which PHP does not count toward max_file_uploads. Coraza turns
|
||
# such a part into an ARGS_POST field (no FILES entry), so they are counted
|
||
# from the raw part headers. A browser sends one per untouched <input
|
||
# type=file>; legitimate forms have a handful. Denied above 64.
|
||
SecRule MULTIPART_PART_HEADERS "@rx (?i)^content-disposition:.*;\s*filename\s*=\s*(?:\"\"|;|$)" \
|
||
"id:209614,phase:2,pass,nolog,t:none,setvar:tx.whp_cve109618_empty_files=+1"
|
||
SecRule TX:whp_cve109618_empty_files "@gt 64" \
|
||
"id:209615,phase:2,deny,status:403,log,t:none,\
|
||
msg:'Multipart body with more than 64 empty file parts (CVE-2026-109618 mitigation)',\
|
||
logdata:'%{tx.whp_cve109618_empty_files} empty file parts',tag:'whp/cve-2026-109618',ctl:ruleEngine=On"
|
||
|
||
# -619: session upload progress. The engine flip sits on the LAST chain link
|
||
# on purpose: Coraza runs a chain starter's ctl as soon as the first link
|
||
# matches, even if the chain then fails (the 920440/944110 defect above).
|
||
# PHP only tracks upload progress for a
|
||
# multipart field named session.upload_progress.name (default
|
||
# PHP_SESSION_UPLOAD_PROGRESS, compared case-sensitively) that precedes the
|
||
# file parts, so that is exactly what is denied. WordPress core, WooCommerce,
|
||
# Divi, Gravity Forms and CF7 do not use the feature.
|
||
SecRule ARGS_POST_NAMES "@streq PHP_SESSION_UPLOAD_PROGRESS" \
|
||
"id:209616,phase:2,deny,status:403,log,t:none,chain,\
|
||
msg:'Multipart session upload-progress field (CVE-2026-109619 mitigation)',\
|
||
tag:'whp/cve-2026-109619'"
|
||
SecRule REQBODY_PROCESSOR "@streq MULTIPART" "t:none,ctl:ruleEngine=On"
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Rule families intentionally kept at DETECT-ONLY for v1 — high FP rate
|
||
# on customer mix. Promote individually after observation:
|
||
#
|
||
# 913xxx (Scanner UAs)— matches legitimate ActivityPub federation
|
||
# (Mastodon's "...Bot" UA) and SiteLockSpider (a
|
||
# paid customer-security service some sites use).
|
||
# Observed on whp01 burn-in 2026-05-13:
|
||
# 20/185 hits = ~11% FP rate on HPR + greggfranklin
|
||
# + suchascream. Detection adds anomaly score
|
||
# either way; enforce upside is low.
|
||
# 941xxx (XSS) — Divi rich-text editor saves, TinyMCE submissions
|
||
# 942xxx (SQLi) — WP admin queries reflected in params
|
||
# 920xxx (other) — most 920xxx rules; 920440 specifically promoted above
|
||
# 933150 — PHP injection FP on WooCommerce checkout
|
||
# (`session_start` literal appearing in billing form data)
|
||
# 950xxx-953xxx — Data leakage / backup-file disclosure (mixed FP)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# RESERVED RULE-ID RANGE: 990000000 – 990999999
|
||
# WHP's coraza_rule_manager generates per-host-exception rules in this range
|
||
# (rule ID = 990000000 + target_rule_id). Do NOT add new rules in this range
|
||
# from any other source. When bumping the coraza-spoa pin, check the CRS
|
||
# changelog for new rules with 9-digit IDs (rare but possible) and re-namespace
|
||
# if collision risk emerges.
|
||
# ---------------------------------------------------------------------------
|