"# 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 );
}
}