"# Flexsolutions Form Builder: no direct access to uploaded files.\n\n\tRequire all denied\n\n\n\tDeny from all\n\n", 'web.config' => "\n\n", 'index.php' => " $content ) { if ( ! file_exists( $dir . '/' . $name ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Writing protection files in our own directory. file_put_contents( $dir . '/' . $name, $content ); } } } /** * A new, randomly named folder in the system's temporary folder for files * that are being processed (not inside the uploads folder). */ private static function new_temp_dir(): string { $dir = trailingslashit( get_temp_dir() ) . self::TEMP_PREFIX . bin2hex( random_bytes( 16 ) ); wp_mkdir_p( $dir ); return $dir; } /** * The encryption key for stored files, created on first use. */ private static function key(): string { $read = static function (): ?string { $hex = get_option( self::KEY_OPTION ); $key = is_string( $hex ) && 1 === preg_match( '/^[0-9a-f]{64}$/', $hex ) ? hex2bin( $hex ) : false; return is_string( $key ) ? $key : null; }; $key = $read(); if ( null !== $key ) { return $key; } $key = sodium_crypto_secretstream_xchacha20poly1305_keygen(); if ( ! add_option( self::KEY_OPTION, bin2hex( $key ), '', false ) ) { // Another request created it first: use that one. wp_cache_delete( 'notoptions', 'options' ); wp_cache_delete( self::KEY_OPTION, 'options' ); $key = $read() ?? $key; } return $key; } /** * Writes an encrypted copy of a file. * * @param string $source Plain file. * @param string $target Encrypted file to create. * @return bool Whether the whole file was encrypted. */ public static function encrypt_file( string $source, string $target ): bool { $size = filesize( $source ); // phpcs:disable WordPress.WP.AlternativeFunctions -- Streaming our own private files in chunks. $in = false !== $size ? fopen( $source, 'rb' ) : false; $out = false !== $in ? fopen( $target, 'wb' ) : false; $ok = false !== $out; if ( $ok ) { [ $state, $header ] = sodium_crypto_secretstream_xchacha20poly1305_init_push( self::key() ); $ok = false !== fwrite( $out, self::MAGIC . $header ); $left = (int) $size; do { $plain = $left > 0 ? fread( $in, min( self::CHUNK, $left ) ) : ''; if ( false === $plain || ( $left > 0 && '' === $plain ) ) { $ok = false; break; } $left -= strlen( $plain ); $tag = $left > 0 ? SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_TAG_MESSAGE : SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_TAG_FINAL; $cipher = sodium_crypto_secretstream_xchacha20poly1305_push( $state, $plain, '', $tag ); $ok = $ok && false !== fwrite( $out, pack( 'N', strlen( $cipher ) ) . $cipher ); } while ( $ok && $left > 0 ); } if ( false !== $in ) { fclose( $in ); } if ( false !== $out ) { fclose( $out ); } // phpcs:enable WordPress.WP.AlternativeFunctions if ( ! $ok ) { if ( is_file( $target ) ) { wp_delete_file( $target ); } return false; } chmod( $target, 0640 ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- Owner-only read on our private file. return true; } /** * Whether a stored file is encrypted (files that earlier builds stored are not * until the upgrade has encrypted them). * * @param string $path File. */ public static function is_encrypted( string $path ): bool { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Reading 8 bytes of our own file. return self::MAGIC === file_get_contents( $path, false, null, 0, strlen( self::MAGIC ) ); } /** * The plain content of a stored file, as a stream at its start (memory, * then the system's temporary folder for large files). Null if the file is * damaged or was encrypted with another key. * * @param string $path Stored file (from entry_file_path()). * @return resource|null */ public static function open_plain( string $path ) { // phpcs:disable WordPress.WP.AlternativeFunctions -- Streaming our own private files in chunks. $in = fopen( $path, 'rb' ); if ( false === $in ) { return null; } $out = fopen( 'php://temp/maxmemory:2097152', 'w+b' ); $ok = false !== $out; if ( $ok && self::MAGIC !== fread( $in, strlen( self::MAGIC ) ) ) { // Not encrypted yet (stored by an earlier build): the file is the content. rewind( $in ); $ok = false !== stream_copy_to_stream( $in, $out ); } elseif ( $ok ) { $header = (string) fread( $in, SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_HEADERBYTES ); $ok = SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_HEADERBYTES === strlen( $header ); $state = $ok ? sodium_crypto_secretstream_xchacha20poly1305_init_pull( $header, self::key() ) : ''; $final = false; while ( $ok && ! $final ) { $length = fread( $in, 4 ); $length = is_string( $length ) && 4 === strlen( $length ) ? (int) unpack( 'N', $length )[1] : 0; $cipher = $length > 0 && $length <= self::CHUNK + SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_ABYTES ? (string) stream_get_contents( $in, $length ) : ''; $result = strlen( $cipher ) === $length && $length > 0 ? sodium_crypto_secretstream_xchacha20poly1305_pull( $state, $cipher ) : false; if ( ! is_array( $result ) ) { $ok = false; break; } $final = SODIUM_CRYPTO_SECRETSTREAM_XCHACHA20POLY1305_TAG_FINAL === $result[1]; $ok = false !== fwrite( $out, $result[0] ); } } fclose( $in ); if ( ! $ok ) { if ( false !== $out ) { fclose( $out ); } return null; } rewind( $out ); // phpcs:enable WordPress.WP.AlternativeFunctions return $out; } /** * Encrypts files that earlier builds stored unencrypted (in place). Runs from * Installer::maybe_upgrade() until all are done, a few seconds at a time. */ public static function encrypt_existing(): void { if ( '1' === get_option( self::ENCRYPTED_OPTION ) ) { return; } $entries = self::base_path() . '/entries'; $started = microtime( true ); foreach ( is_dir( $entries ) ? (array) glob( $entries . '/*/*' ) : array() as $path ) { if ( ! is_string( $path ) || ! is_file( $path ) || is_link( $path ) || self::is_encrypted( $path ) ) { continue; } if ( microtime( true ) - $started > 5 ) { return; // Continue on the next request. } $part = $path . '.part'; // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename -- Replacing our own file with its encrypted copy. if ( ! self::encrypt_file( $path, $part ) || ! rename( $part, $path ) ) { return; // Try again later; the file stays readable as it is. } } update_option( self::ENCRYPTED_OPTION, '1' ); } /** * Per-file size limit for a field, in bytes, never above the server's limit. * * @param Field $field File field. */ public static function limit( Field $field ): int { $limit = self::DEFAULT_LIMIT; $raw = $field->option( 'limit' ); if ( null !== $raw && preg_match( '/^(\d{1,6})(kb|mb)?$/i', $raw, $m ) ) { $unit = strtolower( $m[2] ?? '' ); $limit = (int) $m[1] * ( 'mb' === $unit ? MB_IN_BYTES : ( 'kb' === $unit ? KB_IN_BYTES : 1 ) ); } return max( 1, min( $limit, (int) wp_max_upload_size() ) ); } /** * Allowed extensions for a field (blocked types removed). * * @param Field $field File field. * @return string[] */ public static function types( Field $field ): array { $raw = $field->option( 'filetypes' ); $types = null !== $raw ? explode( '|', strtolower( $raw ) ) : self::DEFAULT_TYPES; return array_values( array_diff( array_filter( array_map( 'trim', $types ) ), self::BLOCKED_TYPES ) ); } /** * Checks one uploaded file. Returns a message key on failure, or null. * * @param Field $field File field. * @param array $file One $_FILES entry. */ public static function check( Field $field, array $file ): ?string { $error = (int) ( $file['error'] ?? UPLOAD_ERR_NO_FILE ); if ( UPLOAD_ERR_INI_SIZE === $error || UPLOAD_ERR_FORM_SIZE === $error ) { return 'file_too_large'; } if ( UPLOAD_ERR_OK !== $error ) { return 'upload_failed'; } $tmp = (string) ( $file['tmp_name'] ?? '' ); $name = (string) ( $file['name'] ?? '' ); $size = (int) ( $file['size'] ?? 0 ); // Name checks first: allowed extension only, and double extensions like // "shell.php.jpg" are refused outright. $ext = strtolower( (string) pathinfo( $name, PATHINFO_EXTENSION ) ); if ( '' === $ext || ! in_array( $ext, self::types( $field ), true ) || self::has_script_part( $name ) ) { return 'file_type'; } // Only files PHP itself received in this request (blocks path tricks). if ( '' === $tmp || ! is_uploaded_file( $tmp ) ) { return 'upload_failed'; } if ( $size <= 0 || $size > self::limit( $field ) || filesize( $tmp ) > self::limit( $field ) ) { return 'file_too_large'; } // Real content check: WordPress inspects the file and confirms its type // matches the extension and is allowed on this site. $check = wp_check_filetype_and_ext( $tmp, $name ); if ( empty( $check['ext'] ) || empty( $check['type'] ) || strtolower( (string) $check['ext'] ) !== $ext ) { return 'file_type'; } return null; } /** * Whether a file name hides a script type before its last extension, as * sent ("shell.php.jpg") or as it will be stored. Cleaning the name turns * "shell.php .png", "shell.php#.png" and "shell.php%00.png" into * "shell.php-.png", "shell.php_.png" and "shell.php00.png", so each inner * part is compared without its punctuation and version digits. * * @param string $name Original file name. */ private static function has_script_part( string $name ): bool { foreach ( array( $name, self::clean_name( $name ) ) as $candidate ) { foreach ( array_slice( explode( '.', strtolower( $candidate ) ), 1, -1 ) as $part ) { $bare = (string) preg_replace( '/[^a-z0-9]/', '', $part ); if ( in_array( $part, self::BLOCKED_TYPES, true ) || in_array( $bare, self::BLOCKED_TYPES, true ) || 1 === preg_match( '/^(php|phtml?|phar|phps)\d*$/', $bare ) ) { return true; } } } return false; } /** * Moves checked uploads into a fresh, randomly named temporary directory. * * @param array> $files Field name => $_FILES entry (already checked). * @return array> Stored files: field, original_name, path, mime, size. */ public static function store_temporarily( array $files ): array { if ( array() === $files ) { return array(); } $dir = self::new_temp_dir(); require_once ABSPATH . 'wp-admin/includes/file.php'; // WordPress's upload handler moves the file (refusing anything not uploaded in // this request), but into our temporary folder, under the name we already checked. $into_dir = static fn( array $uploads ): array => array_merge( $uploads, array( 'path' => $dir, 'url' => '', 'subdir' => '', 'error' => false, ) ); add_filter( 'upload_dir', $into_dir ); $stored = array(); foreach ( $files as $field_name => $file ) { $original = self::clean_name( (string) $file['name'] ); $upload = array( 'name' => $original, 'type' => '', 'tmp_name' => (string) $file['tmp_name'], 'error' => (int) $file['error'], 'size' => (int) $file['size'], ); $moved = wp_handle_upload( $upload, array( 'test_form' => false, 'test_type' => false, // Checked by the validator, with this field's own types. 'unique_filename_callback' => static fn(): string => $original, ) ); $target = (string) ( $moved['file'] ?? '' ); if ( isset( $moved['error'] ) || dirname( $target ) !== $dir ) { continue; } chmod( $target, 0640 ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- Owner-only read on our private file. $check = wp_check_filetype_and_ext( $target, $original ); $stored[] = array( 'field' => (string) $field_name, 'original_name' => $original, 'path' => $target, 'mime' => (string) $check['type'], 'size' => (int) filesize( $target ), ); } remove_filter( 'upload_dir', $into_dir ); return $stored; } /** * Stores encrypted copies of the temporary files in the entry's folder and * deletes the temporary files. * * @param array> $stored From store_temporarily(). * @param int $entry_id Entry ID. * @return array> Rows for Entry_Store (stored_name is relative to base_dir()). */ public static function keep_for_entry( array $stored, int $entry_id ): array { $rows = array(); if ( array() === $stored ) { return $rows; } $relative_dir = 'entries/' . $entry_id; wp_mkdir_p( self::base_dir() . '/' . $relative_dir ); $temp_dirs = array(); foreach ( $stored as $file ) { $temp_dirs[ dirname( (string) $file['path'] ) ] = true; $relative = $relative_dir . '/' . bin2hex( random_bytes( 8 ) ) . '-' . $file['original_name']; if ( self::encrypt_file( (string) $file['path'], self::base_dir() . '/' . $relative ) ) { $rows[] = array( 'field' => $file['field'], 'original_name' => $file['original_name'], 'stored_name' => $relative, 'mime' => $file['mime'], 'size' => $file['size'], ); } } // Remove the temporary folders with the plain files. foreach ( array_keys( $temp_dirs ) as $dir ) { self::remove_dir( $dir ); } return $rows; } /** * Temporary plain copies of an entry's files under their original names, * for attaching to a resent notification. Remove them with discard(). * * @param array $files Rows of the entry_files table. * @return array> Same shape as store_temporarily(). */ public static function copies_for_mail( array $files ): array { $dir = ''; $copies = array(); foreach ( $files as $file ) { $source = self::entry_file_path( (string) $file->stored_name ); $name = self::clean_name( (string) $file->original_name ); if ( null === $source || '' === $name ) { continue; } $dir = '' !== $dir ? $dir : self::new_temp_dir(); $target = $dir . '/' . $name; $plain = file_exists( $target ) ? null : self::open_plain( $source ); if ( null === $plain ) { continue; } // phpcs:disable WordPress.WP.AlternativeFunctions -- Writing our own temporary file. $handle = fopen( $target, 'wb' ); $copied = false !== $handle && false !== stream_copy_to_stream( $plain, $handle ); if ( false !== $handle ) { fclose( $handle ); } fclose( $plain ); // phpcs:enable WordPress.WP.AlternativeFunctions if ( ! $copied ) { continue; } chmod( $target, 0640 ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- Owner-only read on our private file. $copies[] = array( 'field' => (string) $file->field_name, 'original_name' => $name, 'path' => $target, 'mime' => (string) $file->mime, 'size' => (int) $file->size, ); } return $copies; } /** * Deletes temporary files and their folder. * * @param array> $stored From store_temporarily(). */ public static function discard( array $stored ): void { $dirs = array(); foreach ( $stored as $file ) { if ( is_file( (string) $file['path'] ) ) { wp_delete_file( (string) $file['path'] ); } $dirs[ dirname( (string) $file['path'] ) ] = true; } foreach ( array_keys( $dirs ) as $dir ) { self::remove_dir( $dir ); } } /** * Absolute path of a stored entry file, or null if it escapes the storage folder. * * @param string $relative Stored name from the database. */ public static function entry_file_path( string $relative ): ?string { $base = realpath( self::base_dir() ); $path = realpath( self::base_dir() . '/' . ltrim( $relative, '/' ) ); if ( false === $base || false === $path || ! str_starts_with( $path, $base . DIRECTORY_SEPARATOR . 'entries' . DIRECTORY_SEPARATOR ) || ! is_file( $path ) ) { return null; } return $path; } /** * Deletes all stored files of an entry. * * @param int $entry_id Entry ID. */ public static function delete_entry_files( int $entry_id ): void { self::remove_dir( self::base_dir() . '/entries/' . $entry_id ); } /** * Removes temporary folders older than TEMP_TTL (left behind by failures), * including the tmp/ folder in the uploads folder that earlier builds used. */ public static function cleanup_temp(): void { $dirs = (array) glob( trailingslashit( get_temp_dir() ) . self::TEMP_PREFIX . '*', GLOB_ONLYDIR ); if ( is_dir( self::base_dir() . '/tmp' ) ) { $dirs = array_merge( $dirs, (array) glob( self::base_dir() . '/tmp/*', GLOB_ONLYDIR ) ); } foreach ( $dirs as $dir ) { if ( is_string( $dir ) && filemtime( $dir ) < time() - self::TEMP_TTL ) { self::remove_dir( $dir ); } } } /** * Removes the whole storage folder (uninstall). */ public static function remove_all(): void { self::remove_dir( self::base_dir() ); } /** * A safe file name: no path parts, no leading dots, limited length. * * @param string $name Original name. */ public static function clean_name( string $name ): string { $name = sanitize_file_name( wp_basename( $name ) ); $name = ltrim( $name, '.' ); if ( strlen( $name ) > 150 ) { $ext = pathinfo( $name, PATHINFO_EXTENSION ); $name = substr( $name, 0, 140 ) . ( '' !== $ext ? '.' . $ext : '' ); } return '' !== $name ? $name : 'file'; } /** * Recursively deletes a directory inside our storage folder only. * * @param string $dir Directory. */ private static function remove_dir( string $dir ): void { $base = realpath( self::base_path() ); $real = realpath( $dir ); $temp = realpath( get_temp_dir() ); // Our own temporary folders are allowed too. $is_temp = false !== $real && false !== $temp && dirname( $real ) === $temp && str_starts_with( basename( $real ), self::TEMP_PREFIX ); if ( false === $real || ( ! $is_temp && ( false === $base || ( $real !== $base && ! str_starts_with( $real, $base . DIRECTORY_SEPARATOR ) ) ) ) ) { return; } $items = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator( $real, \FilesystemIterator::SKIP_DOTS ), \RecursiveIteratorIterator::CHILD_FIRST ); foreach ( $items as $item ) { if ( $item->isDir() && ! $item->isLink() ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- Our own private folder. rmdir( $item->getPathname() ); } else { wp_delete_file( $item->getPathname() ); } } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir -- Our own private folder. rmdir( $real ); } }