The IDs, zeros removed. */ public static function ids( string $key ): array { $value = self::raw( $key ); if ( ! is_array( $value ) ) { return array(); } /* * Non-scalar members are dropped before casting. `absint()` reads any * non-empty array as 1, so `entries[][]=x` used to put record 1 into a * bulk delete or export that never selected it. */ return array_values( array_filter( array_map( 'absint', array_filter( $value, 'is_scalar' ) ) ) ); } /** * Determines whether an argument is present. * * @param string $key Argument name. * @param string $method Optional. One of 'any', 'post' or 'get'. Default 'any'. * * @return bool True when the argument was sent. */ public static function has( string $key, string $method = 'any' ): bool { return null !== self::raw( $key, $method ); } /** * Returns an unslashed value without sanitising it. * * Reserved for values whose shape only the receiver knows, such as the field * payload of a submission, which the field validator cleans per type. Do not * use it for anything that goes straight into output or a query. * * @param string $key Argument name. * @param string $method Optional. One of 'any', 'post' or 'get'. Default 'any'. * * @return mixed The unslashed value, or null when absent. */ public static function raw( string $key, string $method = 'any' ): mixed { // phpcs:disable WordPress.Security.NonceVerification.Recommended, WordPress.Security.NonceVerification.Missing -- Callers verify a nonce before acting; this class only reads. // phpcs:disable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitised by the typed readers above, or by the receiver for raw payloads. /* * `any` reads POST then GET rather than `$_REQUEST`. * * What `$_REQUEST` contains is a php.ini setting: with `request_order` * unset and `variables_order` including `C` — a real configuration, and the * PHP default on some builds — cookies are merged into it. Reading it * therefore made `Request::int( 'form' )` and `Request::key( 'action' )` * answerable by a cookie, which is not something a caller asking for "the * request" is expecting, and is attacker-controlled in exactly the cases * that matter. */ $source = match ( $method ) { 'post' => $_POST, 'get' => $_GET, default => isset( $_POST[ $key ] ) ? $_POST : $_GET, }; // phpcs:enable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized // phpcs:enable WordPress.Security.NonceVerification.Recommended, WordPress.Security.NonceVerification.Missing if ( ! isset( $source[ $key ] ) ) { return null; } return wp_unslash( $source[ $key ] ); } /** * Returns a sanitised server variable. * * @param string $key Variable name, for example REMOTE_ADDR. * * @return string The value, or an empty string. */ public static function server( string $key ): string { if ( ! isset( $_SERVER[ $key ] ) ) { return ''; } return sanitize_text_field( wp_unslash( $_SERVER[ $key ] ) ); } /** * Returns the address the request came from. */ public static function ip(): string { return self::server( 'REMOTE_ADDR' ); } /** * Rebuilds a file entry that PHP nested by property. * * `$_FILES` groups an array input by property rather than by field, so the * entry has to be reassembled before WordPress can handle it. * * @param string $group Name of the array wrapping the inputs. * @param string $key Field key inside that array. * * @return array{name: string, type: string, tmp_name: string, error: int, size: int}|null The file entry, or null when nothing was sent. */ public static function file( string $group, string $key ): ?array { // phpcs:disable WordPress.Security.NonceVerification.Missing -- Callers verify a nonce before acting. if ( ! isset( $_FILES[ $group ]['name'][ $key ] ) ) { return null; } /* * Each property has to be a scalar. Posting `formpilot_fields[file][]` — a * multi-file input, or a hand-built request — makes PHP nest one level * deeper, and the casts below would then hit arrays on a public, * unauthenticated endpoint. This field takes one file; anything else is * not a file entry. */ foreach ( array( 'name', 'tmp_name', 'error', 'size' ) as $property ) { if ( isset( $_FILES[ $group ][ $property ][ $key ] ) && ! is_scalar( $_FILES[ $group ][ $property ][ $key ] ) ) { return null; } } $entry = self::file_entry( sanitize_file_name( wp_unslash( (string) $_FILES[ $group ]['name'][ $key ] ) ), isset( $_FILES[ $group ]['tmp_name'][ $key ] ) ? self::tmp_name( sanitize_text_field( $_FILES[ $group ]['tmp_name'][ $key ] ) ) : '', isset( $_FILES[ $group ]['error'][ $key ] ) ? absint( $_FILES[ $group ]['error'][ $key ] ) : UPLOAD_ERR_NO_FILE, isset( $_FILES[ $group ]['size'][ $key ] ) ? absint( $_FILES[ $group ]['size'][ $key ] ) : 0 ); // phpcs:enable WordPress.Security.NonceVerification.Missing if ( null === $entry || '' === $entry['name'] ) { return null; } return $entry; } /** * Returns a single file sent under its own input name. * * The counterpart of {@see self::file()} for an input that is not part of an * array. * * @param string $key Input name. * * @return array{name: string, type: string, tmp_name: string, error: int, size: int}|null The file entry, or null when nothing was sent. */ public static function upload( string $key ): ?array { // phpcs:disable WordPress.Security.NonceVerification.Missing -- Callers verify a nonce before acting. if ( ! isset( $_FILES[ $key ]['name'] ) ) { return null; } // An array input nests one level deeper; that is not a single file. foreach ( array( 'name', 'tmp_name', 'error', 'size' ) as $property ) { if ( isset( $_FILES[ $key ][ $property ] ) && ! is_scalar( $_FILES[ $key ][ $property ] ) ) { return null; } } $entry = self::file_entry( sanitize_file_name( wp_unslash( (string) $_FILES[ $key ]['name'] ) ), isset( $_FILES[ $key ]['tmp_name'] ) ? self::tmp_name( sanitize_text_field( $_FILES[ $key ]['tmp_name'] ) ) : '', isset( $_FILES[ $key ]['error'] ) ? absint( $_FILES[ $key ]['error'] ) : UPLOAD_ERR_NO_FILE, isset( $_FILES[ $key ]['size'] ) ? absint( $_FILES[ $key ]['size'] ) : 0 ); // phpcs:enable WordPress.Security.NonceVerification.Missing return $entry; } /** * Accepts a temporary upload path only when PHP itself created it. * * The path arrives through `sanitize_text_field()` and deliberately not * `wp_unslash()`: WordPress never adds slashes to `$_FILES`, so unslashing * would only strip the backslashes out of a Windows path such as * `C:\xampp\tmp\php1A2.tmp` and break every upload there. What makes the * value trustworthy is `is_uploaded_file()`, which confirms PHP received the * file through an HTTP upload in this request, so no other path on the * server can be passed off as one. * * @param string $path Sanitised temporary path. * * @return string The path, or an empty string when it is not a genuine upload. */ private static function tmp_name( string $path ): string { return ( '' !== $path && is_uploaded_file( $path ) ) ? $path : ''; } /** * Builds a file entry from values that are already sanitised. * * The type the browser sent is never read: it is a client claim. The type in * the entry is the one WordPress detects from the file itself, and is empty * when the file is not a genuine upload or its type is not allowed. Callers * that restrict types further still run their own wp_check_filetype_and_ext(). * * @param string $name Sanitised file name. * @param string $tmp_name Temporary path set by PHP. * @param int $error PHP upload error code. * @param int $size Size in bytes reported by PHP. * * @return array{name: string, type: string, tmp_name: string, error: int, size: int}|null The entry, or null when nothing was sent. */ private static function file_entry( string $name, string $tmp_name, int $error, int $size ): ?array { if ( UPLOAD_ERR_NO_FILE === $error ) { return null; } $type = ''; // `$tmp_name` is only non-empty once self::tmp_name() has confirmed a genuine upload. if ( UPLOAD_ERR_OK === $error && '' !== $name && '' !== $tmp_name ) { $check = wp_check_filetype_and_ext( $tmp_name, $name ); $type = is_string( $check['type'] ) ? $check['type'] : ''; } return array( 'name' => $name, 'type' => $type, 'tmp_name' => $tmp_name, 'error' => $error, 'size' => $size, ); } }