arraypress / wp-file-utils
Path containment, MIME types and filename helpers for WordPress plugins that serve files.
Requires
- php: >=8.3
Requires (Dev)
- phpcompatibility/phpcompatibility-wp: ^2.1
- phpunit/phpunit: ^12.0
- squizlabs/php_codesniffer: ^3.13.5
- wp-coding-standards/wpcs: ^3.4
This package is auto-updated.
Last update: 2026-08-25 18:13:19 UTC
README
Path containment, MIME types and filename helpers for a WordPress plugin that serves files.
Install
composer require arraypress/wp-file-utils
Requires PHP 8.3.
Keeping a download inside its folder
The one function here that anything security-related should be calling:
use ArrayPress\FileUtils\Path; $file = Path::within( $downloads_dir, $_GET['file'] ); if ( null === $file ) { wp_die( __( 'No such file.', 'my-plugin' ), '', [ 'response' => 404 ] ); } readfile( $file );
Both sides are resolved with realpath() and compared, so .., symlinks,
doubled slashes, Windows separators and stream wrappers are all gone before
the comparison. The candidate has to exist — a path that does not resolve is
not a file anybody is about to read.
This replaces a sanitize_path() that removed .. by string replacement,
which is the canonical broken version: ....// has its inner .. removed
and comes out as ../. A check that can be defeated by writing the attack
twice is worse than no check, because callers stop looking for a real one.
Paths and URLs
Path::from_url( 'https://example.test/wp-content/uploads/2026/08/manual.pdf' ); Path::to_url( '/var/www/wp-content/uploads/2026/08/manual.pdf' ); Path::is_local( $file ); Path::normalize( $path ); // wp_normalize_path() when WordPress is loaded
from_url() tries uploads, then wp-content, then the site root, and each
branch confirms the file is actually there — a URL that maps to nothing is
not a path, and handing one back means the caller finds out two functions
later. A query string is not part of the path.
What a file is
use ArrayPress\FileUtils\MIME; MIME::of( 'novel.epub' ); // application/epub+zip MIME::must_download( 'image/svg+xml' ); // true MIME::is_media( 'video/mp4' ); // true MIME::extension_for( 'image/jpeg' ); // jpg
Core answers first. This library used to carry its own table of 64
extensions: 38 identical to core's, 5 disagreeing with it. The
disagreements were the problem — the same file was one type through the media
library and another through a download endpoint, and nothing said so. Core
called a Photoshop file application/octet-stream, which downloads; this
called it image/vnd.adobe.photoshop, which some browsers render.
What is left is the 21 core has no answer for, which is what a store actually sells: ebooks, fonts, installers, design files. A test asserts none of them collides with core, so adding one that does fails rather than silently overriding.
must_download() defaults to yes. Getting it wrong in that direction
shows somebody a file they wanted to save; getting it wrong in the other
renders whatever was uploaded in the store's own origin. HTML, JavaScript,
PHP and SVG are never inline — the last one catches people out, because
it passes every image/ check and is a document that can carry script.
Filenames
use ArrayPress\FileUtils\Filename; Filename::rename( 'Invoice 2026', 'original.pdf' ); // Invoice-2026.pdf Filename::rename( 'shell.php', 'original.pdf' ); // shell.pdf Filename::with_extension( 'manual.pdf', 'txt' ); // manual.txt Filename::extension( 'holiday.jpg?v=2' ); // jpg
Deliberately small: sanitize_file_name() and wp_unique_filename() are
core's and are better than anything here would be. rename() is the one core
has no answer for, and it keeps the extension the file already had — a store
letting somebody rename a download must not let them turn invoice.pdf into
invoice.php, and must not make them retype .pdf either.
What was removed
Everything core already does:
| Was | Use instead |
|---|---|
File::normalize_path() |
wp_normalize_path() |
File::join_path() |
path_join() |
File::upload_path() / _url() |
wp_upload_dir() |
Security::is_safe_filename() |
sanitize_file_name() |
Security::is_allowed_file_type() |
wp_check_filetype() |
Security::sanitize_path() |
Path::within() |
MIME::get_all_types() |
wp_get_mime_types() |
Testing
composer test # phpunit composer lint # phpcs, defect sniffs composer format:check # phpcs, formatting