diff --git a/Dockerfile.lsphp b/Dockerfile.lsphp index 588bcf2..c884838 100644 --- a/Dockerfile.lsphp +++ b/Dockerfile.lsphp @@ -21,6 +21,34 @@ ARG OLS_VERSION=1.8.4 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} ARG PHPVER=83 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"; \ 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 ## 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 diff --git a/ext/cac-path-parity/.gitignore b/ext/cac-path-parity/.gitignore new file mode 100644 index 0000000..cf428f4 --- /dev/null +++ b/ext/cac-path-parity/.gitignore @@ -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 diff --git a/ext/cac-path-parity/cac_path_parity.c b/ext/cac-path-parity/cac_path_parity.c new file mode 100644 index 0000000..b59da77 --- /dev/null +++ b/ext/cac-path-parity/cac_path_parity.c @@ -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//public_html + * $_SERVER['SCRIPT_FILENAME'] = /home//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///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// + * -> /home/, so every file OPERATION resolves and PHP's own __FILE__ / + * __DIR__ / realpath() / getcwd() already report /home//... — 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// -> /home/. 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//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//), 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 diff --git a/ext/cac-path-parity/config.m4 b/ext/cac-path-parity/config.m4 new file mode 100644 index 0000000..6da2bdb --- /dev/null +++ b/ext/cac-path-parity/config.m4 @@ -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 diff --git a/ext/cac-path-parity/php_cac_path_parity.h b/ext/cac-path-parity/php_cac_path_parity.h new file mode 100644 index 0000000..cc4acf0 --- /dev/null +++ b/ext/cac-path-parity/php_cac_path_parity.h @@ -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 */ diff --git a/ext/cac-path-parity/tests/001-rewrite.phpt b/ext/cac-path-parity/tests/001-rewrite.phpt new file mode 100644 index 0000000..cf38688 --- /dev/null +++ b/ext/cac-path-parity/tests/001-rewrite.phpt @@ -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-- + +--EXPECT-- +string(21) "/home/bob/public_html" +string(8) "site.com" diff --git a/ext/cac-path-parity/tests/002-boundary-sibling.phpt b/ext/cac-path-parity/tests/002-boundary-sibling.phpt new file mode 100644 index 0000000..b634b15 --- /dev/null +++ b/ext/cac-path-parity/tests/002-boundary-sibling.phpt @@ -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-- + +--EXPECT-- +string(39) "/mnt/users/bob/site.com.bak/public_html" diff --git a/ext/cac-path-parity/tests/003-boundary-exact.phpt b/ext/cac-path-parity/tests/003-boundary-exact.phpt new file mode 100644 index 0000000..cae7d8b --- /dev/null +++ b/ext/cac-path-parity/tests/003-boundary-exact.phpt @@ -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-- + +--EXPECT-- +string(9) "/home/bob" diff --git a/ext/cac-path-parity/tests/004-inert-when-unconfigured.phpt b/ext/cac-path-parity/tests/004-inert-when-unconfigured.phpt new file mode 100644 index 0000000..a034e71 --- /dev/null +++ b/ext/cac-path-parity/tests/004-inert-when-unconfigured.phpt @@ -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-- + +--EXPECT-- +string(35) "/mnt/users/bob/site.com/public_html" diff --git a/ext/cac-path-parity/tests/005-auto-globals-jit.phpt b/ext/cac-path-parity/tests/005-auto-globals-jit.phpt new file mode 100644 index 0000000..4834dd9 --- /dev/null +++ b/ext/cac-path-parity/tests/005-auto-globals-jit.phpt @@ -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-- + +--EXPECT-- +string(21) "/home/bob/public_html" diff --git a/ext/cac-path-parity/tests/006-not-overridable.phpt b/ext/cac-path-parity/tests/006-not-overridable.phpt new file mode 100644 index 0000000..de95ae7 --- /dev/null +++ b/ext/cac-path-parity/tests/006-not-overridable.phpt @@ -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-- + +--EXPECT-- +bool(false) +bool(false) +string(23) "/mnt/users/bob/site.com" +string(21) "/home/bob/public_html" diff --git a/ext/cac-path-parity/tests/fpm-parity-check.sh b/ext/cac-path-parity/tests/fpm-parity-check.sh new file mode 100755 index 0000000..cde05ec --- /dev/null +++ b/ext/cac-path-parity/tests/fpm-parity-check.sh @@ -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//... . +## 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' + "$SITE/customer-waf.php" <<'PHP' + "$TMP/old-normalize.php" <<'PHP' + "$TMP/fpm.conf" <"$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" diff --git a/scripts/cac-lsphp-normalize.php b/scripts/cac-lsphp-normalize.php index 039a715..16108dd 100644 --- a/scripts/cac-lsphp-normalize.php +++ b/scripts/cac-lsphp-normalize.php @@ -1,6 +1,16 @@ /mnt/users mount, * so OLS sends lsphp $_SERVER['DOCUMENT_ROOT'] / ['SCRIPT_FILENAME'] under diff --git a/scripts/entrypoint-lsphp.sh b/scripts/entrypoint-lsphp.sh index c75cd17..dffd56d 100644 --- a/scripts/entrypoint-lsphp.sh +++ b/scripts/entrypoint-lsphp.sh @@ -22,6 +22,14 @@ ## /home/$user/public_html files. PHP canonicalises the symlink, so ## __FILE__/__DIR__/realpath all report /home/$user/public_html (verified ## 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 @@ -60,8 +68,13 @@ SAFE_DOMAIN="$domain" case "$domain" in \*.*) SAFE_DOMAIN="wildcard.${domain#\*.}" ;; 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" -ln -sfn "/home/$user" "/mnt/users/$user/$SAFE_DOMAIN" +ln -sfn "/home/$user" "$OLS_SITE_PATH" ## ---- detached-lsphp pool sizing ---- # 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) ---- ## 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. -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 mkdir -p "$SCAN_DIR" cat > "$SCAN_DIR/99-user-error-log.ini" < so cac-lsphp is byte-for-byte 1:1 with - ## cac-fpm. Customer sites have no auto_prepend by default, so this is safe; a - ## site that sets its own .user.ini auto_prepend overrides it (paths still - ## resolve via the symlink either way). - cat > "$SCAN_DIR/99-cac-lsphp-normalize.ini" <<'EOF' + ## ---- $_SERVER path parity with cac-fpm ---- + ## Point the cac_path_parity extension at THIS site's mapping. Same two + ## values the compatibility symlink above is built from, so the rewrite and + ## the symlink can never disagree. + ## + ## 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" < "$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 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 ## back to the baked lsphp-overrides.ini defaults when unset. 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}" } > "$SCAN_DIR/99-user-opcache.ini" 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 +echo "entrypoint-lsphp: \$_SERVER path parity = ${PATH_PARITY_MODE} (${OLS_SITE_PATH} -> /home/${user})" + ## ---- ownership ---- ## 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