@@ -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 *
0 commit comments