Files
cloud-apache-container/ext/cac-path-parity/cac_path_parity.c
T

295 lines
11 KiB
C
Raw Normal View History

/*
* 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