Symlink Allow Paths

By default OxPHP refuses any request that resolves to a path outside the canonical DOCUMENT_ROOT. A symlink inside DOCUMENT_ROOT pointing at an external directory returns 404 and the path resolution logs Blocked request: resolved path escapes document root.

This is the right default: it stops directory traversal and accidentally exposing config files or secrets sitting one level up. But it also blocks legitimate patterns that frameworks have been using for years: Laravel's php artisan storage:link, Symfony asset bundles, shared upload volumes mounted into multiple containers.

SYMLINK_ALLOW_PATHS is the explicit opt-in: you list the filesystem paths that symlinks under DOCUMENT_ROOT are permitted to resolve to. Anything not on the list keeps the strict 404 behaviour.

Configuration

bash
# Absolute paths, comma-separated SYMLINK_ALLOW_PATHS=/var/www/storage,/opt/shared/assets # Relative paths resolve against DOCUMENT_ROOT SYMLINK_ALLOW_PATHS=../storage,../shared/uploads # Mixed SYMLINK_ALLOW_PATHS=/opt/shared/cdn,../storage/app/public

When unset (the default), no symlink can leave DOCUMENT_ROOT.

Laravel example

bash
DOCUMENT_ROOT=/app/public SYMLINK_ALLOW_PATHS=../storage/app/public

Then php artisan storage:link creates public/storage -> ../storage/app/public inside the project. URLs to /storage/<file> resolve through the symlink to /app/storage/app/public/<file>, which canonicalises to a path the allow-list authorises. The application needs no code change.

How it works

At startup, each entry is resolved:

  • Absolute entries are checked against the blacklist (see below), then passed through realpath(3) (i.e. std::fs::canonicalize). If realpath fails because the target doesn't exist, the server refuses to start.
  • Relative entries are joined with the canonical DOCUMENT_ROOT, then realpath'd.

The resulting canonical paths are stored as the allow-list. Duplicates are silently deduped.

At request time, the routing layer canonicalises the resolved file path and verifies it satisfies one of:

  1. lives inside DOCUMENT_ROOT, or
  2. equals exactly one of the allow-list entries (file targets), or
  3. starts with one of the allow-list entries followed by / (directory targets).

The routing check caches its verdict per path, so the file that is finally read is checked again.

A `DOCUMENT_ROOT` behind a release symlink

DOCUMENT_ROOT is resolved once, at startup, and static files read from disk and PHP scripts are checked against that location every time they are opened. A root reached through a release symlink, such as /app/current/public in a Capistrano- or Deployer-style layout, therefore answers 404 for both once current is switched to a new release, until the server restarts. Files served from the in-memory cache keep answering with the bytes they hold. Point DOCUMENT_ROOT at a path that does not change across releases, or restart the server as part of the switch.

Static files

For static files the check runs a second time at serve time, on the file actually opened and before any byte of it is read. The location is taken from the open descriptor (/proc/self/fd/N on Linux, F_GETPATH on macOS), so a symlink swapped after the cached routing check is still refused. Where the descriptor's path is unavailable, the path is resolved again and must name the same file that was opened.

Note

A file answered from the in-memory cache is not reopened. A swapped link keeps returning the bytes cached from its earlier, validated target until the entry is evicted, or, with STATIC_REVALIDATE=on, until revalidation notices the change.

PHP scripts

PHP scripts get the same treatment in every mode except Worker mode, whose entry file is a path the administrator configured. The worker opens the script itself, checks the location of the open descriptor the same way, and hands the engine that open file rather than the path, so the file that is opened is the file that was checked. There is one exception: a script whose resolved name contains .phar and which is a zip, tar or compressed phar archive is opened a second time by name by the phar extension, and that second open is not covered by the check.

Script at serve time Result
Not a regular file (a directory, a FIFO), or exists but cannot be read 500, logs Cannot open script. PHP's own failed-to-open warning and fatal error are not produced.
Deleted since routing 404, including a script OPcache holds, which with opcache.validate_timestamps off would otherwise run from the cache without the file being opened
Link repointed after routing, to a script or to a text file the engine would print as is 404, logs Blocked request: opened script escapes document root
Link repointed to another script inside DOCUMENT_ROOT, or to an allow-listed target Runs that script

