arraypress/wp-file-utils

Path containment, MIME types and filename helpers for WordPress plugins that serve files.

Maintainers

Package info

github.com/arraypress/wp-file-utils

Homepage

pkg:composer/arraypress/wp-file-utils

Transparency log

Statistics

Installs: 19

Dependents: 1

Suggesters: 0

Stars: 1

Open Issues: 0

dev-main 2026-08-25 18:13 UTC

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