*/ private const MAGIC = [ 'webm' => "\x1A\x45\xDF\xA3", // EBML (WebM / Matroska). 'ogg' => 'OggS', 'riff' => 'RIFF', // WAV. 'id3' => 'ID3', // MP3 with tags. ]; /** * Settings instance. * * @var object */ private $settings; /** * Constructor. * * @param object $settings Settings instance. */ public function __construct( $settings ) { $this->settings = $settings; } /** * Validate one uploaded file. * * @param array|null $file One entry from $_FILES. * @return true|WP_Error True when the clip is safe to use. */ public function validate( $file ) { if ( ! is_array( $file ) || empty( $file['tmp_name'] ) ) { return new WP_Error( 'aisentic_voice_no_file', __( 'No audio was received.', 'aisentic' ), [ 'status' => 400 ] ); } if ( isset( $file['error'] ) && UPLOAD_ERR_OK !== (int) $file['error'] ) { return new WP_Error( 'aisentic_voice_upload_error', __( 'The audio did not upload correctly.', 'aisentic' ), [ 'status' => 400 ] ); } $path = (string) $file['tmp_name']; // Must be a genuine PHP upload, not an arbitrary server path handed to us. if ( ! $this->is_real_upload( $path ) ) { return new WP_Error( 'aisentic_voice_not_upload', __( 'That file was not uploaded.', 'aisentic' ), [ 'status' => 400 ] ); } if ( ! is_readable( $path ) ) { return new WP_Error( 'aisentic_voice_unreadable', __( 'The audio could not be read.', 'aisentic' ), [ 'status' => 400 ] ); } $size = (int) filesize( $path ); if ( $size <= 0 ) { return new WP_Error( 'aisentic_voice_empty', __( 'The audio file is empty.', 'aisentic' ), [ 'status' => 400 ] ); } $max_bytes = (int) $this->settings->get( 'voice.maxUploadBytes', 5242880 ); if ( $size > $max_bytes ) { return new WP_Error( 'aisentic_voice_too_large', sprintf( /* translators: %d: maximum size in megabytes. */ __( 'The recording is too long. Keep it under %d MB.', 'aisentic' ), (int) round( $max_bytes / 1048576 ) ), [ 'status' => 400 ] ); } // The first bytes of the file (magic bytes) are the main check. // Why: finfo says application/octet-stream for some valid short WebM // clips, so finfo alone would reject real recordings. if ( ! $this->has_audio_magic( $path ) ) { return new WP_Error( 'aisentic_voice_bad_header', __( 'That file is not audio.', 'aisentic' ), [ 'status' => 400 ] ); } // Reject audio that also has script code inside (a "polyglot" file). // Today the clip is deleted after the turn and is never public, so it // cannot run. This check keeps it safe if storage changes later. if ( $this->has_script_payload( $path ) ) { return new WP_Error( 'aisentic_voice_script_payload', __( 'That file is not audio.', 'aisentic' ), [ 'status' => 400 ] ); } // finfo is a second check. If finfo finds a type not in ALLOWED_MIMES, // reject the file. An empty result means finfo is missing, so we // rely on the magic-byte check only. $detected = $this->detect_mime( $path ); if ( '' !== $detected && ! in_array( $detected, self::ALLOWED_MIMES, true ) ) { return new WP_Error( 'aisentic_voice_bad_type', __( 'That file is not audio.', 'aisentic' ), [ 'status' => 400, 'detected' => $detected, ] ); } return true; } /** * Look for script markers in the first part of the file. * * @param string $path Absolute path. * @return bool True when the clip carries script source. */ private function has_script_payload( string $path ): bool { $handle = fopen( $path, 'rb' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Reading an upload before WP touches it. if ( false === $handle ) { return true; // Cannot read it, so do not trust it. } $head = (string) fread( $handle, 4096 ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread -- See above. fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- See above. foreach ( [ ' $bytes ) { if ( 0 !== strncmp( $head, $bytes, strlen( $bytes ) ) ) { continue; } // RIFF is also AVI, WebP and others. Only RIFF....WAVE is audio. if ( 'riff' === $kind ) { return 'WAVE' === substr( $head, 8, 4 ); } return true; } // MP4/M4A: bytes 4-8 are 'ftyp'. if ( 'ftyp' === substr( $head, 4, 4 ) ) { return true; } // Bare MP3 frame sync. if ( strlen( $head ) > 1 && "\xFF" === $head[0] && ( ord( $head[1] ) & 0xE0 ) === 0xE0 ) { return true; } return false; } }