feat(cac-lsphp): guarantee $_SERVER path parity via a PHP extension

A site moved from cac-fpm to cac-lsphp must see byte-identical
$_SERVER['DOCUMENT_ROOT'] and ['SCRIPT_FILENAME'] (/home/<user>/...).
The auto_prepend_file normaliser that did this was PHP_INI_PERDIR, so
any site with its own .user.ini auto_prepend_file silently displaced it
— the state 7 live shared_ols sites (Wordfence, cPanel imports) are
actually in. Hardening the hook was not an option either: making our
prepend win would have disabled those Wordfence WAFs.

Replace it with cac_path_parity, a small PHP extension that rewrites the
filesystem-path $_SERVER keys from RINIT. RINIT cannot be displaced by
.user.ini, and it occupies no userland hook, so the customer's own
auto_prepend_file stays the only prepend in play and keeps working. The
mapping lives in two PHP_INI_SYSTEM settings, which .user.ini (PERDIR /
USER only) and ini_set() cannot reach.

Mechanism is a path-component-bounded string prefix swap, not realpath():
byte-identical to cac-fpm by construction (realpath would resolve a
customer's own symlinked public_html to some third path), no syscall, and
no failure path. Every guard fails open and leaves $_SERVER untouched;
nothing here can warn, throw or 500 a site. Unconfigured it is fully
inert, so cac-fpm and cac-litespeed are unaffected.

Built in a separate Dockerfile stage keyed off the existing ARG PHPVER —
gcc/phpize/headers never reach the shipped image (verified absent; the
image grows ~155kB), and a base-image PHP bump recompiles with no human
step. A `lsphp -i | grep` assertion fails the build if the .so does not
load, so an image can never ship having silently lost parity.

The entrypoint selects the extension when present and removes any stale
prepend ini left by an older image; if the extension is somehow not
loadable it falls back to the old normaliser and logs a WARNING rather
than losing normalisation entirely. It also now logs the active parity
mode, and warns when lsphp reports no ini scan dir (previously silent).

Probe lsphp with `-i` only: it is the LSAPI SAPI, not the CLI, and
answers `-m`/`-r` by printing usage and exiting 0 — a `lsphp -m | grep`
check never matches and never errors, which is the exact class of silent
always-false assertion this change exists to remove.

Verified: 6 .phpt tests; tests/fpm-parity-check.sh proves under the FPM
SAPI that with a customer .user.ini auto_prepend_file present both keys
are still corrected AND the customer's prepend still runs, and that the
old mechanism does not; and in a real built cac-lsphp:php83 container
that SCRIPT_FILENAME is rewritten, the customer prepend still fires, and
another tenant's path is left untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-05 11:38:19 -07:00
co-authored by Claude Opus 5
parent 03b8f3f730
commit da16faaff5
14 changed files with 763 additions and 9 deletions
@@ -0,0 +1,25 @@
--TEST--
cac_path_parity: rewrites the configured prefix on a filesystem $_SERVER key
--EXTENSIONS--
cac_path_parity
--INI--
cac_path_parity.from=/mnt/users/bob/site.com
cac_path_parity.to=/home/bob
variables_order=EGPCS
--ENV--
CONTEXT_DOCUMENT_ROOT=/mnt/users/bob/site.com/public_html
HTTP_HOST=site.com
--FILE--
<?php
// NOTE: the CLI SAPI overwrites DOCUMENT_ROOT (to "") and SCRIPT_FILENAME /
// PATH_TRANSLATED (to the script path) AFTER the env import, so those three
// cannot be driven from --ENV-- here. They go through the identical code path
// as CONTEXT_DOCUMENT_ROOT (one loop over one key table); the real web-SAPI
// proof for them is tests/web-sapi-parity-check.sh.
var_dump($_SERVER['CONTEXT_DOCUMENT_ROOT']);
// Non-path vars must be untouched.
var_dump($_SERVER['HTTP_HOST']);
?>
--EXPECT--
string(21) "/home/bob/public_html"
string(8) "site.com"
@@ -0,0 +1,18 @@
--TEST--
cac_path_parity: a sibling dir sharing the prefix is NOT rewritten
--EXTENSIONS--
cac_path_parity
--INI--
cac_path_parity.from=/mnt/users/bob/site.com
cac_path_parity.to=/home/bob
variables_order=EGPCS
--ENV--
CONTEXT_DOCUMENT_ROOT=/mnt/users/bob/site.com.bak/public_html
--FILE--
<?php
// Replacement happens only on a path-COMPONENT boundary. Without that guard a
// neighbouring directory would be folded into this container's /home.
var_dump($_SERVER['CONTEXT_DOCUMENT_ROOT']);
?>
--EXPECT--
string(39) "/mnt/users/bob/site.com.bak/public_html"
@@ -0,0 +1,17 @@
--TEST--
cac_path_parity: an exact prefix match (no trailing component) is rewritten
--EXTENSIONS--
cac_path_parity
--INI--
cac_path_parity.from=/mnt/users/bob/site.com/
cac_path_parity.to=/home/bob/
variables_order=EGPCS
--ENV--
CONTEXT_DOCUMENT_ROOT=/mnt/users/bob/site.com
--FILE--
<?php
// Also covers trailing slashes in the configured values being tolerated.
var_dump($_SERVER['CONTEXT_DOCUMENT_ROOT']);
?>
--EXPECT--
string(9) "/home/bob"
@@ -0,0 +1,16 @@
--TEST--
cac_path_parity: completely inert when no mapping is configured
--EXTENSIONS--
cac_path_parity
--INI--
variables_order=EGPCS
--ENV--
CONTEXT_DOCUMENT_ROOT=/mnt/users/bob/site.com/public_html
--FILE--
<?php
// cac-fpm / cac-litespeed never configure a mapping, so the extension must be
// a no-op there. This is the no-regression guarantee for the other tiers.
var_dump($_SERVER['CONTEXT_DOCUMENT_ROOT']);
?>
--EXPECT--
string(35) "/mnt/users/bob/site.com/public_html"
@@ -0,0 +1,20 @@
--TEST--
cac_path_parity: works with auto_globals_jit=On (lazy $_SERVER, the default)
--EXTENSIONS--
cac_path_parity
--INI--
auto_globals_jit=1
cac_path_parity.from=/mnt/users/bob/site.com
cac_path_parity.to=/home/bob
variables_order=EGPCS
--ENV--
CONTEXT_DOCUMENT_ROOT=/mnt/users/bob/site.com/public_html
--FILE--
<?php
// With auto_globals_jit=On, $_SERVER does not exist yet when extension RINIT
// runs. The extension forces the auto-global so there is something to rewrite;
// drop that call and this test prints the /mnt/users path.
var_dump($_SERVER['CONTEXT_DOCUMENT_ROOT']);
?>
--EXPECT--
string(21) "/home/bob/public_html"
@@ -0,0 +1,25 @@
--TEST--
cac_path_parity: mapping is PHP_INI_SYSTEM — userland cannot change it
--EXTENSIONS--
cac_path_parity
--INI--
cac_path_parity.from=/mnt/users/bob/site.com
cac_path_parity.to=/home/bob
variables_order=EGPCS
--ENV--
CONTEXT_DOCUMENT_ROOT=/mnt/users/bob/site.com/public_html
--FILE--
<?php
// PHP_INI_SYSTEM entries are not modifiable at runtime, and .user.ini honours
// only PHP_INI_PERDIR|PHP_INI_USER — so a customer cannot redirect or disable
// the rewrite the way they can displace an auto_prepend_file.
var_dump(ini_set('cac_path_parity.from', '/tmp'));
var_dump(ini_set('cac_path_parity.to', '/tmp'));
var_dump(ini_get('cac_path_parity.from'));
var_dump($_SERVER['CONTEXT_DOCUMENT_ROOT']);
?>
--EXPECT--
bool(false)
bool(false)
string(23) "/mnt/users/bob/site.com"
string(21) "/home/bob/public_html"
+143
View File
@@ -0,0 +1,143 @@
#!/usr/bin/env bash
## fpm-parity-check.sh — end-to-end proof under a REAL web SAPI.
##
## WHY NOT .phpt: the CLI SAPI overwrites DOCUMENT_ROOT / SCRIPT_FILENAME /
## PATH_TRANSLATED after importing the environment, and the cli-server SAPI does
## not process .user.ini at all — so neither can exercise the two things that
## actually matter here.
##
## WHY PHP-FPM: php-fpm takes DOCUMENT_ROOT and SCRIPT_FILENAME as caller-
## supplied FastCGI params and honours .user.ini — structurally the same shape as
## OpenLiteSpeed handing a detached lsphp its LSAPI params. It is the closest
## analogue available without an OLS runtime.
##
## Asserts:
## 1. CONTROL — no mapping => PHP reports the raw /mnt/users paths, i.e. the
## test reproduces the bug before claiming to fix it.
## 2. FIX — mapping => both keys read /home/<user>/... .
## 3. WORDFENCE — mapping AND a customer .user.ini auto_prepend_file (the state
## 7 live shared_ols sites are in): paths are STILL corrected
## AND the customer's prepend STILL runs. This is the case the
## old auto_prepend_file normaliser silently lost.
## 4. OLD — for the record: the previous auto_prepend mechanism, with the
## same customer .user.ini, does NOT run. This is the evidence
## that hardening the prepend hook could not have worked.
##
## Usage: ./fpm-parity-check.sh [ROOT] [PHP_FPM_BIN] [EXT_SO]
## ROOT defaults to /mnt/users (falls back to a temp dir if not creatable).
set -uo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="${1:-/mnt/users}"
FPM_BIN="${2:-$(command -v php-fpm8.3 || echo /usr/sbin/php-fpm8.3)}"
EXT_SO="${3:-$HERE/../modules/cac_path_parity.so}"
PORT="${PORT:-9001}"
command -v cgi-fcgi >/dev/null || { echo "SKIP: cgi-fcgi not installed (apt install libfcgi-bin)"; exit 0; }
[ -x "$FPM_BIN" ] || { echo "SKIP: php-fpm not found"; exit 0; }
[ -f "$EXT_SO" ] || { echo "SKIP: $EXT_SO not built (run phpize && ./configure && make)"; exit 0; }
mkdir -p "$ROOT" 2>/dev/null || ROOT="$(mktemp -d)/mnt/users"
USER_NAME=bob
SITE="$ROOT/$USER_NAME/site.com"
DOCROOT="$SITE/public_html"
HOME_PATH="/home/$USER_NAME"
TMP="$(mktemp -d)"
fail=0
mkdir -p "$DOCROOT" || { echo "cannot create $DOCROOT"; exit 1; }
trap 'rm -rf "$TMP"; rm -f "$DOCROOT/.user.ini"' EXIT
cat > "$DOCROOT/probe.php" <<'PHP'
<?php
echo "DOCUMENT_ROOT=" . $_SERVER['DOCUMENT_ROOT'] . "\n";
echo "SCRIPT_FILENAME=" . $_SERVER['SCRIPT_FILENAME'] . "\n";
echo "PREPEND_RAN=" . (defined('CUSTOMER_PREPEND_RAN') ? 'yes' : 'no') . "\n";
PHP
## Stand-in for the customer's wordfence-waf.php.
cat > "$SITE/customer-waf.php" <<'PHP'
<?php
define('CUSTOMER_PREPEND_RAN', 1);
PHP
## Stand-in for the OLD mechanism (scripts/cac-lsphp-normalize.php).
cat > "$TMP/old-normalize.php" <<'PHP'
<?php
foreach (array('DOCUMENT_ROOT', 'SCRIPT_FILENAME') as $k) {
if (!empty($_SERVER[$k]) && strncmp($_SERVER[$k], '/mnt/users/', 11) === 0) {
$r = realpath($_SERVER[$k]);
if ($r !== false) { $_SERVER[$k] = $r; }
}
}
PHP
cat > "$TMP/fpm.conf" <<EOF
[global]
error_log = $TMP/fpm-error.log
daemonize = no
[www]
listen = 127.0.0.1:$PORT
pm = static
pm.max_children = 2
EOF
run_case() {
"$FPM_BIN" -n -y "$TMP/fpm.conf" -F -d user_ini.cache_ttl=0 "$@" \
>"$TMP/fpm.out" 2>&1 &
local pid=$! out=""
for _ in $(seq 1 40); do
sleep 0.15
out=$(SCRIPT_FILENAME="$DOCROOT/probe.php" DOCUMENT_ROOT="$DOCROOT" \
SCRIPT_NAME=/probe.php REQUEST_METHOD=GET QUERY_STRING= \
cgi-fcgi -bind -connect "127.0.0.1:$PORT" 2>/dev/null)
[ -n "$out" ] && break
done
kill "$pid" 2>/dev/null; wait "$pid" 2>/dev/null
printf '%s' "$out"
}
expect() {
local label="$1" got="$2" want="$3"
if [ "$got" = "$want" ]; then
echo " PASS $label"
else
echo " FAIL $label"
echo " want: $want"
echo " got: $got"
fail=1
fi
}
field() { printf '%s' "$1" | sed -n "s/^$2=//p"; }
EXT=( -d "extension=$EXT_SO" )
MAP=( -d "cac_path_parity.from=$SITE" -d "cac_path_parity.to=$HOME_PATH" )
USERINI_LINE="auto_prepend_file = $SITE/customer-waf.php"
echo "== 1. CONTROL: extension loaded, no mapping (reproduces the bug) =="
rm -f "$DOCROOT/.user.ini"
out=$(run_case "${EXT[@]}")
expect "DOCUMENT_ROOT is the raw OLS path" "$(field "$out" DOCUMENT_ROOT)" "$DOCROOT"
expect "SCRIPT_FILENAME is the raw OLS path" "$(field "$out" SCRIPT_FILENAME)" "$DOCROOT/probe.php"
echo "== 2. FIX: mapping configured =="
out=$(run_case "${EXT[@]}" "${MAP[@]}")
expect "DOCUMENT_ROOT == cac-fpm value" "$(field "$out" DOCUMENT_ROOT)" "$HOME_PATH/public_html"
expect "SCRIPT_FILENAME == cac-fpm value" "$(field "$out" SCRIPT_FILENAME)" "$HOME_PATH/public_html/probe.php"
echo "== 3. WORDFENCE: customer .user.ini auto_prepend_file present =="
printf '%s\n' "$USERINI_LINE" > "$DOCROOT/.user.ini"
out=$(run_case "${EXT[@]}" "${MAP[@]}")
expect "DOCUMENT_ROOT still corrected" "$(field "$out" DOCUMENT_ROOT)" "$HOME_PATH/public_html"
expect "SCRIPT_FILENAME still corrected" "$(field "$out" SCRIPT_FILENAME)" "$HOME_PATH/public_html/probe.php"
expect "customer auto_prepend_file still ran" "$(field "$out" PREPEND_RAN)" "yes"
echo "== 4. OLD MECHANISM (why the prepend hook could not be hardened) =="
out=$(run_case -d "auto_prepend_file=$TMP/old-normalize.php")
expect "auto_prepend normaliser is displaced by the customer's .user.ini" \
"$(field "$out" DOCUMENT_ROOT)" "$DOCROOT"
expect "customer's prepend is the one that ran" "$(field "$out" PREPEND_RAN)" "yes"
rm -f "$DOCROOT/.user.ini"
if [ "$fail" -eq 0 ]; then echo "ALL PASS"; else echo "FAILURES"; fi
exit "$fail"