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, because the resolved script path is matched against the patterns as well as 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, since the deny-list only affects .php execution and 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, so /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. It also answers requests denied by a .oxphpdeny rule, so one setting covers both.

HTTP status

Any value from 400 to 599 (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 loop check concerns PHP_DENY_PATHS only: a .oxphpdeny rule may cover the script, which then answers a direct request for itself. 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

For a PHP_DENY_PATHS match, 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. For a .oxphpdeny rule, the pattern is the line as written in the file (/composer.*, *.sql), with its leading / when the line has one. A script that answers both should not assume either 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, with no difference in timing or in the body. 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, but the resolved script does). The direct-URI screen, which is the path attackers probe with, stays oracle-free.

Observability

Metric Description
oxphp_php_deny_total Counter incremented on every request PHP_DENY_PATHS denies

Each such denial also produces a tracing::info log:

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

A request that a .oxphpdeny rule denies is answered before PHP_DENY_PATHS is checked, so it shows up in neither of these. It is counted in oxphp_path_deny_total and logged at debug.

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:

  • Catch a PATH_INFO bypass of a literal file pattern. 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.
  • Match case-insensitively (see Case Sensitivity above).
  • Support regex. Patterns are globs only, anchored, with */**/?/[abc]/{a,b} operators. Use multiple comma-separated patterns instead of (a|b).
  • Affect 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.

Prompts for your agent

These prompts assume an agent with the OxPHP documentation MCP server connected. The agent reads the pages it needs itself.

prompt
Harden my legacy PHP application against uploaded-shell execution with `PHP_DENY_PATHS`. Read the `security/php-deny` page through the OxPHP documentation MCP server. Confirm which routing mode this repository runs, since the deny-list is ignored in Framework and Worker mode, then find its writable and upload directories and give me the glob patterns and fallback value to deny them.

See also

Found a mistake? Report it →