295 lines
11 KiB
C
295 lines
11 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.
|
||
|
|
* - $_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
|