__FILE__, __DIR__ and $_SERVER['SCRIPT_FILENAME'] are the same as without the check. Because the engine is handed the resolved location of the file, three things follow:

  • OPcache keeps a script reached through a link under the file the link resolves to.
  • The script's working directory is the directory of that file. For a link to a file in another directory, that is not the directory the link sits in.
  • The phar extension decides by that resolved name whether the script is a phar. A link named app.phar.php that points at a zip-based phar whose own name has no .phar is compiled as a plain file, and a link without .phar in its name that points at a phar whose name has it runs as one.
Included files are not checked

The check covers the script a request was routed to. Files that script pulls in with include or require, or opens by name, and auto_prepend_file, are resolved by PHP as usual. Where scripts or their directories are writable by code you do not trust, add open_basedir as well.

open_basedir applies to the routed script itself too: one outside it is refused the way PHP refuses any script it cannot open, with status 500 and the usual open_basedir restriction in effect warnings. It is checked when PHP compiles the script, at the same point as for a script PHP opens itself: after auto_prepend_file has run, with the script's directory as the working directory. A relative entry such as open_basedir=. therefore means the script's directory, and an open_basedir that the prepended file narrows with ini_set() applies to the script it prepends to. That includes a script OPcache already holds, which PHP would have run without checking.

Blacklist

A small set of paths can never appear in SYMLINK_ALLOW_PATHS; typos and misunderstandings would otherwise widen the attack surface dramatically. The server refuses to start if any entry resolves to a blacklisted path.

Forbidden as exact match:

text
/ /etc /proc /sys /dev /var /home /tmp /root /usr /srv

Forbidden as prefix (entry lies under one of these directories):

text
/etc /proc /sys /dev /tmp /root /usr
Note

/var, /home, and /srv are exact-only: bare /srv is rejected, but /srv/myapp/storage is allowed, just as /var/www/storage and /home/<any>/... are allowed. Entries are checked twice: once against the raw admin-supplied path (so that macOS-style /etc -> /private/etc cannot launder a blacklisted path through realpath), once against the canonical form (defense-in-depth for symlink-target escapes).

The blacklist itself is hardcoded; there is no env var to extend it. The default is the conservative minimum that catches typo-level mistakes; admins who need stricter policies should layer them outside (filesystem permissions, container mount restrictions, AppArmor/SELinux profiles).

Failure modes

Configuration error Result
Entry target does not exist on disk Server refuses to start, error names the entry and canonicalize
Entry matches the blacklist (raw or canonical) Server refuses to start, error names the entry and the blacklist rule
Empty/whitespace-only env var Treated as unset (strict default behaviour)
Duplicate entries Deduped silently after canonicalisation
No symlink exists yet at startup Allow-list is registered but inert until a symlink appears; no startup check requires the symlink

Security notes

  • The allow-list is opt-in, and the safe default of "no escapes" is preserved when the variable is unset
  • Entries are canonicalised at startup, so .. and intermediate symlinks in the entry path are collapsed before storage
  • Static files and PHP scripts are checked on the opened file, so on Linux with /proc mounted, and on macOS, swapping a symlink after it has been served once cannot make a later response come from, or run a script from, outside the allowed locations. The fallback used elsewhere is narrower, as described above
  • File targets match exactly; directory targets match by directory prefix. Listing a file at /etc/passwd would still be rejected by the blacklist, but more generally: listing a single file at /opt/shared/license.key does not implicitly grant access to siblings
  • Routing-time validation is cached per requested URL (one realpath per unique URL until eviction). The serve-time check adds a lookup of the open descriptor's location for each static file read from disk, and nothing for a file served from the in-memory cache. For a PHP script outside Worker mode it adds about eight system calls to every request (counted with strace on PHP 8.4): an open and a close of the script, that lookup, and fstat, fcntl and lseek calls on the descriptor. That includes a request OPcache answers, which would otherwise not touch the file when opcache.validate_timestamps is off. On a script that echoes one word this measured about 3% of throughput; a real application spends proportionally less of its request on it

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
Set up `SYMLINK_ALLOW_PATHS` for the storage symlinks my application depends on. Read the `security/symlink-allow-paths` page through the OxPHP documentation MCP server. Find the symlinks under my document root, such as a `storage:link`-style link, and their real targets, then give me the allow-list entries and flag any target that would hit the blacklist.

See also

Found a mistake? Report it →