Merge branch 'feat/lsphp-server-path-parity'

Guarantees $_SERVER path parity between cac-fpm and cac-lsphp via a PHP
extension rewriting the filesystem-path keys from RINIT, configured by two
PHP_INI_SYSTEM entries a customer's .user.ini cannot reach.

The prepend it replaces was PHP_INI_PERDIR, so any site with its own
auto_prepend_file displaced it -- 7 live shared_ols sites are in that state.
Hardening the hook was self-defeating: PHP resolves ONE winning
auto_prepend_file after the .user.ini chain, so you cannot chain from the
losing side, and php_admin_value would make ours win by making the customer's
Wordfence WAF never run. An extension occupies no userland hook at all.

The symlink-farm alternative was rejected twice over: followSymLink in a
shared multi-tenant OLS is a cross-tenant read risk, and /home/<user> is
ambiguous in the single shared-ols container when one user has several sites.
This commit is contained in:
2026-08-05 11:41:03 -07:00
14 changed files with 763 additions and 9 deletions
+53
View File
@@ -21,6 +21,34 @@
ARG OLS_VERSION=1.8.4 ARG OLS_VERSION=1.8.4
ARG PHPVER=83 ARG PHPVER=83
## ---- stage 1: build the cac_path_parity extension --------------------------
## $_SERVER['DOCUMENT_ROOT']/['SCRIPT_FILENAME'] parity with cac-fpm, enforced
## from RINIT so a customer's .user.ini cannot displace it — see
## ext/cac-path-parity/cac_path_parity.c for why this is an extension and not an
## auto_prepend_file. Built against THIS image's own lsphp so the API/ABI
## (`PHP API` / extension_dir) always match; a PHP version bump in the base
## image therefore recompiles rather than silently loading a stale .so.
##
## Separate stage on purpose: the compiler + headers (~400MB) stay out of the
## shipped image, which gains only the ~40KB .so. Costs ~1-2 min of CI per PHP
## version; both stages share the same base layer, so no extra pull.
FROM litespeedtech/openlitespeed:${OLS_VERSION}-lsphp${PHPVER} AS ext-build
ARG PHPVER=83
COPY ./ext/cac-path-parity /usr/src/cac-path-parity
RUN apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
build-essential autoconf pkg-config \
lsphp${PHPVER}-dev && \
cd /usr/src/cac-path-parity && \
/usr/local/lsws/lsphp${PHPVER}/bin/phpize && \
./configure --enable-cac-path-parity \
--with-php-config=/usr/local/lsws/lsphp${PHPVER}/bin/php-config && \
make -j"$(nproc)" && \
mkdir -p /build-out && \
cp modules/cac_path_parity.so /build-out/
## ---- stage 2: the shipped sidecar image ------------------------------------
FROM litespeedtech/openlitespeed:${OLS_VERSION}-lsphp${PHPVER} FROM litespeedtech/openlitespeed:${OLS_VERSION}-lsphp${PHPVER}
ARG PHPVER=83 ARG PHPVER=83
ENV PHPVER=${PHPVER} ENV PHPVER=${PHPVER}
@@ -54,6 +82,31 @@ RUN bash -c 'set -e; \
cp /etc/lsws-templates/lsphp-overrides.ini "$SCAN_DIR/99-prod-overrides.ini"; \ cp /etc/lsws-templates/lsphp-overrides.ini "$SCAN_DIR/99-prod-overrides.ini"; \
echo "wrote overrides to $SCAN_DIR"' echo "wrote overrides to $SCAN_DIR"'
## Install the cac_path_parity extension into lsphp's own extension_dir and load
## it unconditionally. It is INERT until the entrypoint writes the per-site
## cac_path_parity.from/.to mapping, so it is safe in any context (including
## wp-cli runs, where $_SERVER carries no filesystem paths).
##
grep` is a BUILD-TIME ASSERTION| grep` is a BUILD-TIME ASSERTION: if the .so fails to
## load (ABI drift after a base-image PHP bump, bad build) the image build fails
## here rather than shipping a sidecar that silently lost path parity.
## NOTE: probe lsphp with `-i` ONLY. The lsphp binary is the LSAPI SAPI, not the
## CLI — it accepts just -[b|c|n|h|i|q|s|v|?] and answers anything else (`-m`,
## `-r`) by printing its usage text and exiting 0. A `lsphp -m | grep` check
## therefore never matches AND never fails, which is exactly the kind of silent
## always-false assertion this whole change exists to eliminate.
COPY --from=ext-build /build-out/cac_path_parity.so /tmp/cac_path_parity.so
RUN bash -c 'set -e; \
LSPHP="/usr/local/lsws/lsphp${PHPVER}/bin/lsphp"; \
EXT_DIR=$("$LSPHP" -i 2>/dev/null | awk -F" => " "/^extension_dir/ {print \$2; exit}"); \
SCAN_DIR=$("$LSPHP" -i 2>/dev/null | awk -F"=> " "/^Scan this dir/ {print \$2; exit}"); \
mkdir -p "$EXT_DIR" "$SCAN_DIR"; \
mv /tmp/cac_path_parity.so "$EXT_DIR/"; \
printf "; installed by Dockerfile.lsphp\nextension=cac_path_parity.so\n" \
> "$SCAN_DIR/00-cac-path-parity.ini"; \
"$LSPHP" -i 2>/dev/null | grep -q "^cac_path_parity support => enabled$"; \
echo "cac_path_parity installed into $EXT_DIR and verified loadable"'
## php-lsapi gates .user.ini parsing behind this env var (see entrypoint-lsphp.sh ## php-lsapi gates .user.ini parsing behind this env var (see entrypoint-lsphp.sh
## for the full explanation). Set here so the value is visible in `docker inspect` ## for the full explanation). Set here so the value is visible in `docker inspect`
## and survives an entrypoint override; the entrypoint re-exports it with the same ## and survives an entrypoint override; the entrypoint re-exports it with the same
+41
View File
@@ -0,0 +1,41 @@
# phpize / configure / make artifacts from building this extension locally.
# The shipped build happens inside Dockerfile.lsphp's ext-build stage, so
# nothing generated here is ever committed.
.deps
.libs/
Makefile
Makefile.fragments
Makefile.global
Makefile.objects
acinclude.m4
aclocal.m4
autom4te.cache/
build/
config.guess
config.h
config.h.in
config.log
config.nice
config.status
config.sub
configure
configure.ac
include/
install-sh
libtool
ltmain.sh
missing
mkinstalldirs
modules/
run-tests.php
*.lo
*.la
*.o
*.so
tests/*.php
tests/*.diff
tests/*.exp
tests/*.log
tests/*.out
tests/*.sh
!tests/fpm-parity-check.sh
+294
View File
@@ -0,0 +1,294 @@
/*
* cac_path_parity — $_SERVER filesystem-path parity for the shared-ols tier.
*
* WHAT PROBLEM THIS SOLVES
* ------------------------
* A site on the standalone tiers (cac / cac-fpm / cac-litespeed) sees:
*
* $_SERVER['DOCUMENT_ROOT'] = /home/<user>/public_html
* $_SERVER['SCRIPT_FILENAME'] = /home/<user>/public_html/index.php
*
* On the shared-ols tier the webserver is a SHARED OpenLiteSpeed container that
* serves every tenant out of one bulk `/docker/users -> /mnt/users:ro` mount, so
* its vhost docRoot is /mnt/users/<user>/<domain>/public_html. OLS has no
* ProxyFCGISetEnvIf-style remap (unlike shared-httpd -> cac-fpm): it hands lsphp
* exactly that path. The cac-lsphp sidecar symlinks /mnt/users/<user>/<domain>
* -> /home/<user>, so every file OPERATION resolves and PHP's own __FILE__ /
* __DIR__ / realpath() / getcwd() already report /home/<user>/... — but the RAW
* strings OLS put in $_SERVER still read /mnt/users. Moving a site from cac-fpm
* to cac-lsphp therefore changed two $_SERVER values, which is exactly the kind
* of difference that surfaces later as a broken plugin path or a mismatched
* absolute path stored in the database.
*
* WHY THIS IS AN EXTENSION AND NOT AN auto_prepend_file
* -----------------------------------------------------
* The first fix was an `auto_prepend_file` drop-in that realpath()'d the two
* keys. `auto_prepend_file` is PHP_INI_PERDIR, so ANY site with its own
* .user.ini auto_prepend_file silently displaces it and the normaliser never
* runs — the state 7 live shared_ols sites are actually in today (Wordfence and
* cPanel imports). PHP resolves a single winning value for auto_prepend_file
* after the .user.ini chain is parsed, so there is no way to "chain" from the
* losing side either.
*
* The obvious hardening — `php_admin_value auto_prepend_file` — is WORSE, not
* better: making our prepend un-overridable makes the customer's prepend
* un-runnable, which would disable those same 7 Wordfence WAFs. The two goals
* are irreconcilable as long as the mechanism IS the prepend hook.
*
* An extension sidesteps that entirely. RINIT runs before any userland code and
* cannot be displaced by .user.ini, and it consumes no userland hook — so the
* customer's auto_prepend_file remains the only prepend in play and keeps
* working untouched. Both constraints are satisfied at once.
*
* The mapping comes from two PHP_INI_SYSTEM entries. PHP_INI_SYSTEM is NOT
* settable from .user.ini (which honours only PHP_INI_PERDIR/PHP_INI_USER), nor
* from ini_set(), nor from .htaccess — so a customer cannot point the rewrite
* somewhere else or switch it off. The cac-lsphp entrypoint writes them from the
* same `user`/`domain` env the symlink is built from, so the two can't drift.
*
* WHY A STRING PREFIX SWAP AND NOT realpath()
* -------------------------------------------
* The old normaliser called realpath(), which worked only because the sidecar
* symlinks /mnt/users/<user>/<domain> -> /home/<user>. A plain prefix swap is
* better on every axis that matters here:
*
* - It is byte-identical to cac-fpm BY CONSTRUCTION. realpath() resolves ALL
* symlinks, so a customer who makes public_html itself a symlink would get
* some third path — cac-fpm reports the literal /home/<user>/public_html.
* - It cannot fail. realpath() returns false for a path that does not exist
* (and is constrained by open_basedir), leaving the value half-normalised.
* - It costs no syscall. realpath() is an lstat chain on every request.
*
* The prefix is this site's FULL mount path (/mnt/users/<user>/<domain>), not
* the bare bulk-mount root, and it only matches on a path-component boundary —
* so a value pointing at another tenant, or one that is already canonical, is
* never touched.
*
* FAILURE MODES — every one of them leaves $_SERVER untouched and lets the
* request proceed. Nothing here can warn, throw, or 500 a customer site:
* - mapping unset/empty (any tier that is not shared-ols) -> RINIT returns
* immediately, extension is inert.
* - $_SERVER absent or not an array -> return.
* - key absent from $_SERVER -> skip that key.
* - key present but not a string -> skip that key.
* - value shorter than the prefix / prefix mismatch -> skip that key.
* - value matches the prefix mid-component -> skip that key.
* There is no error path, no userland-visible diagnostic, and no dependency on
* the filesystem being readable.
*
* SCOPE / KNOWN LIMITS
* --------------------
* Only $_SERVER is rewritten. LSAPI also answers getenv('DOCUMENT_ROOT') from
* the request environment, and that still returns the /mnt/users string. That is
* a deliberate limit: touching the SAPI environment risks the request env lsphp
* itself reads. Real-world PHP (WordPress and its plugin ecosystem) reads
* $_SERVER, not getenv(), for these.
*
* The sidecar's compatibility symlink is still REQUIRED and is not replaced by
* this extension: it is what makes the path OLS sends actually resolve on disk.
* This extension only corrects the strings.
*/
#ifdef HAVE_CONFIG_H
#include "config.h"
#endif
#include "php.h"
#include "php_ini.h"
#include "ext/standard/info.h"
#include "SAPI.h"
#include "zend_compile.h" /* zend_is_auto_global_str() */
#include "php_cac_path_parity.h"
ZEND_DECLARE_MODULE_GLOBALS(cac_path_parity)
#define CACPP_G(v) ZEND_MODULE_GLOBALS_ACCESSOR(cac_path_parity, v)
/*
* The $_SERVER keys that carry a FILESYSTEM path. URI-derived keys (PHP_SELF,
* SCRIPT_NAME, REQUEST_URI) are already identical across tiers and are left
* alone. PATH_TRANSLATED and CONTEXT_DOCUMENT_ROOT are usually absent under
* OLS; rewriting them is a no-op when they are, and correct when they are not.
*/
static const struct {
const char *name;
size_t len;
} cacpp_keys[] = {
{ ZEND_STRL("DOCUMENT_ROOT") },
{ ZEND_STRL("SCRIPT_FILENAME") },
{ ZEND_STRL("PATH_TRANSLATED") },
{ ZEND_STRL("CONTEXT_DOCUMENT_ROOT") },
};
/* clang-format off */
PHP_INI_BEGIN()
/*
* PHP_INI_SYSTEM is load-bearing: it is the reason a customer's .user.ini
* cannot reach these. Do not relax to PERDIR.
*/
STD_PHP_INI_ENTRY("cac_path_parity.from", "", PHP_INI_SYSTEM, OnUpdateString,
from, zend_cac_path_parity_globals, cac_path_parity_globals)
STD_PHP_INI_ENTRY("cac_path_parity.to", "", PHP_INI_SYSTEM, OnUpdateString,
to, zend_cac_path_parity_globals, cac_path_parity_globals)
PHP_INI_END()
/* clang-format on */
static PHP_GINIT_FUNCTION(cac_path_parity)
{
#if defined(COMPILE_DL_CAC_PATH_PARITY) && defined(ZTS)
ZEND_TSRMLS_CACHE_UPDATE();
#endif
cac_path_parity_globals->from = NULL;
cac_path_parity_globals->to = NULL;
}
/* Trailing slashes would defeat the component-boundary test below. */
static size_t cacpp_trim(const char *s, size_t len)
{
while (len > 1 && s[len - 1] == '/') {
len--;
}
return len;
}
static void cacpp_rewrite_key(zval *server, const char *key, size_t key_len,
const char *from, size_t from_len,
const char *to, size_t to_len)
{
zval *val = zend_hash_str_find(Z_ARRVAL_P(server), key, key_len);
if (val == NULL) {
return;
}
ZVAL_DEREF(val);
if (Z_TYPE_P(val) != IS_STRING) {
return;
}
const char *s = Z_STRVAL_P(val);
size_t len = Z_STRLEN_P(val);
if (len < from_len || memcmp(s, from, from_len) != 0) {
return;
}
/*
* Only replace on a PATH-COMPONENT boundary. Without this,
* from=/mnt/users/bob/site.com would also match a sibling directory
* /mnt/users/bob/site.com.bak and silently rewrite another site's path
* into this site's /home.
*/
if (len != from_len && s[from_len] != '/') {
return;
}
size_t tail_len = len - from_len;
zend_string *out = zend_string_alloc(to_len + tail_len, 0);
memcpy(ZSTR_VAL(out), to, to_len);
memcpy(ZSTR_VAL(out) + to_len, s + from_len, tail_len);
ZSTR_VAL(out)[to_len + tail_len] = '\0';
zval nv;
ZVAL_STR(&nv, out);
/*
* Update the track_vars array IN PLACE. $_SERVER in the symbol table and
* PG(http_globals)[TRACK_VARS_SERVER] are two references to the SAME
* zend_array, which is why this is visible to userland. Do NOT
* SEPARATE_ARRAY() here: that would copy the array and leave the symbol
* table pointing at the original, i.e. silently do nothing. This is the
* same in-place pattern php_register_variable_ex() uses.
*/
zend_hash_str_update(Z_ARRVAL_P(server), key, key_len, &nv);
}
PHP_RINIT_FUNCTION(cac_path_parity)
{
#if defined(ZTS) && defined(COMPILE_DL_CAC_PATH_PARITY)
ZEND_TSRMLS_CACHE_UPDATE();
#endif
const char *from = CACPP_G(from);
const char *to = CACPP_G(to);
/* Unconfigured (any tier that isn't shared-ols) => completely inert. */
if (from == NULL || *from == '\0' || to == NULL || *to == '\0') {
return SUCCESS;
}
/*
* With auto_globals_jit=On (the default) $_SERVER is not built yet at
* RINIT — php_hash_environment() only MARKED it for lazy creation. Reading
* PG(http_globals)[TRACK_VARS_SERVER] here without this call finds IS_UNDEF
* and the extension silently does nothing, which is precisely the failure
* the auto_prepend approach had. Forcing the auto-global now builds it (via
* the SAPI's register_server_variables) so there is something to rewrite,
* and the later userland access gets the corrected array.
*/
zend_is_auto_global_str(ZEND_STRL("_SERVER"));
zval *server = &PG(http_globals)[TRACK_VARS_SERVER];
if (Z_TYPE_P(server) != IS_ARRAY) {
return SUCCESS;
}
size_t from_len = cacpp_trim(from, strlen(from));
size_t to_len = cacpp_trim(to, strlen(to));
for (size_t i = 0; i < sizeof(cacpp_keys) / sizeof(cacpp_keys[0]); i++) {
cacpp_rewrite_key(server, cacpp_keys[i].name, cacpp_keys[i].len,
from, from_len, to, to_len);
}
return SUCCESS;
}
PHP_MINIT_FUNCTION(cac_path_parity)
{
REGISTER_INI_ENTRIES();
return SUCCESS;
}
PHP_MSHUTDOWN_FUNCTION(cac_path_parity)
{
UNREGISTER_INI_ENTRIES();
return SUCCESS;
}
PHP_MINFO_FUNCTION(cac_path_parity)
{
const char *from = CACPP_G(from);
const char *to = CACPP_G(to);
int active = (from && *from && to && *to);
php_info_print_table_start();
php_info_print_table_header(2, "cac_path_parity support", "enabled");
php_info_print_table_row(2, "Version", PHP_CAC_PATH_PARITY_VERSION);
/* The canary greps for this row: "active" proves the mapping is live. */
php_info_print_table_row(2, "Rewriting", active ? "active" : "inactive (unconfigured)");
php_info_print_table_end();
DISPLAY_INI_ENTRIES();
}
zend_module_entry cac_path_parity_module_entry = {
STANDARD_MODULE_HEADER,
"cac_path_parity",
NULL, /* no userland functions — by design */
PHP_MINIT(cac_path_parity),
PHP_MSHUTDOWN(cac_path_parity),
PHP_RINIT(cac_path_parity),
NULL, /* RSHUTDOWN */
PHP_MINFO(cac_path_parity),
PHP_CAC_PATH_PARITY_VERSION,
PHP_MODULE_GLOBALS(cac_path_parity),
PHP_GINIT(cac_path_parity),
NULL, /* GSHUTDOWN */
NULL, /* post-deactivate */
STANDARD_MODULE_PROPERTIES_EX
};
#ifdef COMPILE_DL_CAC_PATH_PARITY
#if defined(ZTS)
ZEND_TSRMLS_CACHE_DEFINE()
#endif
ZEND_GET_MODULE(cac_path_parity)
#endif
+15
View File
@@ -0,0 +1,15 @@
dnl config.m4 for the cac_path_parity extension.
dnl Built out-of-tree against the image's own lsphp:
dnl phpize && ./configure --with-php-config=/usr/local/lsws/lsphpNN/bin/php-config
dnl No external libraries, no optional features — pure core API.
PHP_ARG_ENABLE([cac_path_parity],
[whether to enable cac_path_parity support],
[AS_HELP_STRING([--enable-cac-path-parity],
[Enable cac_path_parity ($_SERVER path parity for the shared-ols tier)])],
[no])
if test "$PHP_CAC_PATH_PARITY" != "no"; then
AC_DEFINE(HAVE_CAC_PATH_PARITY, 1, [Have cac_path_parity support])
PHP_NEW_EXTENSION(cac_path_parity, cac_path_parity.c, $ext_shared)
fi
+24
View File
@@ -0,0 +1,24 @@
/*
* cac_path_parity — $_SERVER filesystem-path parity for the shared-ols tier.
*
* See cac_path_parity.c for the full rationale.
*/
#ifndef PHP_CAC_PATH_PARITY_H
#define PHP_CAC_PATH_PARITY_H
extern zend_module_entry cac_path_parity_module_entry;
#define phpext_cac_path_parity_ptr &cac_path_parity_module_entry
#define PHP_CAC_PATH_PARITY_VERSION "1.0.0"
#if defined(ZTS) && defined(COMPILE_DL_CAC_PATH_PARITY)
ZEND_TSRMLS_CACHE_EXTERN()
#endif
ZEND_BEGIN_MODULE_GLOBALS(cac_path_parity)
char *from;
char *to;
ZEND_END_MODULE_GLOBALS(cac_path_parity)
#endif /* PHP_CAC_PATH_PARITY_H */
@@ -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"
+11 -1
View File
@@ -1,6 +1,16 @@
<?php <?php
/** /**
* cac-lsphp $_SERVER path normaliser (auto_prepend). * cac-lsphp $_SERVER path normaliser (auto_prepend) — DEGRADED FALLBACK ONLY.
*
* SUPERSEDED by the cac_path_parity PHP extension (ext/cac-path-parity/), which
* does this from RINIT where a customer's .user.ini cannot displace it. The
* entrypoint only wires this file up when that extension is not loadable in the
* running image, and logs a WARNING when it does. Do not extend this script —
* fix the extension instead.
*
* It is kept because the flaw documented at the bottom of this docblock is
* exactly why the extension exists, and because an image where the extension
* failed to load should degrade to the old behaviour rather than to nothing.
* *
* The shared-ols container serves from its bulk /docker/users->/mnt/users mount, * The shared-ols container serves from its bulk /docker/users->/mnt/users mount,
* so OLS sends lsphp $_SERVER['DOCUMENT_ROOT'] / ['SCRIPT_FILENAME'] under * so OLS sends lsphp $_SERVER['DOCUMENT_ROOT'] / ['SCRIPT_FILENAME'] under
+61 -8
View File
@@ -22,6 +22,14 @@
## /home/$user/public_html files. PHP canonicalises the symlink, so ## /home/$user/public_html files. PHP canonicalises the symlink, so
## __FILE__/__DIR__/realpath all report /home/$user/public_html (verified ## __FILE__/__DIR__/realpath all report /home/$user/public_html (verified
## 2026-06-10) — the customer never sees the /mnt/users path. ## 2026-06-10) — the customer never sees the /mnt/users path.
##
## THE $_SERVER STRINGS: the symlink makes paths RESOLVE, but the raw strings OLS
## put in $_SERVER['DOCUMENT_ROOT']/['SCRIPT_FILENAME'] still read /mnt/users.
## The cac_path_parity extension (baked into the image, configured per-site
## below) rewrites those two at request start, so a site moved from cac-fpm to
## cac-lsphp sees byte-identical values. It replaced an auto_prepend_file
## normaliser that any site's own .user.ini silently displaced — see
## ext/cac-path-parity/cac_path_parity.c.
set -euo pipefail set -euo pipefail
@@ -60,8 +68,13 @@ SAFE_DOMAIN="$domain"
case "$domain" in case "$domain" in
\*.*) SAFE_DOMAIN="wildcard.${domain#\*.}" ;; \*.*) SAFE_DOMAIN="wildcard.${domain#\*.}" ;;
esac esac
## The exact path prefix the shared-ols container serves this site from — the
## string OLS puts in SCRIPT_FILENAME/DOCUMENT_ROOT. Used twice: for the symlink
## that makes it RESOLVE, and for the cac_path_parity mapping that makes it READ
## like cac-fpm. Deriving both from one variable keeps them in lockstep.
OLS_SITE_PATH="/mnt/users/$user/$SAFE_DOMAIN"
mkdir -p "/mnt/users/$user" mkdir -p "/mnt/users/$user"
ln -sfn "/home/$user" "/mnt/users/$user/$SAFE_DOMAIN" ln -sfn "/home/$user" "$OLS_SITE_PATH"
## ---- detached-lsphp pool sizing ---- ## ---- detached-lsphp pool sizing ----
# shellcheck source=/dev/null # shellcheck source=/dev/null
@@ -102,7 +115,13 @@ echo "Container memory: ${CONTAINER_MEMORY_MB}MB | PHP_LSAPI_CHILDREN=${PHP_LSAP
## ---- per-site ini drop-ins (identical mechanism to entrypoint-litespeed.sh) ---- ## ---- per-site ini drop-ins (identical mechanism to entrypoint-litespeed.sh) ----
## error_log → the same customer-visible path cac:phpNN / cac-litespeed use, so ## error_log → the same customer-visible path cac:phpNN / cac-litespeed use, so
## "where's my PHP error log?" is answered identically across all site types. ## "where's my PHP error log?" is answered identically across all site types.
SCAN_DIR=$("$LSPHP_BIN" -i 2>/dev/null | awk -F'=> ' '/^Scan this dir/ {print $2; exit}') ## Capture lsphp's own info once and read both answers out of it. Probe with
## `-i` ONLY: lsphp is the LSAPI SAPI, not the CLI — it accepts just
## -[b|c|n|h|i|q|s|v|?] and answers `-m`/`-r` by printing usage and exiting 0, so
## a `lsphp -m | grep` test never matches and never errors either.
PATH_PARITY_MODE="none"
LSPHP_INFO=$("$LSPHP_BIN" -i 2>/dev/null || true)
SCAN_DIR=$(printf '%s\n' "$LSPHP_INFO" | awk -F'=> ' '/^Scan this dir/ {print $2; exit}')
if [ -n "$SCAN_DIR" ]; then if [ -n "$SCAN_DIR" ]; then
mkdir -p "$SCAN_DIR" mkdir -p "$SCAN_DIR"
cat > "$SCAN_DIR/99-user-error-log.ini" <<EOF cat > "$SCAN_DIR/99-user-error-log.ini" <<EOF
@@ -110,15 +129,42 @@ if [ -n "$SCAN_DIR" ]; then
error_log = /home/${user}/logs/php-fpm/error.log error_log = /home/${user}/logs/php-fpm/error.log
log_errors = On log_errors = On
EOF EOF
## Normalise \$_SERVER['DOCUMENT_ROOT']/['SCRIPT_FILENAME'] from the OLS-sent ## ---- $_SERVER path parity with cac-fpm ----
## /mnt/users path back to /home/<user> so cac-lsphp is byte-for-byte 1:1 with ## Point the cac_path_parity extension at THIS site's mapping. Same two
## cac-fpm. Customer sites have no auto_prepend by default, so this is safe; a ## values the compatibility symlink above is built from, so the rewrite and
## site that sets its own .user.ini auto_prepend overrides it (paths still ## the symlink can never disagree.
## resolve via the symlink either way). ##
cat > "$SCAN_DIR/99-cac-lsphp-normalize.ini" <<'EOF' ## Both settings are PHP_INI_SYSTEM: a customer's .user.ini (PHP_INI_PERDIR /
## PHP_INI_USER only) cannot redirect or disable them, and the extension
## occupies no userland hook — so the customer's own auto_prepend_file (the
## Wordfence WAF on several live sites) keeps working untouched. That
## combination is why this is an extension: the previous auto_prepend_file
## normaliser was itself PHP_INI_PERDIR and any site with its own prepend
## silently displaced it, while making OUR prepend win would have disabled
## THEIRS. See ext/cac-path-parity/cac_path_parity.c.
if printf '%s\n' "$LSPHP_INFO" | grep -q '^cac_path_parity support => enabled$'; then
cat > "$SCAN_DIR/99-cac-path-parity.ini" <<EOF
; rendered at container start by entrypoint-lsphp.sh ; rendered at container start by entrypoint-lsphp.sh
cac_path_parity.from = ${OLS_SITE_PATH}
cac_path_parity.to = /home/${user}
EOF
## Drop the pre-extension fallback if an older image left one here — the
## container filesystem survives a "docker restart", so an in-place upgrade
## must not keep a stale auto_prepend pointing at the old normaliser.
rm -f "$SCAN_DIR/99-cac-lsphp-normalize.ini"
PATH_PARITY_MODE="extension"
else
## Degraded fallback for an image built before the extension existed (or one
## where it failed to load). Restores the old, .user.ini-defeatable
## behaviour rather than losing normalisation entirely — but say so loudly,
## because in this mode parity is NOT guaranteed.
cat > "$SCAN_DIR/99-cac-lsphp-normalize.ini" <<'EOF'
; rendered at container start by entrypoint-lsphp.sh (DEGRADED FALLBACK)
auto_prepend_file = /scripts/cac-lsphp-normalize.php auto_prepend_file = /scripts/cac-lsphp-normalize.php
EOF EOF
PATH_PARITY_MODE="auto_prepend (DEGRADED)"
echo "WARNING: entrypoint-lsphp: cac_path_parity extension not loadable in this image — falling back to the auto_prepend normaliser, which a site's own .user.ini auto_prepend_file will silently displace. Rebuild/repull cac-lsphp:php${PHPVER}." >&2
fi
## Per-site opcache override (panel: Advanced Tuning → OpCache size); falls ## Per-site opcache override (panel: Advanced Tuning → OpCache size); falls
## back to the baked lsphp-overrides.ini defaults when unset. ## back to the baked lsphp-overrides.ini defaults when unset.
if [ -n "${OPCACHE_MEMORY_MB:-}" ] || [ -n "${OPCACHE_MAX_FILES:-}" ]; then if [ -n "${OPCACHE_MEMORY_MB:-}" ] || [ -n "${OPCACHE_MAX_FILES:-}" ]; then
@@ -129,8 +175,15 @@ EOF
[ -n "${OPCACHE_MAX_FILES:-}" ] && echo "opcache.max_accelerated_files = ${OPCACHE_MAX_FILES}" [ -n "${OPCACHE_MAX_FILES:-}" ] && echo "opcache.max_accelerated_files = ${OPCACHE_MAX_FILES}"
} > "$SCAN_DIR/99-user-opcache.ini" } > "$SCAN_DIR/99-user-opcache.ini"
fi fi
else
## No scan dir means none of the per-site ini drop-ins land — including the
## path-parity mapping. Previously this failed silently; it must not, because
## the tier's cac-fpm parity guarantee is one of the things lost.
echo "WARNING: entrypoint-lsphp: lsphp reports no additional-ini scan dir — per-site error_log, opcache and \$_SERVER path-parity settings were NOT applied." >&2
fi fi
echo "entrypoint-lsphp: \$_SERVER path parity = ${PATH_PARITY_MODE} (${OLS_SITE_PATH} -> /home/${user})"
## ---- ownership ---- ## ---- ownership ----
## Ensure the dirs we created + the log file are customer-owned so lsphp (running ## Ensure the dirs we created + the log file are customer-owned so lsphp (running
## as $user) can read code and write logs. Customer content is already ## as $user) can read code and write logs. Customer content is already