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
# 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 400–599 (default 404). Pairs with ERROR_PAGES_DIR for a custom HTML body:
PHP_DENY_PATHS="/uploads/**"
PHP_DENY_FALLBACK="403"
ERROR_PAGES_DIR="/var/www/errors" # serves errors/403.htmlPHP script
A /-prefixed URI path to a fallback script inside DOCUMENT_ROOT:
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:
<?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:
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.phpdoes 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 doesinclude $_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:
WARN PHP_DENY_DIRS is deprecated, use PHP_DENY_PATHS instead — the alias will be removed in a future releaseWhen both are set, PHP_DENY_PATHS wins and PHP_DENY_DIRS is reported as ignored. Values are not merged.
See also
- Routing — routing modes and path security
- Error Pages — custom HTML bodies for status-fallback responses
- Configuration Reference — full env-var list
- Metrics —
oxphp_php_deny_totaland friends