*/
class Epicformbuilder_Services_Uploads {
/**
* Subdirectories of the plugin uploads directory.
*
* @var string[]
*/
private const SUBDIRECTORIES = [ 'exports', 'logs' ];
/**
* Body of the .htaccess denying direct web access.
*
* Apache 2.4 needs "Require all denied" and only understands the 2.2
* "Deny from all" form when mod_access_compat is loaded, so both are
* written behind module guards.
*
* @var string
*/
private const HTACCESS_CONTENT = "\nRequire all denied\n\n\nOrder allow,deny\nDeny from all\n\n";
/**
* Body of the .htaccess re-granting access to a public subdirectory.
*
* Re-grants access (overriding the base directory's deny), denies
* anything that could execute or reconfigure the server, and marks
* responses to be downloaded rather than rendered inline. This is
* Apache-only: nginx never reads .htaccess, so refusing to store an
* executable upload in the first place (the upload handler's filetype
* allowlist) remains the defence that works everywhere, not this file.
*
* Deliberately does not set "Options -Indexes" to stop directory
* listing: on a host that grants FileInfo (needed for the rules below)
* but not the separate "Options" override, Apache returns a 500 for
* every request under this directory rather than silently ignoring the
* directive — breaking every attachment download, a worse outcome than
* the listing it would have prevented. write_directory_index() writing
* an index.html file achieves the same thing without needing any
* AllowOverride grant at all: both Apache and nginx serve a directory
* index file ahead of a listing by default.
*
* @var string
*/
private const PUBLIC_HTACCESS_CONTENT = "\nRequire all granted\n\n\nOrder allow,deny\nAllow from all\n\n\n\n\nRequire all denied\n\n\nOrder allow,deny\nDeny from all\n\n\n\n\nHeader set X-Content-Type-Options \"nosniff\"\nHeader set Content-Disposition \"attachment\"\n\n";
/**
* Body of the index.php suppressing directory listings.
*
* Used only for directories that deny all web access outright
* (exports/, logs/, the base directory): WordPress convention, and
* harmless there since nothing ever resolves it as a directory index —
* the request is denied before that lookup would matter.
*
* @var string
*/
private const INDEX_CONTENT = "\n";
/**
* Name of the pre-1.x upload directory, superseded by base_dir().
*
* @var string
*/
private const LEGACY_DIRECTORY = 'epicformbuilder';
/**
* Returns the plugin uploads directory path with a trailing slash.
*
* @since 1.0.0
*
* @return string Absolute path to wp-content/uploads/epicforms/.
*/
public static function base_dir(): string {
$upload_dir = wp_upload_dir();
return trailingslashit( $upload_dir['basedir'] ) . 'epicforms/';
}
/**
* Builds the nonce-protected admin-post.php link for an export or log file.
*
* The link is returned raw, not HTML-escaped as wp_nonce_url() would: it
* travels in JSON to the admin app, where a literal "&" would reach
* PHP as "amp;_wpnonce" and fail the nonce check. Escape at output instead.
*
* @since 1.0.0
*
* @param string $type Subdirectory: 'exports' or 'logs'.
* @param string $file File name within it.
*
* @return string
*/
public static function download_url( string $type, string $file ): string {
return add_query_arg(
[
'action' => 'epicforms_protected_download',
'type' => rawurlencode( $type ),
'file' => rawurlencode( $file ),
'_wpnonce' => wp_create_nonce( 'epicforms_protected_download' ),
],
admin_url( 'admin-post.php' )
);
}
/**
* Creates the plugin uploads directories and protects them from the web.
*
* Writes .htaccess to the base directory and index.php to the base
* directory and every subdirectory. Safe to call repeatedly: files are
* only written when missing or when their contents have drifted.
*
* @since 1.0.0
*
* @return void
*/
public static function ensure_protected(): void {
$base_dir = self::base_dir();
self::ensure_directory( $base_dir );
self::write_file( $base_dir . '.htaccess', self::HTACCESS_CONTENT );
self::write_file( $base_dir . 'index.php', self::INDEX_CONTENT );
foreach ( self::SUBDIRECTORIES as $subdirectory ) {
$directory = $base_dir . $subdirectory . '/';
self::ensure_directory( $directory );
self::write_file( $directory . 'index.php', self::INDEX_CONTENT );
}
foreach ( self::public_subdirectories() as $subdirectory ) {
$directory = $base_dir . $subdirectory . '/';
self::ensure_directory( $directory );
self::write_file( $directory . '.htaccess', self::PUBLIC_HTACCESS_CONTENT );
self::write_directory_index( $directory );
}
}
/**
* Returns the subdirectories that must stay fetchable over the web.
*
* Everything else inherits the "deny all" from the base .htaccess. Core
* stores nothing public, so the list is empty unless an add-on that
* serves visitor files (e.g. file uploads) registers its directory; each
* one then gets its own .htaccess that re-grants access while still
* blocking anything executable.
*
* @since 1.0.0
*
* @return string[] Subdirectory names, relative to base_dir().
*/
private static function public_subdirectories(): array {
/**
* Filters the plugin uploads subdirectories that are publicly fetchable.
*
* @since 1.0.0
*
* @param string[] $subdirectories Subdirectory names, e.g. [ 'uploads' ].
*/
$subdirectories = (array) apply_filters( 'epicforms_public_upload_subdirectories', [] );
return array_values(
array_filter(
array_map( 'sanitize_file_name', array_map( 'strval', $subdirectories ) ),
function ( $name ) {
return '' !== $name && ! in_array( $name, self::SUBDIRECTORIES, true );
}
)
);
}
/**
* Writes an index.html to a directory to suppress directory listings.
*
* `ensure_protected()` only seeds the top-level subdirectories; callers
* that create their own directories on demand further down the tree —
* e.g. the file-upload addon's per-form, per-submission directories
* under its public subdirectory — need the same protection
* applied to each one individually, since directory listing would
* otherwise expose every attachment's file name to anyone who can reach
* the directory.
*
* Writes index.html rather than index.php: this is only ever called for
* directories inside the public, granted subtree, where
* PUBLIC_HTACCESS_CONTENT's own rules deny `.php` — see
* INDEX_HTML_CONTENT. Also removes a stray legacy `index.php` if one is
* found, so an install upgrading from before this change can't end up
* with both files present and a host whose DirectoryIndex happens to
* prefer `.php` resolving to the denied one first.
*
* @since 1.0.0
*
* @param string $directory Absolute directory path.
* @return void
*/
public static function write_directory_index( string $directory ): void {
$directory = trailingslashit( $directory );
self::write_file( $directory . 'index.html', self::INDEX_HTML_CONTENT );
if ( file_exists( $directory . 'index.php' ) ) {
// phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- removing a stray legacy protection file this service itself wrote; WP_Filesystem is not initialised on the front end.
unlink( $directory . 'index.php' );
}
}
/**
* Denies web access to the pre-1.x upload directory, without moving files.
*
* Earlier versions of the file-upload addon wrote submission
* attachments to wp-content/uploads/epicformbuilder/ rather than the
* directory this service protects, so an install that has been running
* since then can have real attachments — including anything written
* through the extension-spoofing vulnerability the endpoint used to
* have — sitting in a directory with no .htaccess at all.
*
* Deliberately does not move or touch the files themselves: their URLs
* are already stored in submissions, and the download handler rebuilds
* the download URL from that stored value, so relocating them would
* break every existing download link. Denying access is the safe
* default for a security fix; whether and how to migrate the files is
* a separate decision left to the site owner.
*
* A no-op when the legacy directory does not exist, so it's cheap to
* call unconditionally from the upgrade path.
*
* @since 1.0.0
*
* @return void
*/
public static function protect_legacy_directory(): void {
$upload_dir = wp_upload_dir();
$legacy_dir = trailingslashit( $upload_dir['basedir'] ) . self::LEGACY_DIRECTORY . '/';
if ( ! is_dir( $legacy_dir ) ) {
return;
}
self::write_file( $legacy_dir . '.htaccess', self::HTACCESS_CONTENT );
}
/**
* Creates a directory when it does not exist yet.
*
* @since 1.0.0
*
* @param string $directory Absolute directory path.
* @return void
*/
private static function ensure_directory( string $directory ): void {
if ( ! file_exists( $directory ) ) {
wp_mkdir_p( $directory );
}
}
/**
* Writes a protection file unless it already holds the expected contents.
*
* @since 1.0.0
*
* @param string $path Absolute file path.
* @param string $contents Expected file contents.
* @return void
*/
private static function write_file( string $path, string $contents ): void {
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- reading a local protection file the plugin owns, WP_Filesystem is not initialised on the front end.
if ( is_readable( $path ) && file_get_contents( $path ) === $contents ) {
return;
}
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- writing a local protection file the plugin owns, WP_Filesystem is not initialised on the front end.
file_put_contents( $path, $contents );
}
}