PHP Execution Deny-List

PHP_DENY_PATHS blocks direct execution of .php files matching configured glob patterns. It targets a recurring class of vulnerabilities in legacy PHP applications: an attacker uploads a PHP file to a writable public directory (/uploads, /cache, image-resize temp dirs) and reaches it via direct URI to gain code execution.

The check runs before any disk I/O, so denied paths return the same response whether the file exists on disk or not. There is no existence oracle for attackers to probe upload directories with.

When it applies

Direct-mapping modes, the modes where a URI resolves straight to a .php file on disk:

Routing mode PHP_DENY_PATHS honored?
Traditional (no ENTRY_FILE) Yes
SPA (ENTRY_FILE=index.html) Yes — SPA executes existing .php files directly, so the deny-list applies
Framework (ENTRY_FILE=index.php) No — warned and ignored
Worker (WORKER_MODE_ENABLED=true) No — warned and ignored

In Framework mode every request is rewritten to the front controller and arbitrary .php files are never executed directly; a deny-list would only break application routes that happen to end in .php. In Worker mode every non-static request is dispatched to the worker script, so there is nothing for the deny-list to deny. Setting PHP_DENY_PATHS in either mode emits a startup warning and disables the check.

The deny-list also covers scripts reached indirectly: a request to /uploads/ that would resolve to uploads/index.php through the directory-index lookup is denied when uploads/** is on the list — the resolved script path is matched against the patterns, not just the request URI. For such denials OXPHP_DENIED_PATH carries the sanitized request URI without the trailing slash (/uploads/ is reported as /uploads).

Configuration

bash
# Comma-separated glob patterns PHP_DENY_PATHS="/uploads/**,/cache/**,/tmp/**" # What to return on a match (default: 404) PHP_DENY_FALLBACK="403"

A request to /uploads/shell.php now returns 403 without touching the disk. A request to /uploads/image.png is served normally — the deny-list only affects .php execution, never static-file serving.

Pattern syntax

Patterns are matched against the sanitized URI (the request path with .. segments and percent-encoded bypasses already resolved) using the globset syntax. The leading / on each pattern is optional — /uploads/** and uploads/** are equivalent.

Pattern Matches Doesn't match
/uploads/** /uploads/x.php, /uploads/a/b/c.php, /uploads/shell.php/extra /uploads.php, /public/uploads/x.php
/files/*.php /files/x.php /files/sub/x.php (single * does not cross /)
/admin/legacy.php /admin/legacy.php /admin/legacy.php/x (PATH_INFO not covered — see below)
/admin/legacy.php{,/**} /admin/legacy.php, /admin/legacy.php/x /admin/other.php
/**/wp-config.php /wp-config.php, /site/wp-config.php /wp-config.txt

Multiple patterns are combined with OR — a request matches the deny-list if it matches any pattern.

Single files vs directories

