Skip to content

Commit 699c5bd

Browse files
authored
Bound ZIP intake before decompression (Automattic#859)
* fix: bound ZIP intake before decompression (Automattic#854) [AI: OpenAI GPT-5.6 Sol via OpenCode] * fix: satisfy ZIP intake lint [AI: OpenAI GPT-5.6 Sol via OpenCode]
1 parent 1c2fa1c commit 699c5bd

2 files changed

Lines changed: 145 additions & 2 deletions

File tree

includes/rest.php

Lines changed: 102 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1108,8 +1108,19 @@ function static_site_importer_rest_archive_files( array $archive ) {
11081108
return new WP_Error( 'static_site_importer_zip_unavailable', __( 'ZIP archive extraction is unavailable on this server.', 'static-site-importer' ), array( 'status' => 500 ) );
11091109
}
11101110

1111-
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- Decodes uploaded ZIP archive payload content.
1112-
$content = isset( $archive['content_base64'] ) ? base64_decode( (string) $archive['content_base64'], true ) : false;
1111+
$encoded_content = isset( $archive['content_base64'] ) ? (string) $archive['content_base64'] : '';
1112+
$limits = static_site_importer_rest_archive_limits();
1113+
if ( strlen( $encoded_content ) > $limits['max_encoded_bytes'] ) {
1114+
return static_site_importer_rest_archive_limit_error( 'encoded_bytes_exceeded' );
1115+
}
1116+
1117+
// This upper bound is calculated without allocating the decoded archive.
1118+
if ( intdiv( strlen( $encoded_content ) + 3, 4 ) * 3 > $limits['max_decoded_bytes'] ) {
1119+
return static_site_importer_rest_archive_limit_error( 'decoded_bytes_exceeded' );
1120+
}
1121+
1122+
// phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- Decodes uploaded ZIP archive payload content after its encoded and decoded sizes are bounded.
1123+
$content = base64_decode( $encoded_content, true );
11131124
if ( false === $content ) {
11141125
return new WP_Error( 'static_site_importer_invalid_archive_content', __( 'Uploaded ZIP archive content could not be decoded.', 'static-site-importer' ), array( 'status' => 400 ) );
11151126
}
@@ -1128,6 +1139,43 @@ function static_site_importer_rest_archive_files( array $archive ) {
11281139
return new WP_Error( 'static_site_importer_archive_open_failed', __( 'Uploaded ZIP archive could not be opened.', 'static-site-importer' ), array( 'status' => 400 ) );
11291140
}
11301141

1142+
if ( $zip->numFiles > $limits['max_entries'] ) {
1143+
$zip->close();
1144+
wp_delete_file( $tmp );
1145+
return static_site_importer_rest_archive_limit_error( 'entry_count_exceeded' );
1146+
}
1147+
1148+
$total_uncompressed_bytes = 0;
1149+
for ( $i = 0; $i < $zip->numFiles; $i++ ) {
1150+
$stat = $zip->statIndex( $i );
1151+
if ( ! is_array( $stat ) || $stat['size'] < 0 || $stat['comp_size'] < 0 ) {
1152+
$zip->close();
1153+
wp_delete_file( $tmp );
1154+
return new WP_Error( 'static_site_importer_archive_metadata_invalid', __( 'A ZIP archive entry has invalid metadata.', 'static-site-importer' ), array( 'status' => 400 ) );
1155+
}
1156+
1157+
$entry_uncompressed_bytes = (int) $stat['size'];
1158+
$entry_compressed_bytes = (int) $stat['comp_size'];
1159+
if ( $entry_uncompressed_bytes > $limits['max_entry_uncompressed_bytes'] ) {
1160+
$zip->close();
1161+
wp_delete_file( $tmp );
1162+
return static_site_importer_rest_archive_limit_error( 'entry_uncompressed_bytes_exceeded' );
1163+
}
1164+
1165+
$total_uncompressed_bytes += $entry_uncompressed_bytes;
1166+
if ( $total_uncompressed_bytes > $limits['max_total_uncompressed_bytes'] ) {
1167+
$zip->close();
1168+
wp_delete_file( $tmp );
1169+
return static_site_importer_rest_archive_limit_error( 'total_uncompressed_bytes_exceeded' );
1170+
}
1171+
1172+
if ( 0 === $entry_compressed_bytes ? $entry_uncompressed_bytes > 0 : $entry_uncompressed_bytes / $entry_compressed_bytes > $limits['max_compression_ratio'] ) {
1173+
$zip->close();
1174+
wp_delete_file( $tmp );
1175+
return static_site_importer_rest_archive_limit_error( 'compression_ratio_exceeded' );
1176+
}
1177+
}
1178+
11311179
$files = array();
11321180
for ( $i = 0; $i < $zip->numFiles; $i++ ) {
11331181
$entry = $zip->getNameIndex( $i );
@@ -1176,6 +1224,58 @@ function static_site_importer_rest_archive_files( array $archive ) {
11761224
return $files;
11771225
}
11781226

1227+
/**
1228+
* Return bounded ZIP intake limits.
1229+
*
1230+
* @return array<string,int>
1231+
*/
1232+
function static_site_importer_rest_archive_limits(): array {
1233+
$hard_limits = array(
1234+
'max_encoded_bytes' => 52428800,
1235+
'max_decoded_bytes' => 39321600,
1236+
'max_entries' => 5000,
1237+
'max_entry_uncompressed_bytes' => 26214400,
1238+
'max_total_uncompressed_bytes' => 104857600,
1239+
'max_compression_ratio' => 200,
1240+
);
1241+
$defaults = array(
1242+
'max_encoded_bytes' => 26214400,
1243+
'max_decoded_bytes' => 19660800,
1244+
'max_entries' => 1000,
1245+
'max_entry_uncompressed_bytes' => 10485760,
1246+
'max_total_uncompressed_bytes' => 52428800,
1247+
'max_compression_ratio' => 100,
1248+
);
1249+
$limits = apply_filters( 'static_site_importer_archive_limits', $defaults );
1250+
$limits = is_array( $limits ) ? $limits : $defaults;
1251+
1252+
foreach ( $hard_limits as $key => $maximum ) {
1253+
$candidate = isset( $limits[ $key ] ) ? (int) $limits[ $key ] : $defaults[ $key ];
1254+
$limits[ $key ] = min( $maximum, max( 1, $candidate ) );
1255+
}
1256+
1257+
return $limits;
1258+
}
1259+
1260+
/**
1261+
* Build a stable archive-policy error without exposing archive contents.
1262+
*
1263+
* @param string $reason Policy reason code.
1264+
* @return WP_Error
1265+
*/
1266+
function static_site_importer_rest_archive_limit_error( string $reason ): WP_Error {
1267+
$code = 'static_site_importer_archive_' . $reason;
1268+
1269+
return new WP_Error(
1270+
$code,
1271+
__( 'The ZIP archive exceeds the configured safety limit.', 'static-site-importer' ),
1272+
array(
1273+
'status' => 400,
1274+
'diagnostic' => array( 'code' => $code ),
1275+
)
1276+
);
1277+
}
1278+
11791279
/**
11801280
* Normalize uploaded file paths into artifact paths.
11811281
*

tests/smoke-importer-block.php

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1189,6 +1189,49 @@ static function ( mixed $output, array $request ): mixed {
11891189
$paths = array_column( $artifact['files'] ?? array(), 'path' );
11901190
$assert( in_array( 'website/site/index.html', $paths, true ), 'rest-zip-extracts-normalized-entry' );
11911191
$assert( in_array( 'website/escape.html', $paths, true ), 'rest-zip-strips-traversal-entry' );
1192+
1193+
$assert_archive_limit = static function ( string $label, array $limits, array $entries, string $expected_code ) use ( $assert ): void {
1194+
$zip_path = tempnam( sys_get_temp_dir(), 'ssi-limit-' );
1195+
$zip = new ZipArchive();
1196+
$zip->open( $zip_path, ZipArchive::OVERWRITE );
1197+
foreach ( $entries as $path => $content ) {
1198+
$zip->addFromString( $path, $content );
1199+
}
1200+
$zip->close();
1201+
1202+
$GLOBALS['ssi_filters']['static_site_importer_archive_limits'] = array(
1203+
static function ( array $configured_limits ) use ( $limits ): array {
1204+
return array_merge( $configured_limits, $limits );
1205+
},
1206+
);
1207+
$result = static_site_importer_rest_archive_files(
1208+
array(
1209+
'name' => 'adversarial.zip',
1210+
'content_base64' => base64_encode( file_get_contents( $zip_path ) ),
1211+
)
1212+
);
1213+
unset( $GLOBALS['ssi_filters']['static_site_importer_archive_limits'] );
1214+
@unlink( $zip_path );
1215+
1216+
$assert( is_wp_error( $result ) && $expected_code === $result->get_error_code(), $label . '-rejects-before-materialization', is_wp_error( $result ) ? $result->get_error_code() : 'archive was materialized' );
1217+
$assert( is_wp_error( $result ) && $expected_code === ( $result->get_error_data()['diagnostic']['code'] ?? '' ), $label . '-returns-stable-diagnostic' );
1218+
};
1219+
1220+
$assert_archive_limit( 'rest-zip-encoded-size', array( 'max_encoded_bytes' => 1 ), array( 'index.html' => '<main>bounded</main>' ), 'static_site_importer_archive_encoded_bytes_exceeded' );
1221+
$assert_archive_limit( 'rest-zip-decoded-size', array( 'max_encoded_bytes' => 1024 * 1024, 'max_decoded_bytes' => 1 ), array( 'index.html' => '<main>bounded</main>' ), 'static_site_importer_archive_decoded_bytes_exceeded' );
1222+
$assert_archive_limit( 'rest-zip-entry-count', array( 'max_entries' => 1 ), array( 'index.html' => '<main>one</main>', 'about.html' => '<main>two</main>' ), 'static_site_importer_archive_entry_count_exceeded' );
1223+
$assert_archive_limit( 'rest-zip-entry-size', array( 'max_entry_uncompressed_bytes' => 16 ), array( 'index.html' => str_repeat( 'a', 32 ) ), 'static_site_importer_archive_entry_uncompressed_bytes_exceeded' );
1224+
$assert_archive_limit( 'rest-zip-aggregate-size', array( 'max_entry_uncompressed_bytes' => 1024, 'max_total_uncompressed_bytes' => 32 ), array( 'index.html' => str_repeat( 'a', 20 ), 'about.html' => str_repeat( 'b', 20 ) ), 'static_site_importer_archive_total_uncompressed_bytes_exceeded' );
1225+
$assert_archive_limit( 'rest-zip-compression-ratio', array( 'max_entry_uncompressed_bytes' => 4096, 'max_compression_ratio' => 2 ), array( 'index.html' => str_repeat( 'a', 2048 ) ), 'static_site_importer_archive_compression_ratio_exceeded' );
1226+
$GLOBALS['ssi_filters']['static_site_importer_archive_limits'] = array(
1227+
static function ( array $limits ): array {
1228+
$limits['max_encoded_bytes'] = PHP_INT_MAX;
1229+
return $limits;
1230+
},
1231+
);
1232+
$hard_limited = static_site_importer_rest_archive_limits();
1233+
unset( $GLOBALS['ssi_filters']['static_site_importer_archive_limits'] );
1234+
$assert( 52428800 >= $hard_limited['max_encoded_bytes'], 'rest-zip-filter-cannot-exceed-encoded-hard-ceiling' );
11921235
}
11931236

11941237
$artifact = static_site_importer_rest_source_artifact(

0 commit comments

Comments
 (0)