safe_destination_path( $relative_path ); if ( ! is_file( $abs_path ) || filesize( $abs_path ) !== $expected_size ) { return false; } // A changed file of equal size must not count. An unreadable one is re-sent, since hash_equals() throws on false. $actual = hash_file( 'sha256', $abs_path ); return is_string( $actual ) && hash_equals( $expected_hash, $actual ); } /** * Verify the per-chunk SHA-256 and write one base64-encoded chunk to disk. * * A mismatch throws before anything is written. Callers holding raw bytes use * write_raw_chunk() to skip the base64 round trip. * * @param string $expected_chunk_hash SHA-256 hex of the raw (pre-base64) bytes. * @param bool $compressed True when $b64_data holds gzip+base64. * @param int|null $max_expanded_bytes Per-request ceiling on the raw bytes this chunk expands to. Bounds memory and refuses a gzip bomb. Not a budget for the file: a larger file arrives as several requests. * * @return int Logical length the chunk occupies in the destination. A byte-identical replay returns the same length, so the pull cursor advances either way. * * @throws RuntimeException On hash mismatch, cap refusal, path escape, or write failure. */ public function write_chunk( string $relative_path, string $b64_data, int $offset, string $expected_chunk_hash, bool $compressed = false, ?int $max_expanded_bytes = null ): int { // Refuse before decoding anything, so a refused file costs no work and leaves no scratch behind. $abs_path = $this->safe_destination_path( $relative_path ); $encoded = base64_decode( $b64_data, strict: true ); if ( false === $encoded ) { throw new RuntimeException( "Base64 decode failed for file chunk: {$relative_path}" ); } // chunk_hash covers the original bytes, so decompress before hashing. if ( $compressed ) { if ( '' === $encoded ) { // A compressed chunk with no payload is zero bytes and has no stream to inflate. The digest check still binds it to the hash of nothing. $raw = ''; } elseif ( null !== $max_expanded_bytes ) { // gzdecode() inflates everything before a cap could refuse it, which is what a bomb exploits. Inflate incrementally instead. $raw = $this->inflate_bounded( $encoded, max( 0, $max_expanded_bytes ), $relative_path ); } else { $raw = gzdecode( $encoded ); if ( false === $raw ) { throw new RuntimeException( "Gzip decompression failed for file chunk: {$relative_path}" ); } } } else { $raw = $encoded; } // Verify before writing a byte. chunk_hash covers the decompressed data. $actual_hash = hash( 'sha256', $raw ); if ( ! hash_equals( $expected_chunk_hash, $actual_hash ) ) { throw new RuntimeException( "Chunk integrity check failed for `{$relative_path}`: expected {$expected_chunk_hash}, got {$actual_hash}." ); } // Measured on raw bytes, since a base64 wire estimate undercounts compressed payloads. // The offset stays out of the comparison: a cumulative length against a per-request // ceiling would refuse the later chunks of any larger file. if ( null !== $max_expanded_bytes && strlen( $raw ) > $max_expanded_bytes ) { throw new RuntimeException( "Destination cap exceeded for `{$relative_path}`: one request expanding to " . strlen( $raw ) . ' raw bytes passes the per-request ceiling of ' . $max_expanded_bytes . '.' ); } $dir = dirname( $abs_path ); if ( ! is_dir( $dir ) ) { if ( ! wp_mkdir_p( $dir ) ) { throw new RuntimeException( "Failed to create directory: {$dir}" ); } } // Write to a temp file so a partial transfer is never served before the full-file hash is verified. $tmp_path = TransferTempFile::path_for( $abs_path ); if ( $offset > 0 && ! is_file( $tmp_path ) ) { // A retry after a verified promotion finds no temp file. The final file already holds these bytes, so acknowledge instead of restarting at offset 0. if ( is_file( $abs_path ) && self::slice_matches( $abs_path, $offset, $raw, $expected_chunk_hash ) ) { return strlen( $raw ); } throw new RuntimeException( "Transfer state missing for {$relative_path}: expected temp file at non-zero offset {$offset}. " . 'Restart file transfer from offset 0.' ); } if ( $offset > 0 ) { $tmp_size = filesize( $tmp_path ); if ( false === $tmp_size || (int) $tmp_size < $offset ) { throw new RuntimeException( "Transfer offset mismatch for {$relative_path}: expected temp file size {$offset}, got " . ( false === $tmp_size ? 'unknown' : (string) $tmp_size ) . '. Restart file transfer from offset 0.' ); } // A replayed chunk is acknowledged by its logical length, since the cursor advances on accepted bytes. Only the exact bytes count; anything else at this offset is a gap. if ( (int) $tmp_size > $offset && self::slice_matches( $tmp_path, $offset, $raw, $expected_chunk_hash ) ) { return strlen( $raw ); } if ( (int) $tmp_size > $offset ) { throw new RuntimeException( "Transfer offset mismatch for {$relative_path}: expected temp file size {$offset}, got {$tmp_size}. " . 'Restart file transfer from offset 0.' ); } } // Chunks append at an offset, and WP_Filesystem has no append or stream API, so a PHP handle writes the scratch file beside its target inside uploads. $mode = $offset === 0 ? 'wb' : 'r+b'; $handle = fopen( $tmp_path, $mode ); if ( false === $handle ) { throw new RuntimeException( "Cannot open temp file for writing: {$tmp_path}" ); } try { if ( $offset > 0 ) { if ( fseek( $handle, $offset ) !== 0 ) { throw new RuntimeException( "Cannot seek to offset {$offset} in: {$tmp_path}" ); } } $written_total = 0; $raw_length = strlen( $raw ); // fwrite() may write short. Loop until every byte lands, or fail. while ( $written_total < $raw_length ) { $written = fwrite( $handle, substr( $raw, $written_total ) ); if ( false === $written || 0 === $written ) { throw new RuntimeException( "Write failed at offset " . ( $offset + $written_total ) . " in: {$tmp_path}" ); } $written_total += $written; } } finally { fclose( $handle ); } return $written_total; } /** * Inflate a gzip payload in bounded pieces, refusing once more than $max_bytes * have expanded. gzdecode() allocates the whole payload before the budget can be checked. */ private function inflate_bounded( string $gzip, int $max_bytes, string $relative_path ): string { $ctx = inflate_init( ZLIB_ENCODING_GZIP ); if ( false === $ctx ) { throw new RuntimeException( "Gzip decompression failed for file chunk: {$relative_path}" ); } $out = ''; $len = strlen( $gzip ); // One inflate_add() returns all the output its input yields. Feed pieces small enough that the worst deflate expansion (1032:1, RFC 1951) stays within the cap. $step = max( 128, min( 64 * 1024, (int) floor( $max_bytes / 1032 ) + 1 ) ); for ( $pos = 0; $pos < $len; $pos += $step ) { $piece = inflate_add( $ctx, substr( $gzip, $pos, $step ), ZLIB_NO_FLUSH ); if ( false === $piece ) { throw new RuntimeException( "Gzip decompression failed for file chunk: {$relative_path}" ); } $out .= $piece; if ( strlen( $out ) > $max_bytes ) { throw new RuntimeException( "Destination cap exceeded for `{$relative_path}`: inflated stream passed the remaining budget of {$max_bytes} bytes." ); } } // Finishing the stream flushes whatever the window held. $tail = inflate_add( $ctx, '', ZLIB_FINISH ); if ( false !== $tail ) { $out .= $tail; if ( strlen( $out ) > $max_bytes ) { throw new RuntimeException( "Destination cap exceeded for `{$relative_path}`: inflated stream passed the remaining budget of {$max_bytes} bytes." ); } } return $out; } /** * True when the bytes of $path at $offset are exactly this chunk (same length and * digest). Tells a byte-identical replay from a gap or collision at the same offset. */ private static function slice_matches( string $path, int $offset, string $raw, string $expected_hash ): bool { $existing = file_get_contents( $path, false, null, $offset, strlen( $raw ) ); return false !== $existing && strlen( $existing ) === strlen( $raw ) && hash_equals( $expected_hash, hash( 'sha256', $existing ) ); } /** * Verify the full-file SHA-256 once all chunks are written, then promote the temp file. * * A mismatch or missing temp file deletes the temp and throws, so the retry starts clean. * * @param string $expected_hash Full-file SHA-256 hex from the manifest entry. * * @throws RuntimeException If the temp file is missing or unhashable, the hash does not match, or promotion fails. */ public function verify_file_hash( string $relative_path, string $expected_hash ): void { $abs_path = $this->safe_destination_path( $relative_path ); $tmp_path = TransferTempFile::path_for( $abs_path ); // A replayed verify can arrive after the previous run already promoted the temp // file, and a final file holding the expected digest is the same outcome. // This is also the normal path for an unchanged file: a temp sits beside a // destination that already matches. Leaving it would leak one file per unchanged // file per run, served as a static asset and carried into the next manifest. // A held lease never reaches here, so no other transfer is writing this path. if ( is_file( $abs_path ) && hash_equals( $expected_hash, (string) hash_file( 'sha256', $abs_path ) ) ) { if ( is_file( $tmp_path ) ) { wp_delete_file( $tmp_path ); } return; } if ( ! is_file( $tmp_path ) ) { throw new RuntimeException( "Cannot verify `{$relative_path}`: temp file missing." ); } $tmp_hash = hash_file( 'sha256', $tmp_path ); if ( false === $tmp_hash ) { wp_delete_file( $tmp_path ); throw new RuntimeException( "Cannot hash temp file for `{$relative_path}`" ); } if ( ! hash_equals( $expected_hash, $tmp_hash ) ) { wp_delete_file( $tmp_path ); throw new RuntimeException( "Full-file integrity check failed for `{$relative_path}`: expected {$expected_hash}, got {$tmp_hash}." ); } if ( ! $this->promote_temp_file( $tmp_path, $abs_path, $relative_path ) ) { throw new RuntimeException( "Could not promote verified file: {$relative_path}" ); } } /** * Promote a verified temp file to its final path. * * rename() is atomic on one filesystem. When it fails (e.g. cross-device), copy, * verify the copy landed, then remove the source. * * @return bool True when the final path now holds the verified content. */ private function promote_temp_file( string $tmp_path, string $abs_path, string $relative_path ): bool { if ( rename( $tmp_path, $abs_path ) ) { $this->after_promotion( $abs_path ); return true; } // Cross-filesystem fallback. $ok = copy( $tmp_path, $abs_path ); if ( ! $ok ) { return false; } $copied_hash = hash_file( 'sha256', $abs_path ); $source_hash = hash_file( 'sha256', $tmp_path ); if ( false === $copied_hash || false === $source_hash || ! hash_equals( $source_hash, $copied_hash ) ) { wp_delete_file( $abs_path ); return false; } wp_delete_file( $tmp_path ); $promoted = is_file( $abs_path ) && ! is_file( $tmp_path ); if ( $promoted ) { $this->after_promotion( $abs_path ); } return $promoted; } /** * Settle a promoted file into its final state. * * A copy made under a restrictive umask can lack the world-read bit, unreadable * on hosts that run PHP as another user, so it gets the WordPress default * permissions. Best effort: it must not fail a promotion whose bytes already verified. */ private function after_promotion( string $abs_path ): void { $perms = @fileperms( $abs_path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Reads the mode of the promoted file; a failed read returns false and the fix is skipped below. if ( false !== $perms && ! ( $perms & 0004 ) ) { @chmod( $abs_path, defined( 'FS_CHMOD_FILE' ) ? (int) FS_CHMOD_FILE : 0644 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- Best-effort chmod so the promoted file stays readable; a failed chmod is not fatal. } } /** * Resolve a relative path to an absolute destination path inside the uploads folder. * * Every write, promotion and read goes through here. The uploads folder is asked * of WordPress each time rather than trusted from the caller, so a base that was * wrong when the request was built is still refused. * * @throws RuntimeException If the path escapes the base, the base is not inside uploads, or the name is one that is never written. */ private function safe_destination_path( string $relative_path ): string { if ( '' === $relative_path || str_contains( $relative_path, "\0" ) ) { throw new RuntimeException( 'Invalid file path.' ); } $refusal = UploadsWriteGuard::refused_name( $relative_path ); if ( null !== $refusal ) { throw new FileRefusedException( "Refused: {$refusal}" ); } $uploads = UploadsWriteGuard::root(); if ( '' === $uploads ) { throw new RuntimeException( 'Refused: the uploads folder cannot be resolved, so nothing is written.' ); } // Resolve the base through realpath so comparisons hold when it is reached via a symlink. $base = rtrim( realpath( $this->uploads_base ) ?: $this->uploads_base, DIRECTORY_SEPARATOR ); if ( $base !== $uploads && ! str_starts_with( $base, $uploads . DIRECTORY_SEPARATOR ) ) { throw new RuntimeException( 'Refused: files are only written inside the uploads folder.' ); } // A dot segment is refused before the join, so containment never depends on a folder existing for realpath() to resolve. // Windows drops trailing dots and spaces from a path part, so "..." and ".. " count as dot segments too. foreach ( VirtualPath::segments( $relative_path ) as $segment ) { if ( '' === rtrim( $segment, '. ' ) ) { throw new FileRefusedException( "Path traversal attempt blocked: {$relative_path}" ); } } $joined = $base . DIRECTORY_SEPARATOR . VirtualPath::to_local( $relative_path ); $normalised = $this->resolve_existing_prefix( $joined ); // The resolved path, not the joined one, has to stay inside uploads: a symlink in the tree can lead out of it. if ( ! str_starts_with( $normalised, $base . DIRECTORY_SEPARATOR ) || ! str_starts_with( $normalised, $uploads . DIRECTORY_SEPARATOR ) ) { throw new FileRefusedException( "Path traversal attempt blocked: {$relative_path}" ); } return $normalised; } /** * Resolve a path that does not exist yet, as far as it exists. * * realpath() fails for an unwritten file and a lexical fallback can't see * symlinks: if `my-theme/assets` points elsewhere, `my-theme/assets/x.css` reads * as inside the base and the write follows the link out. So the longest existing * ancestor is resolved for real and the remainder appended. * * @param string $path Absolute path, which need not exist. */ private function resolve_existing_prefix( string $path ): string { $real = realpath( $path ); if ( false !== $real ) { return $real; } $tail = []; $current = $path; while ( true ) { $parent = dirname( $current ); // Nothing exists up to the root, so the base itself is missing. The lexical result is the best answer. if ( $parent === $current ) { return $this->normalise_path( $path ); } $tail[] = basename( $current ); $current = $parent; $real = realpath( $current ); if ( false !== $real ) { $resolved = rtrim( $real, DIRECTORY_SEPARATOR ); foreach ( array_reverse( $tail ) as $segment ) { $resolved .= DIRECTORY_SEPARATOR . $segment; } return $resolved; } } } /** * Resolve `.` and `..` without requiring the path to exist. */ private function normalise_path( string $path ): string { $parts = explode( DIRECTORY_SEPARATOR, $path ); $result = []; foreach ( $parts as $part ) { if ( '' === $part || '.' === $part ) { continue; } if ( '..' === $part ) { array_pop( $result ); } else { $result[] = $part; } } return DIRECTORY_SEPARATOR . implode( DIRECTORY_SEPARATOR, $result ); } // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped, WordPress.WP.AlternativeFunctions.file_system_operations_chmod, WordPress.WP.AlternativeFunctions.file_system_operations_fclose, WordPress.WP.AlternativeFunctions.file_system_operations_fopen, WordPress.WP.AlternativeFunctions.file_system_operations_fwrite, WordPress.WP.AlternativeFunctions.rename_rename }