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, symlink-swap TOCTOU attacks, 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
# 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/publicWhen unset (the default), no symlink can leave DOCUMENT_ROOT.
Laravel example
DOCUMENT_ROOT=/app/public
SYMLINK_ALLOW_PATHS=../storage/app/publicThen 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. No code change in the application.
How it works
At startup, each entry is resolved:
- Absolute entries — checked against the blacklist (see below), then passed through
realpath(3)(i.e.std::fs::canonicalize). Ifrealpathfails (target doesn't exist) the server refuses to start. - Relative entries — joined with the canonical
DOCUMENT_ROOT, thenrealpath'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:
- lives inside
DOCUMENT_ROOT, or - equals exactly one of the allow-list entries (file targets), or
- starts with one of the allow-list entries followed by
/(directory targets).
The same check runs a second time as a TOCTOU guard inside the static-file serve path, after the route cache, before any read syscall.
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:
/ /etc /proc /sys /dev /var /home /tmp /root /usr /srvForbidden as prefix (entry lies under one of these directories):
/etc /proc /sys /dev /tmp /root /usr/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 — 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 - Runtime canonicalisation closes the symlink-swap TOCTOU window — the validated path is the path that gets read
- File targets match exactly; directory targets match by directory prefix. Listing a file at
/etc/passwdwould still be rejected by the blacklist, but more generally: listing a single file at/opt/shared/license.keydoes not implicitly grant access to siblings - Path validation results are cached per requested URL (one
realpathper unique URL until eviction), so the runtime cost is amortised away
See also
- PHP Deny Paths — block PHP execution under specific URI globs; orthogonal to symlink policy
- Dot Path Blocking — refuses
.well-known-style traversal and dotfile leaks - Trusted Proxies — separate trust boundary for
X-Forwarded-*headers - Configuration Reference — all environment variables