/* * 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. * - either side of the mapping not an ABSOLUTE path -> inert. Nothing * the entrypoint writes is anything else, and a relative prefix cannot * usefully match a SAPI-supplied path, so a malformed mapping is treated * exactly like an absent one. * - $_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. * * The one thing this CANNOT detect is a well-formed but WRONG mapping: it will * confidently rewrite to a wrong path and say nothing. That is accepted by * construction rather than overlooked — entrypoint-lsphp.sh derives from/to from * the same two variables it builds the compatibility symlink from, so a wrong * mapping means the symlink is wrong too and the site is already broken in a far * louder way. The only runtime signal is `lsphp -i`, which prints * "Rewriting => active" alongside the live from/to values. * * 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; /* * to="/" is the one absolute prefix that survives cacpp_trim() as a bare * separator, and the tail always starts with one — splicing both would give * "//public_html". Drop it when there IS a tail; keep it when there is not * (value == from exactly, where "/" is the correct answer). Unreachable from * the entrypoint, which always writes to=/home/. */ size_t eff_to_len = to_len; if (tail_len > 0 && eff_to_len == 1 && to[0] == '/') { eff_to_len = 0; } zend_string *out = zend_string_alloc(eff_to_len + tail_len, 0); memcpy(ZSTR_VAL(out), to, eff_to_len); memcpy(ZSTR_VAL(out) + eff_to_len, s + from_len, tail_len); ZSTR_VAL(out)[eff_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; } /* * Both sides must be ABSOLUTE. The entrypoint only ever writes absolute * paths; a relative prefix would be a typo or a mangled ini, and matching it * against a SAPI-supplied path could only ever produce nonsense. Treat it * like an absent mapping — inert, no diagnostic, request proceeds. */ if (*from != '/' || *to != '/') { 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