Both work. /uploads/** blocks an entire subtree; /admin/legacy.php blocks one specific script. To block a single legacy entry point and any PATH_INFO invocations of it (/admin/legacy.php/foo), use the brace form: /admin/legacy.php{,/**}.

Case sensitivity

Matching is case-sensitive. On case-insensitive filesystems (default macOS HFS+/APFS, default Windows NTFS, ext4 with casefold), a request to /uploads/Shell.PHP would bypass a pattern of /uploads/**/*.php. Use a broad directory pattern like /uploads/** (which matches all extensions) when serving from such a filesystem, or normalize uploads to lowercase at write time.

Fallback modes

PHP_DENY_FALLBACK controls what is returned on a match.

HTTP status

Any value in 400599 (default 404). Pairs with ERROR_PAGES_DIR for a custom HTML body:

bash
PHP_DENY_PATHS="/uploads/**" PHP_DENY_FALLBACK="403" ERROR_PAGES_DIR="/var/www/errors" # serves errors/403.html

PHP script

A /-prefixed URI path to a fallback script inside DOCUMENT_ROOT:

bash
PHP_DENY_PATHS="/uploads/**" PHP_DENY_FALLBACK="/_security/denied.php"

The script is validated at startup — it must exist, canonicalize inside DOCUMENT_ROOT, and must not itself match PHP_DENY_PATHS (loop prevention; startup aborts otherwise). The script runs with two extra $_SERVER keys identifying the original request:

$_SERVER key Value
OXPHP_DENIED_PATH Original sanitized URI, with a leading / (same form as PATH_INFO)
OXPHP_DENIED_PATTERN The glob pattern that matched

OXPHP_DENIED_PATTERN is stored without a leading / (glob-normalized), while OXPHP_DENIED_PATH keeps the / of the request URI. If you compare the path against the pattern, ltrim($_SERVER['OXPHP_DENIED_PATH'], '/') first so both are in the same form.

Example honeypot:

/_security/denied.php
<?php // /_security/denied.php — runs in place of any matched .php request. error_log(sprintf( "PHP execution denied: path=%s pattern=%s ip=%s ua=%s", $_SERVER['OXPHP_DENIED_PATH'] ?? '', $_SERVER['OXPHP_DENIED_PATTERN'] ?? '', $_SERVER['REMOTE_ADDR'] ?? '', $_SERVER['HTTP_USER_AGENT'] ?? '-', )); http_response_code(404); echo "Not Found";

This lets you decide the response per-request (return 404 to attackers, 403 to authenticated admins, redirect probe scanners to a sinkhole) instead of being limited to one static status.

No existence oracle

Both Status and Script fallbacks are returned without touching the filesystem. A request to /uploads/never-uploaded.php and a request to /uploads/actually-on-disk.php produce identical responses — no timing difference, no body difference. An attacker scanning for uploaded shells cannot use the deny-list to enumerate which filenames exist.

The resolved-path screen is the one exception: it necessarily runs after route resolution, so its denials are existence-dependent. /uploads/ is denied only when uploads/index.php actually exists on disk; likewise, with a single-star pattern such as /uploads/*.php, a PATH_INFO request /uploads/shell.php/x is denied only when uploads/shell.php exists (the full URI does not match the pattern — the resolved script does). The direct-URI screen — the path attackers probe with — stays oracle-free.

Observability

Metric Description
oxphp_php_deny_total Counter incremented on every denied request

Each denial also produces a tracing::info log:

text
PHP execution denied by PHP_DENY_PATHS path=uploads/shell.php pattern=uploads/**

Access logs record the resulting status (the PHP_DENY_FALLBACK value or the fallback script's http_response_code()) — denied requests are not distinguished from normal requests at the access-log level. Cross-reference with the metric or the structured log to attribute spikes.

Performance

Matching is a globset::GlobSet lookup — typically a single SIMD pass over the URI bytes. A hit also bypasses the route cache (denied URIs come from attacker-controlled spraying with effectively unbounded cardinality; caching them would let an attacker evict legitimate entries from the LRU). Both the hit and miss paths are alloc-free after warm-up.

Limitations

What this feature does not do:

  • PATH_INFO bypass for literal file patterns. A pattern of /admin/legacy.php does not match /admin/legacy.php/extra. Use /admin/legacy.php{,/**} to cover both, or use a directory pattern.
  • Case-sensitive matching (see Case Sensitivity above).
  • No regex. Patterns are globs only — anchored, with */**/?/[abc]/{a,b} operators. Use multiple comma-separated patterns instead of (a|b).
  • No effect on include / require / eval. The deny-list governs direct URI execution only. A vulnerable script that does include $_GET['page'] can still load PHP from anywhere readable by the server.

Deprecated alias

The legacy PHP_DENY_DIRS variable is accepted as a deprecated alias and emits a startup warning:

text
WARN PHP_DENY_DIRS is deprecated, use PHP_DENY_PATHS instead — the alias will be removed in a future release

When both are set, PHP_DENY_PATHS wins and PHP_DENY_DIRS is reported as ignored. Values are not merged.

See also