fix(migrate): confine archive paths to the backups dir (S2083 path-traversal taints)

push_packets()/finalize() now refuse any archive outside this site's backups
dir (Packager::archive_path()/create_archive() already enforce this; the guard
is defense-in-depth at the sink). The packager's directory walk keeps its
NOSONAR with the confinement rationale on the flagged line. Clears the three
phpsecurity:S2083 blockers that regressed the Sonar security rating to 5.

Co-Authored-By: Claude <[email protected]>
This commit is contained in:
John Smith
2026-09-03 12:37:59 -04:00
co-authored by Claude
parent 431556842a
commit d8b6c458c6
2 changed files with 18 additions and 4 deletions
+17 -3
View File
@@ -140,12 +140,23 @@ class EMCP_Tools_Migration_Engine {
* @return true|\WP_Error
*/
public static function push_packets( string $path, string $endpoint, string $secret, string $transfer_id, array $opts = array() ) {
// Only archives this site's own backups dir may be streamed. Packager
// already enforces this via archive_path()/create_archive(); the guard is
// defense-in-depth so push_packets can never read an arbitrary path.
if ( ! class_exists( 'EMCP_Tools_Packager' ) ) {
return new WP_Error( 'engine_unavailable', __( 'The packager engine is not available.', 'emcp-tools' ) );
}
$backup_dir = rtrim( wp_normalize_path( EMCP_Tools_Packager::backup_dir() ), '/' ) . '/';
if ( 0 !== strpos( wp_normalize_path( $path ), $backup_dir ) || ! is_file( $path ) ) {
return new WP_Error( 'invalid_archive', __( 'Only archives inside the backups directory can be pushed.', 'emcp-tools' ) );
}
$chunk_size = self::CHUNK_SIZE;
$total = self::packet_count( filesize( $path ) );
$total = self::packet_count( filesize( $path ) ); // NOSONAR -- $path is guarded to the backups dir above.
$start = max( 0, (int) ( $opts['resume_offset'] ?? 0 ) );
$on_packet = isset( $opts['on_packet'] ) && is_callable( $opts['on_packet'] ) ? $opts['on_packet'] : null;
$handle = fopen( $path, 'rb' ); // phpcs:ignore WordPress.WP.AlternativeFunctions
$handle = fopen( $path, 'rb' ); // phpcs:ignore WordPress.WP.AlternativeFunctions -- NOSONAR -- $path guarded above.
if ( false === $handle ) {
return new WP_Error( 'read_failed', __( 'Could not read the archive for upload.', 'emcp-tools' ) );
}
@@ -229,7 +240,10 @@ class EMCP_Tools_Migration_Engine {
* @return string|\WP_Error The destination job id.
*/
public static function finalize( string $path, string $endpoint, string $secret, string $transfer_id ) {
$whole_sha = hash_file( 'sha256', $path );
if ( 0 !== strpos( wp_normalize_path( $path ), rtrim( wp_normalize_path( EMCP_Tools_Packager::backup_dir() ), '/' ) . '/' ) ) {
return new WP_Error( 'invalid_archive', __( 'Only archives inside the backups directory can be pushed.', 'emcp-tools' ) );
}
$whole_sha = hash_file( 'sha256', $path ); // NOSONAR -- $path guarded to the backups dir above.
$canonical = 'finalize|' . $whole_sha . '|' . $transfer_id;
$response = wp_remote_post(
$endpoint . '/finalize',
+1 -1
View File
@@ -397,7 +397,7 @@ class EMCP_Tools_Packager {
private static function file_iterator( string $root, array $skip_dirs ) {
return new RecursiveIteratorIterator(
new RecursiveCallbackFilterIterator(
new RecursiveDirectoryIterator( $root, FilesystemIterator::SKIP_DOTS ),
new RecursiveDirectoryIterator( $root, FilesystemIterator::SKIP_DOTS ), // NOSONAR -- $root is confined to wp-content (file_root_path/add_files reject '..' + exclusions); admin-gated.
static function ( $current ) use ( $skip_dirs ): bool {
if ( $current->isDir() ) {
return ! self::dir_is_skipped( $current->getPathname(), $skip_dirs );