Path Deny Rules (.oxphpdeny)
A .oxphpdeny file at the top of DOCUMENT_ROOT lists request paths the server must not serve directly. It is written in .gitignore syntax with one addition: a rule prefixed with > hands the request to the application's entry script instead of denying it.
# Never served — answered with PHP_DENY_FALLBACK (404 by default)
/vendor
/composer.*
*.sql
*.bak
# Not served directly — the front controller decides
> /storage/invoices/It is meant for files a document root should not hold but often does: vendor/ and composer.json in a legacy application served from its project root, database dumps and backups, build artefacts and source maps in a framework's public/. The rules apply in all four routing modes, to static files and PHP scripts alike. They are checked before any disk access, so a denied path gets the same answer whether or not the file exists.
The file is read once, at startup: edits take effect on the next start. Without the file nothing changes. The file itself is never served. Like every path segment starting with . other than a leading /.well-known/, it is refused before routing by dot-path blocking.
Syntax
What .gitignore supports, .oxphpdeny supports:
- Blank lines and lines starting with
#are skipped. A UTF-8 byte-order mark and CRLF line endings are accepted. - Trailing spaces are dropped unless escaped (
\). !re-includes what an earlier rule excluded.\#and\!at the start of a line are a literal#and!.- A trailing
/makes the rule match directories only. - A
/at the start or in the middle anchors the pattern to the top ofDOCUMENT_ROOT. Without one, the pattern matches at any depth:vendorcovers/vendorand/lib/vendor. *and?stay within one path segment;[abc],[a-z],[!0-9]and[^0-9]are character classes. As in git,?and a class match a single byte, and a class never matches/:[!-]is any byte of a name but-. A letter outside ASCII takes two or more bytes, so only*stands for one: write/caf*.txt, not/caf?.txt. POSIX classes, backslash escapes, characters outside ASCII, a-right after a range and a range that runs backwards are not supported inside a class, and a negated class in a deny or>rule cannot name an ASCII letter (see Differences from.gitignore).**/at the start,/**at the end and/**/in the middle match across directories;**anywhere else is a plain*. A longer run of*counts as**, as in git:***/secret.txtcovers/secret.txtat any depth.- The last matching rule wins, and nothing below an excluded directory can be re-included: with
private/excluded,!private/ok.txthas no effect. To exclude a directory's contents but keep some, exclude/private/*and re-include!/private/ok.txt.
> rules: hand the request to the application
> /storage/invoices/
> *.mapA request matching a > rule is not served from disk. It goes to the entry script with REQUEST_URI intact, exactly as a request for a missing file would, and the application can check a session and stream the file itself. The entry script is the front controller (ENTRY_FILE=index.php) or the worker. Spaces and tabs between > and the pattern are allowed; \> is a literal >. A > rule cannot be negated (!>x is an error), and > alone is an error.
A deny rule still applies below a > directory: with > /storage/invoices/ and *.sql, /storage/invoices/dump.sql is denied, whichever line comes first. A ! rule that lifts the deny leaves the file to the > directory, as in git: add !/storage/invoices/dump.sql after *.sql and the entry script gets that request. ! re-includes nothing below a > directory, as below any excluded one, so the file is still not served from disk.
> rules need an entry script. In Traditional and SPA modes they stop startup.
Differences from .gitignore
- A malformed line stops startup, with the file path and line number, and so does a file that is not UTF-8. git skips such lines silently.
{a,b}is literal. Braces match themselves, as in git.- A class that names
/stops startup, whether directly ([-/]) or through a range across it such as[+-0]. git accepts it and never matches/with it; write the separator outside the class. - A POSIX class, a backslash inside a class, or a
-right after a range stops startup:[[:digit:]],[\]],[a-b-c]. git reads each its own way. Write the range instead ([0-9]), put]first to match it, and put a literal-first or last ([]-],[-a-c]). - A range that runs backwards stops startup, such as
[z-a]or[a--]. git reads it as its first character alone, sox[z-a]covers onlyxz; write that character, or put the lower end first. - A class holding a character outside ASCII stops startup, for example
[äÄ]. git matches a class against one byte, as the server does, and such a character takes two or more:/[äÄ]dminwould cover neither/ädminnor/Ädmin, and/[äÄ]*would cover/öffnungszeiten, which starts with the same byte. Put*in its place. - An escaped
/stops startup (**\/secret.txt,\/vendor). git reads**\/secret.txtassecret.txtbelow at least one directory (not/secret.txtitself), which the server would not, and\/vendormatches no path at all. A separator never needs the escape: write/. - A line that covers other than it reads stops startup, meaning one that starts with a space or tab, ends in an unescaped tab, has
//anywhere, or holds a NUL byte. git accepts each. Whitespace at either end becomes part of the name:> /storage/invoices/, indented, is a deny rule for a path starting with a space rather than a>rule. Escape it with\when a name really starts or ends with it.//vendorandvendor//match no path at all. git ends a pattern at a NUL, sovendor, a NUL and more text cover/vendorin git, where the server would read the whole line and cover only a name holding that NUL. A file saved as UTF-16 has a NUL after every ASCII character: save it as UTF-8. - A line of spaces and tabs is blank. git reads a line holding a tab as a pattern, for a name made of that whitespace.
- Deny and
>rules ignore the case of ASCII letters;!rules do not. The server runs/uploads/shell.PHPas a PHP script on any filesystem, and a case-insensitive one (macOS, Windows) serves/VENDOR/autoload.phpfromvendor/, so a rule that matched one spelling would let the others through:*.phpcoversshell.PHP, andvendor/covers/Vendor/. A!rule re-includes only the spelling it names (with*and!/index.php,/INDEX.PHPstays denied), so ignoring case never lets the server serve from disk a path that exact matching would have kept from it. To keep it so, a negated class in a deny or>rule cannot name an ASCII letter, and one that does stops startup: ignoring case would make[!i]leave outIas well, and/[!i]*.phpwould run/Info.php. Match any character there and re-include the exception, which a!rule then matches exactly:/*.phpand!/i*.php. git matches case-sensitively unlesscore.ignorecaseis set. - A few letters outside ASCII are read as ASCII. A case-insensitive filesystem (macOS) reads them so, and serves
license.txtfor/licenſe.txt:ſ(long s) ass,ßandẞasss, the Kelvin sign (U+212A) ask, the ligaturesfffiflffifflſtstas the letters they join, and the Greek question mark (U+037E) and varia (U+1FEF) as;and`. Deny and>rules read them so too, in the request and in the rule:/license.txtcovers/licenſe.txt, and/straße/covers/strasse/. A rule still covers what it covered as written, and!rules read these letters only as written, so a denial only widens. - Other letters outside ASCII match only as written, and so does each Unicode form of a name. A rule for
/ädmindoes not cover/Ädmin, and a rule forcafé.txtwritten with a composedédoes not covercafefollowed by a combining accent (U+0301). Most Linux filesystems keep each spelling a separate file, so this matters on macOS, which treats both pairs as one file, including through a bind mount from macOS into Docker Desktop. There, a request for the other spelling is served from disk. Put*in place of such letters (/caf*.txt), since?and a class match a single byte, or keep those files out ofDOCUMENT_ROOT. - At most 256 rules, each at most 128 bytes. A rule past either limit stops startup with its line number (see Limits).
- One file, at the top of
DOCUMENT_ROOT, read at startup. Files in subdirectories are not read. It must be a regular file of at most 1 MiB; a symlink to one is fine. A FIFO or a device stops startup unread, and a larger file stops it once its first MiB is read.
What is matched
Rules match the request path after percent-decoding and normalization. Empty segments are dropped, so //vendor/x, /vend%6Fr/x and /vendor%2Fx are all /vendor/x. A segment starting with . (., .., .git) never reaches the rules: the request is refused with a plain 404 first, without PHP_DENY_FALLBACK and without being counted. The exception is a leading /.well-known/ (RFC 8615), which dot-path blocking lets through and the rules see like any other path.
A request is a directory request when its decoded path ends with / (/admin/, /admin%2F, /admin//). A trailing-slash rule matches only those, and everything below them:
| Rule | /admin |
/admin/ |
/admin/x |
/admin/x/y |
|---|---|---|---|---|
admin/ |
— | denied | denied | denied |
admin or /admin |
denied | denied | denied | denied |
admin/*, admin/x |
— | — | denied | denied |
The bare / is never matched, so an allowlist-style file still serves the site root:
*
!/index.php
!/assets/
!/assets/**
!/.well-known/
!/.well-known/**Keep the /.well-known/ lines if the site renews its TLS certificate over ACME HTTP-01. Without them, /.well-known/acme-challenge/<token> is denied.
Two other readings of a request are judged too, and a denial under either stands:
- A directory request is also judged as the file it names. The router drops the trailing slash and may serve
/secret.txt/as the filesecret.txt. - In Traditional mode, a
.phpsegment followed by more path is judged as the script it names./secret.php/x.jsrunssecret.phpwith PATH_INFO/x.js, so it is denied wherever/secret.phpis.
Either way, !*/, the gitignore way to re-include every directory, cannot bring a denied file back. In an allowlist, re-include a directory without the slash (!/docs) to keep /docs/ itself reachable. No other file the router resolves a request to is judged, as the next section shows.
The request path, not the file
Rules see the path the client asked for, not the file the router resolves it to. A request that resolves to a different file is judged by its own path:
/adminwith the ruleadmin/: in Traditional mode the router answers/adminwithadmin/index.php, and/adminis not a directory request./uploads/with the ruleuploads/*.php: the directory indexuploads/index.phpruns, because the request path is/uploads/./x.php/y.png(PATH_INFO) runsx.phpalthough the path ends in.png. The script is named in the path, so Traditional mode judges the request as/x.phptoo, as described above.
To close a directory, write it without the trailing slash (/admin). To stop .php execution in writable directories, use PHP_DENY_PATHS as well: it also checks the script a request resolves to.
Routing modes
| Routing mode | Deny rules | > rules |
|---|---|---|
Traditional (no ENTRY_FILE) |
Yes | Startup error: there is no entry script |
SPA (ENTRY_FILE=index.html) |
Yes | Startup error, since index.html cannot take a request |
Framework (ENTRY_FILE=index.php) |
Yes | Front controller |
Worker (WORKER_MODE_ENABLED=true) |
Yes, with a status-code fallback | Worker entry |
Fallback
A deny rule answers with PHP_DENY_FALLBACK, the setting PHP_DENY_PATHS uses: an HTTP status (404 by default; pairs with ERROR_PAGES_DIR) or a PHP script inside DOCUMENT_ROOT. The script receives OXPHP_DENIED_PATH (the request path, with a leading /) and OXPHP_DENIED_PATTERN (the rule as written in the file, e.g. /composer.*).
The script may sit under a path the file denies. /_security/ hides it from direct requests, and a direct request for it is answered by the script itself, as a denial. PHP_DENY_FALLBACK is read only when the file has at least one deny rule (or PHP_DENY_PATHS is in effect, in Traditional and SPA modes).
In Worker mode the fallback must be a status code: a worker thread runs nothing but the worker entry.
When both .oxphpdeny and PHP_DENY_PATHS match a request, .oxphpdeny answers it.
Observability
- The
oxphp_path_deny_totalmetric counts requests denied by a rule.>rules are routing, not denials, and are not counted.oxphp_php_deny_totalkeeps countingPHP_DENY_PATHSonly; a request both deny is counted once, inoxphp_path_deny_total. - On
/config, thedeny_fileentry gives the rule counts by action and the fallback kind, never the rules (see Internal Server). - The startup log reports
loaded 5 rules (3 deny, 1 entry, 1 allow) from /var/www/html/public/.oxphpdeny. WithLOG_LEVEL=debug, oneoxphpdeny ruleline per rule shows how it was read: line number, action, the pattern as written and the glob it compiled to. - Per request, each denial logs
request denied by .oxphpdenyatdebug, with the path, the rule and its line. A request refused for its depth or length logsrequest refused: path too deep or too long to match against .oxphpdenyatdebug, with its segment count and length; it is not counted.
Startup errors
| Condition | Result |
|---|---|
No .oxphpdeny |
Feature off; nothing changes |
| File unreadable or not UTF-8, or a symlink whose target is missing | Startup error |
| Malformed line | Startup error with <path>: line N: <message> |
| More than 256 rules, or a pattern longer than 128 bytes | Startup error with the line number |
> rule in Traditional or SPA mode |
Startup error |
Worker mode, a deny rule, and a script PHP_DENY_FALLBACK |
Startup error |
Invalid PHP_DENY_FALLBACK and at least one deny rule |
Startup error |
Empty file, comments only, or only ! rules |
Nothing to deny: routing is unchanged, depth limit included; /config shows the file as loaded with its rule counts |
oxphp config --check reports the file errors before a deploy (config: INVALID). The fallback checks run when the server builds its routing, at startup.
Limits
- Path depth and length are capped. With at least one deny or
>rule, a request path deeper than 64 segments, or whose segment count times its length passes 128 KiB, is answered404without being matched. Matching re-checks every parent directory and scans each one's path, so its cost would otherwise grow with both. A path of up to 2 KiB is refused only for its depth; past that the budget is spent by 16 segments at 8 KiB, or 3 at about 43 KiB. - A file holds at most 256 rules, each pattern at most 128 bytes, not counting a leading
!or>. A request path is matched against the rules one by one, and against each parent directory too, so these limits and the one above bound what a request costs. A typical file takes well under a millisecond a request. A file at both limits built to be slow (256 long rules starting/x/**/*, against paths made to almost match them) takes about 40 ms a request and keeps about 50 MB of match state per Tokio worker thread. - For case sensitivity, see Differences from
.gitignore. - There is no reload. Restart to apply an edit.
Examples
A legacy application served from its project root:
/vendor
/composer.*
/config
*.sql
*.sql.gz
*.bak
*.log/readme.html
/license.txt
/wp-config-sample.php
/wp-content/debug.logIn Framework mode, the front controller gets /storage/invoices/2026-07.pdf, checks the session and streams the file:
> /storage/invoices/Prompts for your agent
These prompts assume an agent with the OxPHP documentation MCP server connected. The agent reads the pages it needs itself.
Write a `.oxphpdeny` file for the application in this repository. Read the `security/oxphpdeny` page through the OxPHP documentation MCP server.
Work out which directory is `DOCUMENT_ROOT` and which routing mode the deployment uses, then find what under that directory should never be served — `vendor/`, `composer.*`, config files, database dumps, backups, logs, source maps — and which of those paths the application serves itself and needs a `>` rule instead. Give me the file with a comment on each rule, and list anything it cannot cover because rules match the request path rather than the file it resolves to.Port the deny rules from my old web server configuration to `.oxphpdeny`. Read the `security/oxphpdeny` page through the OxPHP documentation MCP server.
Find the nginx `location` blocks that `deny all` or `return 403`/`404`, and the `.htaccess` rules that deny access, in this repository, and translate each into a `.oxphpdeny` line. Tell me which ones dot-path blocking already covers and which ones the file cannot express.Review the `.oxphpdeny` file in this repository for gaps. Read the `security/oxphpdeny` page through the OxPHP documentation MCP server.
Check each rule against how this application is deployed: directories written with a trailing slash that a directory index still reaches, names with letters outside ASCII that macOS serves under another spelling, writable directories that also need `PHP_DENY_PATHS`, and an allowlist that would block `/.well-known/acme-challenge/`. Give me the corrected file and say what each change closes.See also
- PHP Execution Deny-List:
PHP_DENY_PATHSandPHP_DENY_FALLBACK - Dot-Path Blocking: hidden files and directories, refused before these rules run
- Routing: routing modes and path security
- Metrics:
oxphp_path_deny_total