*/ 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 ); } }