Three loose ends from the review, none reachable from entrypoint-lsphp.sh today.
The rewrite semantics and the prefix-boundary logic are untouched; both new
guards only NARROW the set of configurations that do anything, and neither adds
an error path — fail-open is unchanged.
- to="/" produced "//public_html": cacpp_trim() keeps a lone separator, and
the tail already starts with one. Collapse the prefix when there is a tail,
keep it when there is not (value == from exactly, where "/" is correct).
A doubled leading slash is not the same string as the cac-fpm value, which
is the entire point of the extension.
- a non-absolute `from`/`to` was accepted and applied. Both are now required
to start with '/', otherwise RINIT returns exactly as it does for an absent
mapping: inert, no diagnostic, request proceeds.
- a well-formed but WRONG mapping stays undetectable, and now the FAILURE
MODES block says so explicitly rather than leaving it as an unlisted gap,
along with why that is acceptable (the entrypoint 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 more
loudly) and where the only runtime signal is (`lsphp -i`).
Two tests added, both non-vacuous — 007 rewrites without the absolute-path
guard, 008 returns "//public_html" without the collapse. 8/8 pass on PHP
8.1/8.3/8.5, and the FPM harness still reports 9/9 against the changed .so.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
330 lines
13 KiB
C
330 lines
13 KiB
C
/*
|
|
* 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.
|
|
* - 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/<user>.
|
|
*/
|
|
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
|