'application/x-formpilot-no-accepted-type' ); /** * Returns the absolute path of the upload directory. */ public static function base_dir(): string { $uploads = wp_get_upload_dir(); $base = (string) ( $uploads['basedir'] ?? '' ); return '' === $base ? '' : wp_normalize_path( trailingslashit( $base ) . self::SUBDIR ); } /** * Returns the public URL of the upload directory. */ public static function base_url(): string { $uploads = wp_get_upload_dir(); $base = (string) ( $uploads['baseurl'] ?? '' ); return '' === $base ? '' : trailingslashit( $base ) . self::SUBDIR; } /** * Reads a normalised file entry out of the request. * * PHP nests `$_FILES` by property rather than by field, so the entry has to * be rebuilt before it can be handed to WordPress. * * @param string $group Name of the array wrapping the inputs. * @param string $key Field key inside that array. * * @return array|null The file entry, or null when nothing was sent. */ public function extract( string $group, string $key ): ?array { return Request::file( $group, $key ); } /** * Checks an upload without moving it. * * Kept separate from storing so a form that fails validation elsewhere never * leaves an orphaned file behind. * * @param Field $field Field the file belongs to. * @param array $entry Normalised file entry. * * @return WP_Error|null The failure, or null when the file is acceptable. */ public function validate( Field $field, array $entry ): ?WP_Error { $error = $this->check_php_error( (int) $entry['error'] ); if ( $error instanceof WP_Error ) { return $error; } $name = (string) $entry['name']; $path = (string) $entry['tmp_name']; if ( ! is_uploaded_file( $path ) ) { return new WP_Error( 'formpilot_not_uploaded', __( 'The file could not be read.', 'devplugins-custom-form-builder' ) ); } $size = (int) $entry['size']; if ( 0 === $size ) { return new WP_Error( 'formpilot_empty_file', __( 'The uploaded file is empty.', 'devplugins-custom-form-builder' ) ); } $max_size = $this->max_size( $field ); if ( $max_size > 0 && $size > $max_size ) { return new WP_Error( 'formpilot_file_too_large', sprintf( /* translators: %s: Maximum allowed file size, already formatted. */ __( 'The file is too large. Maximum allowed size is %s.', 'devplugins-custom-form-builder' ), size_format( $max_size ) ) ); } if ( $this->has_blocked_extension( $name ) ) { return new WP_Error( 'formpilot_file_type', __( 'This file type is not allowed.', 'devplugins-custom-form-builder' ) ); } $check = wp_check_filetype_and_ext( $path, $name, $this->allowed_mimes( $field ) ); if ( empty( $check['ext'] ) || empty( $check['type'] ) ) { return new WP_Error( 'formpilot_file_type', __( 'This file type is not allowed.', 'devplugins-custom-form-builder' ) ); } /* * A file claiming an image type must actually decode as one. This closes * the gap where a mime check passes on headers alone, and it is the last * cheap check available before the bytes are written. */ if ( str_starts_with( (string) $check['type'], 'image/' ) && 'image/svg+xml' !== $check['type'] ) { // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- A corrupt image emits a warning; the false return is the answer. $dimensions = @getimagesize( $path ); if ( false === $dimensions || empty( $dimensions[0] ) || empty( $dimensions[1] ) ) { return new WP_Error( 'formpilot_file_corrupt', __( 'This file is not a valid image.', 'devplugins-custom-form-builder' ) ); } /** * Filters the largest image, in pixels, an upload may contain. * * The header is read without decoding, but every image is re-encoded to * strip its metadata, and decoding needs about four bytes per pixel. A * few-kilobyte PNG declaring 30000x30000 pixels asks for 3.6 GB, which * takes the PHP worker down on every attempt. * * @since 1.0.0 * * @param int $pixels Largest width times height accepted. Default 40 million. */ $max_pixels = (int) apply_filters( 'formpilot_max_image_pixels', 40000000 ); if ( $max_pixels > 0 && (int) $dimensions[0] * (int) $dimensions[1] > $max_pixels ) { return new WP_Error( 'formpilot_image_too_large', __( 'This image has too many pixels to be processed.', 'devplugins-custom-form-builder' ) ); } } /** * Filters the result of validating an upload. * * @since 1.0.0 * * @param WP_Error|null $error Failure, or null when the file is acceptable. * @param array $entry Normalised file entry. * @param Field $field Field the file belongs to. */ $error = apply_filters( 'formpilot_validate_upload', null, $entry, $field ); return $error instanceof WP_Error ? $error : null; } /** * Moves a validated upload into place. * * @param Field $field Field the file belongs to. * @param array $entry Normalised file entry. * * @return UploadedFile|WP_Error The stored file, or an error. */ public function store( Field $field, array $entry ): UploadedFile|WP_Error { /* * The guard writes the rules that stop this directory executing what is * put in it. It used to be called for its side effect with its answer * thrown away, so an upload proceeded happily into a directory that could * not be hardened at all. A site that genuinely wants that says so. */ if ( ! DirectoryGuard::ensure() && (bool) apply_filters( 'formpilot_require_upload_guard', true ) ) { return new WP_Error( 'formpilot_upload_unprotected', __( 'Uploads are unavailable right now. Please try again later.', 'devplugins-custom-form-builder' ) ); } if ( ! function_exists( 'wp_handle_upload' ) ) { require_once ABSPATH . 'wp-admin/includes/file.php'; } $original = (string) $entry['name']; /* * Ask the bytes what the file is before naming it. `validate()` has run the * same check, but its answer was not carried here, so the extension written * to disk came from the client-supplied name instead. */ $check = wp_check_filetype_and_ext( (string) $entry['tmp_name'], $original, $this->allowed_mimes( $field ) ); $verified = (string) ( $check['ext'] ?? '' ); if ( '' === $verified || empty( $check['type'] ) ) { return new WP_Error( 'formpilot_file_type', __( 'This file type is not allowed.', 'devplugins-custom-form-builder' ) ); } /* * Stored under an unguessable name. `wp_unique_filename()` keeps the * name the visitor chose, which on a public uploads directory means a * file called invoice.pdf sits at a URL anyone can guess. The name the * visitor gave is kept in the entry payload for display instead. */ $entry['name'] = $this->storage_name( $original, $verified ); add_filter( 'upload_dir', array( $this, 'upload_directory' ) ); $uploaded = wp_handle_upload( $entry, array( 'test_form' => false, 'mimes' => $this->allowed_mimes( $field ), ) ); remove_filter( 'upload_dir', array( $this, 'upload_directory' ) ); if ( ! is_array( $uploaded ) || isset( $uploaded['error'] ) ) { $message = is_array( $uploaded ) && isset( $uploaded['error'] ) ? (string) $uploaded['error'] : __( 'The file could not be uploaded.', 'devplugins-custom-form-builder' ); return new WP_Error( 'formpilot_upload_failed', $message ); } $stored_path = wp_normalize_path( (string) ( $uploaded['file'] ?? '' ) ); $stored_type = (string) ( $uploaded['type'] ?? '' ); /* * Files land in a dated subdirectory. Apache and IIS both inherit the rules * written at the root, but a host with `AllowOverride` narrowed does not, * so the guards are repeated where the file actually is. The root call * above has already decided whether uploads may proceed at all; this is * belt and braces, so its answer is not a second gate. */ DirectoryGuard::protect( dirname( $stored_path ) ); /* * Scrubbed after the move rather than before: the temporary file is * replaced by wp_handle_upload(), so this is the first moment the bytes * live at their final path. * * The answer is acted on. Every one of the scrubber's failure paths — an * unreadable file, a serialisation failure, `$wp_filesystem` unavailable * because the site is configured for FTP, an image re-encode that threw — * used to leave the unscrubbed original in place and report success. A * file the scrubber could not vouch for is not stored. */ if ( ! ( new FileScrubber() )->scrub( $stored_path, $stored_type, $verified ) ) { if ( is_file( $stored_path ) ) { wp_delete_file( $stored_path ); } return new WP_Error( 'formpilot_file_rejected', __( 'This file could not be accepted. Please try a different one.', 'devplugins-custom-form-builder' ) ); } /* * The scrubber deletes an SVG it cannot parse and therefore cannot vouch * for, so the file may be gone by this point. */ if ( ! is_file( $stored_path ) ) { return new WP_Error( 'formpilot_file_rejected', __( 'This file could not be accepted. Please try a different one.', 'devplugins-custom-form-builder' ) ); } // Read back rather than trusting the upload: scrubbing rewrites the file. $size = (int) filesize( $stored_path ); $file = new UploadedFile( name: sanitize_file_name( $original ), file: $stored_path, url: (string) ( $uploaded['url'] ?? '' ), type: $stored_type, size: $size > 0 ? $size : (int) $entry['size'], attachment_id: 0 ); if ( Settings::flag( 'upload_to_media_library' ) ) { $file = $this->attach( $file ); } /** * Fires after an upload has been stored. * * @since 1.0.0 * * @param UploadedFile $file Stored file. * @param Field $field Field the file belongs to. */ do_action( 'formpilot_upload_stored', $file, $field ); return $file; } /** * Removes a stored upload. * * Only paths inside the plugin upload directory are touched, so a tampered * row can never point the deletion at an unrelated file. * * @param array|UploadedFile $payload Stored upload. * * @return bool True when something was removed. */ public function delete( array|UploadedFile $payload ): bool { $file = $payload instanceof UploadedFile ? $payload : UploadedFile::from_array( $payload ); $path = self::stored_file( $file->path() ); /* * The payload is a database row, and a row is only as trustworthy as whatever * wrote it: a visitor can type `{"file":"…","attachment_id":42}` into any text * field. The attachment branch used to run first and unconditionally, so an * administrator deleting that spam entry force-deleted media item 42 - any * item in the library - and a path aimed at a guard file removed the upload * directory's .htaccess. Nothing is deleted now unless it is a file this * plugin stored, and an attachment only when it is that same file. */ if ( '' === $path ) { return false; } if ( $file->is_attachment() ) { $attached = get_attached_file( $file->attachment_id(), true ); if ( ! is_string( $attached ) || self::stored_file( $attached ) !== $path ) { return false; } // Deleting the attachment removes the file and its generated sizes. return false !== wp_delete_attachment( $file->attachment_id(), true ); } wp_delete_file( $path ); return true; } /** * Resolves a path to an upload this plugin stored. * * Stricter than {@see self::owns()}, which answers for anything under the * upload directory: this also requires a regular file, and refuses the guard * files DirectoryGuard writes into every directory - whose paths are entirely * predictable - compared without regard to case, since `INDEX.PHP` opens * `index.php` on Windows and macOS. * * @param string $path Absolute path, typically out of a stored payload. * * @return string The resolved, normalised path, or an empty string. */ public static function stored_file( string $path ): string { if ( ! self::owns( $path ) ) { return ''; } $real = wp_normalize_path( (string) realpath( $path ) ); if ( ! is_file( $real ) || in_array( strtolower( basename( $real ) ), OrphanCleaner::PROTECTED_FILES, true ) ) { return ''; } return $real; } /** * Returns the public URL of a stored upload. * * Built from the resolved path rather than read from the stored payload, so a * forged `url` can never be what a screen or an email links to. * * @param string $path Absolute path, typically out of a stored payload. * * @return string The URL, or an empty string when the path is not a stored upload. */ public static function url_for( string $path ): string { $real = self::stored_file( $path ); $real_base = realpath( self::base_dir() ); if ( '' === $real || false === $real_base ) { return ''; } $relative = ltrim( substr( $real, strlen( wp_normalize_path( $real_base ) ) ), '/' ); return trailingslashit( self::base_url() ) . implode( '/', array_map( 'rawurlencode', explode( '/', $relative ) ) ); } /** * Determines whether a path belongs to the plugin upload directory. * * Both sides are resolved with `realpath()` first. `wp_normalize_path()` only * converts separators — it leaves `..` intact — so the prefix test on its own * accepted `…/formpilot-uploads/../../../wp-config.php`, and the payload it was * reading comes out of a database row. Resolving first means the string being * compared is the file the filesystem would actually open, symlinks included. * * A path that does not exist cannot be resolved and is therefore not owned. * Every caller is asking in order to read or delete something, so "it is not * there" and "it is not ours" want the same answer. * * @param string $path Absolute path. * * @return bool True when the file is managed by the plugin. */ public static function owns( string $path ): bool { $base = self::base_dir(); if ( '' === $base || '' === $path || str_contains( $path, "\0" ) ) { return false; } $real_base = realpath( $base ); $real_path = realpath( $path ); if ( false === $real_base || false === $real_path ) { return false; } return str_starts_with( wp_normalize_path( $real_path ), trailingslashit( wp_normalize_path( $real_base ) ) ); } /** * Points WordPress at the plugin upload subdirectory. * * @param array $dirs Upload directory data. * * @return array The filtered directory data. */ public function upload_directory( array $dirs ): array { $subdir = '/' . self::SUBDIR . ( isset( $dirs['subdir'] ) ? (string) $dirs['subdir'] : '' ); $dirs['subdir'] = $subdir; $dirs['path'] = (string) $dirs['basedir'] . $subdir; $dirs['url'] = (string) $dirs['baseurl'] . $subdir; return $dirs; } /** * Adds a stored file to the media library. * * @param UploadedFile $file Stored file. * * @return UploadedFile The file, carrying its attachment ID when one was created. */ private function attach( UploadedFile $file ): UploadedFile { if ( ! function_exists( 'wp_generate_attachment_metadata' ) ) { require_once ABSPATH . 'wp-admin/includes/image.php'; } $attachment_id = wp_insert_attachment( array( 'post_mime_type' => $file->type(), 'post_title' => sanitize_text_field( pathinfo( $file->name(), PATHINFO_FILENAME ) ), 'post_content' => '', /* * Private, not inherit. A parentless attachment with `inherit` counts as * published, so `/wp-json/wp/v2/media` listed every upload - its real * URL and the visitor's original file name - to anyone who asked, which * undid the random storage names. Administrators still see it in the * media library. */ 'post_status' => 'private', ), $file->path() ); if ( is_wp_error( $attachment_id ) || 0 === (int) $attachment_id ) { return $file; } $attachment_id = (int) $attachment_id; wp_update_attachment_metadata( $attachment_id, wp_generate_attachment_metadata( $attachment_id, $file->path() ) ); return new UploadedFile( name: $file->name(), file: $file->path(), url: (string) wp_get_attachment_url( $attachment_id ), type: $file->type(), size: $file->size(), attachment_id: $attachment_id ); } /** * Builds the name a file is stored under. * * Keeps the extension, because that is what the mime check agreed to and * what the server needs to serve the file correctly, and replaces the rest * with random characters so the URL cannot be guessed or enumerated. * * The extension comes from the mime check, not from the client: the check has * looked at the bytes and said what the file actually is, whereas the supplied * name is an attacker-chosen string. Where the check offers nothing usable the * upload is stored without an extension rather than with a guessed one. * * @param string $original Name the visitor uploaded. * @param string $verified Extension confirmed by `wp_check_filetype_and_ext()`. */ private function storage_name( string $original, string $verified ): string { $extension = self::normalize_extension( $verified ); /* * Belt and braces. `verified` already comes from the mime map, which * `without_blocked()` has filtered — but this is the last point before a * name reaches the filesystem, and the cost of checking again is nothing. */ if ( in_array( $extension, self::BLOCKED, true ) ) { $extension = ''; } $name = 'formpilot-' . wp_generate_password( 24, false, false ); /** * Filters the name an upload is stored under. * * Returning the original name restores guessable URLs, so only do that * where the upload directory is not publicly readable. * * @since 1.0.0 * * @param string $name Generated storage name, without extension. * @param string $original Name the visitor uploaded. */ $name = sanitize_file_name( (string) apply_filters( 'formpilot_upload_storage_name', $name, $original ) ); /* * The filter is a trusted extension point, but "trusted" is not the same as * "correct": a callback returning `invoice.php` would put a blocked * extension in front of the real one, which some server configurations * still hand to the interpreter. A name that fails the deny list is * discarded for a generated one rather than sanitised into something the * caller did not ask for. */ if ( '' === $name || $this->has_blocked_extension( $name ) ) { $name = 'formpilot-' . wp_generate_password( 24, false, false ); } return '' === $extension ? $name : $name . '.' . $extension; } /** * Returns the size limit of a field, in bytes. * * @param Field $field Field being processed. */ private function max_size( Field $field ): int { $kilobytes = (int) $field->setting( 'max_size', 0 ); if ( $kilobytes <= 0 ) { $kilobytes = Settings::number( 'upload_max_size' ); } $configured = $kilobytes * KB_IN_BYTES; $server = (int) wp_max_upload_size(); if ( $configured <= 0 ) { return $server; } return min( $configured, $server ); } /** * Returns the mime types a field accepts. * * The field list narrows the global setting, which in turn narrows what the * site allows. An upload can never widen what WordPress itself permits. * * @param Field $field Field being processed. * * @return array Mime types keyed by extension pattern. */ private function allowed_mimes( Field $field ): array { $allowed = get_allowed_mime_types(); /* * "Nothing configured" and "everything configured was rejected" are * different answers and must not share a branch. * * `extensions()` drops entries that are on the deny list, so a site whose * allowed-types setting contained only now-blocked extensions produced an * empty list — indistinguishable from an unconfigured one. The empty list * then fell through to "every mime type WordPress permits", so tightening * the deny list could *widen* what a site accepted. The raw list is what * says whether the administrator configured anything. */ $configured = $this->normalize_extensions( (array) $field->setting( 'allowed_types', array() ) ); $source = 'field'; if ( array() === $configured ) { $configured = $this->normalize_extensions( explode( ',', Settings::text( 'upload_allowed_types' ) ) ); $source = 'global'; } if ( array() === $configured ) { // Genuinely unconfigured: fall back to what the site itself permits. return $this->without_blocked( $allowed ); } $extensions = $this->without_blocked_extensions( $configured ); if ( array() === $extensions ) { $this->report_unusable_types( $source, $configured ); return self::NO_TYPES; } $mimes = array(); foreach ( $allowed as $pattern => $mime ) { $known = explode( '|', (string) $pattern ); if ( array() !== array_intersect( $known, $extensions ) ) { $mimes[ $pattern ] = $mime; } } $mimes = $this->without_blocked( $mimes ); /* * Never hand an empty map onward. `wp_check_filetype()` and * `wp_handle_upload()` both treat `empty( $mimes )` as "no restriction" * and substitute `get_allowed_mime_types()`, so an allowed list that * matched nothing — a typo, or a type this WordPress does not know — * silently became no list at all. */ if ( array() === $mimes ) { $this->report_unusable_types( $source, $configured ); return self::NO_TYPES; } return $mimes; } /** * Announces an allowed-types list that cannot accept anything. * * @param string $source Whether the list came from the field or the settings. * @param array $configured Extensions the administrator asked for. */ private function report_unusable_types( string $source, array $configured ): void { do_action( 'formpilot_log', 'The configured upload types do not match any type this site accepts, so no file can be uploaded.', 'error', 'uploads', array( 'source' => $source, 'types' => implode( ', ', $configured ), ) ); } /** * Normalises a list of extensions, keeping blocked ones. * * @param array $values Raw extensions. * * @return array Lowercase extensions without dots. */ private function normalize_extensions( array $values ): array { $extensions = array(); foreach ( $values as $value ) { if ( ! is_scalar( $value ) ) { continue; } $extension = self::normalize_extension( trim( (string) $value, " \t\n\r\0\x0B." ) ); if ( '' !== $extension ) { $extensions[] = $extension; } } return array_values( array_unique( $extensions ) ); } /** * Drops the blocked entries from a normalised extension list. * * @param array $extensions Normalised extensions. * * @return array The extensions that may be accepted. */ private function without_blocked_extensions( array $extensions ): array { return array_values( array_filter( $extensions, static fn( string $extension ): bool => ! in_array( $extension, self::BLOCKED, true ) ) ); } /** * Drops any blocked extension from a mime map. * * @param array $mimes Mime types keyed by extension pattern. * * @return array The filtered map. */ private function without_blocked( array $mimes ): array { $safe = array(); foreach ( $mimes as $pattern => $mime ) { $known = explode( '|', (string) $pattern ); if ( array() === array_intersect( $known, self::BLOCKED ) ) { $safe[ $pattern ] = $mime; } } return $safe; } /** * Determines whether a file name carries a blocked extension. * * Every dot separated part is checked, so `invoice.php.pdf` is rejected even * though its last extension looks harmless. * * @param string $name File name. * * @return bool True when the name must be refused. */ private function has_blocked_extension( string $name ): bool { $parts = explode( '.', strtolower( $name ) ); array_shift( $parts ); foreach ( $parts as $part ) { if ( in_array( self::normalize_extension( $part ), self::BLOCKED, true ) ) { return true; } } return false; } /** * Reduces an extension to the characters that survive to disk. * * The gate and the writer have to agree about what an extension *is*. They * used to differ: the gate trimmed whitespace while the writer deleted every * character outside `[a-z0-9]`, so `invoice.p-h-p` passed a deny list looking * for `php` and was then written to disk as `.php`. One function now answers * for both. * * @param string $extension Raw extension, with or without surrounding noise. * * @return string The extension as it would be written. */ private static function normalize_extension( string $extension ): string { return (string) preg_replace( '/[^a-z0-9]/', '', strtolower( $extension ) ); } /** * Translates a PHP upload error code into a message. * * @param int $code Value of the error key. * * @return WP_Error|null The error, or null when the upload succeeded. */ private function check_php_error( int $code ): ?WP_Error { if ( UPLOAD_ERR_OK === $code ) { return null; } $message = match ( $code ) { UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE => __( 'The file is larger than the server allows.', 'devplugins-custom-form-builder' ), UPLOAD_ERR_PARTIAL => __( 'The file was only partially uploaded.', 'devplugins-custom-form-builder' ), UPLOAD_ERR_NO_TMP_DIR, UPLOAD_ERR_CANT_WRITE => __( 'The server could not store the file.', 'devplugins-custom-form-builder' ), default => __( 'The file could not be uploaded.', 'devplugins-custom-form-builder' ), }; return new WP_Error( 'formpilot_upload_error', $message ); } }