Files
cloud-apache-container/ext/cac-path-parity/cac_path_parity.c
T
Claude 07378506a7 harden(cac-lsphp): close the two remaining unvetted ini emissions; stop MINFO lying
Three non-blocking findings from the re-review of this branch. No design change:
the extension's fail-open-absolute invariant is untouched (still zero error
emitters, every RINIT return is SUCCESS) and both RINIT guards stay pure
narrowing.

1. entrypoint-lsphp.sh: 99-user-opcache.ini was still emitted unquoted
   -------------------------------------------------------------------
   Twenty-five lines below the mapping fix, the opcache override block
   interpolated the raw env into an unquoted `echo` — the same injection class
   the mapping fix closed. Measured on this branch's image, before this commit:

     OPCACHE_MEMORY_MB=$'128\nprecision = 7\n; '
       -> 99-user-opcache.ini gained a `precision = 7` line
       -> lsphp -i reported  precision => 7 => 7

   WHP casts (int) and clamps 32-512 / 2000-32000 (site-pool-env.php), so this
   is not exploitable today — but "the panel validates it" is precisely the
   argument this branch already rejected for `domain`, and the panel is a
   different repo on a different release cadence. Both siblings in the block are
   now validated at the point of use (digits only, length-capped, range-checked)
   and emitted double-quoted. A rejected value is dropped with a WARNING and the
   image default applies; nothing here is ever fatal.

   The accepted ranges are PHP's own limits for these directives (>= 8 MB;
   [200, 1000000] files), deliberately a strict SUPERSET of the panel's clamps,
   so widening a panel clamp later cannot start silently rejecting real sites.

   The block now also removes a stale fragment when it has nothing valid to
   write: the container filesystem outlives `docker restart`, so without that an
   override that is later cleared — or rejected — would keep applying from the
   previous boot's file.

2. 99-user-error-log.ini was written from an unvetted $user
   --------------------------------------------------------
   It was emitted before the INI_TOKENS_OK branch. Contained in practice (a
   newline is inert inside the quotes, and a `${`-bearing user cannot exist
   because useradd would have failed under `set -euo pipefail`), but "this
   particular unvetted value happens to be contained" is the reasoning this
   branch rejected one screenful up. Now gated identically.

   Costs a rejected user nothing it needs: `log_errors = On` is already baked in
   by 99-prod-overrides.ini, so PHP still logs — to stderr, i.e. `docker logs`,
   which is more visible than a per-site file, not less. Verified fleet-wide
   that no legitimate user reaches the branch (30 shared_ols sites, 4 hosts).

3. MINFO reported "active" for mappings RINIT ignores
   ---------------------------------------------------
   The absolute-path guard was added to RINIT and MINFO kept testing only "both
   values non-empty", so:

     from=mnt/users/bob/site.com   (relative -> INERT since the guard landed)
     lsphp -i  ->  Rewriting => active

   That row is what the post-deploy fleet canary greps to confirm parity is live,
   so the diagnostic would have masked exactly the failure the canary exists to
   find — and the C comment added by this branch documents it as the only runtime
   signal. RINIT and MINFO now share one predicate pair
   (cacpp_mapping_configured / cacpp_mapping_active) rather than two longhand
   copies, which is what drifted. MINFO now distinguishes "inactive (mapping not
   absolute)" from "inactive (unconfigured)" — different operational problems.

   Pure reporting change: the predicates are side-effect-free and cannot fail, so
   MINFO gains no error path.

Tests: two new .phpt cover both directions of the MINFO fix (009 relative
mapping must report inactive, 010 well-formed mapping must still report active),
so tightening it cannot overshoot into the opposite lie. The build gate's
EXPECTED count is derived from `ls tests/*.phpt`, so it picked them up: 10/10.

Non-vacuity, all five demonstrated by mutation:
  - opcache quoting reverted     -> injection lands, `precision => 7` observed
  - error-log gate removed       -> fragment written from the unvetted user
  - MINFO reverted to non-empty  -> 009 FAILS, build gate exits 1
  - MINFO forced always-inactive -> 010 FAILS, build gate exits 1
  - all restored                 -> 10/10, build exit 0
2026-08-05 13:46:52 -07:00

379 lines
15 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.
*
* That row is what the post-deploy fleet canary greps, so MINFO's "active" test
* must stay a mirror of the conditions RINIT actually rewrites under — see
* PHP_MINFO_FUNCTION below, which shares cacpp_mapping_active() with RINIT
* precisely so the two cannot drift. A MINFO that reported "active" for a
* mapping RINIT treats as inert would mask exactly the failure the canary
* exists to catch.
*
* 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;
}
/*
* THE MAPPING PREDICATE — one definition, two callers.
*
* RINIT uses it to decide whether to rewrite; MINFO uses it to REPORT whether
* rewriting is live. Those two tests were written out longhand in two places
* and promptly drifted: the absolute-path guard was added to RINIT only, so
* `lsphp -i` went on printing "Rewriting => active" for a mapping RINIT had
* already decided to ignore. That row is the fleet canary's signal, so the lie
* masked precisely the failure the canary looks for. Keep them sharing this.
*
* Pure predicates over two NUL-terminated strings: no allocation, no side
* effect, no way to fail — MINFO gains no error path by calling them, and the
* fail-open invariant is untouched.
*/
static int cacpp_mapping_configured(const char *from, const char *to)
{
return from != NULL && *from != '\0' && to != NULL && *to != '\0';
}
static int cacpp_mapping_active(const char *from, const char *to)
{
return cacpp_mapping_configured(from, to) && *from == '/' && *to == '/';
}
/* 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 (!cacpp_mapping_configured(from, to)) {
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 (!cacpp_mapping_active(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);
/*
* Report what RINIT would ACTUALLY do, by asking the same predicates RINIT
* asks — never a longhand copy of them (see cacpp_mapping_active above for
* what that cost last time). Three distinct answers, because "configured but
* ignored" is a different operational problem from "not configured" and the
* canary must be able to tell them apart.
*/
const char *state;
if (cacpp_mapping_active(from, to)) {
state = "active";
} else if (cacpp_mapping_configured(from, to)) {
state = "inactive (mapping not absolute)";
} else {
state = "inactive (unconfigured)";
}
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 — and,
* since the predicate is shared with RINIT, proves the request path agrees.
*/
php_info_print_table_row(2, "Rewriting", state);
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