mirror of
https://github.com/WordPress/create-block-theme.git
synced 2026-07-26 21:42:48 +08:00
* tests: add tiny.png and evil.php.txt fixtures for media validation * docs(agents): update test:php commands to test:unit:php * Add CBT_Theme_Media::is_allowed_media_url() extension allowlist * Add CBT_Theme_Media::is_allowed_media_file() MIME allowlist * Apply media URL + file allowlist in add_media_to_local * Add CBT_Theme_Fonts::is_allowed_font_url() extension allowlist * Add CBT_Theme_Fonts::is_allowed_font_file() MIME allowlist * Apply font URL + file allowlist in copy_font_assets_to_theme * Allow font/sfnt and application/vnd.ms-opentype for TTF/OTF MIME detection * Reject multi-extension polyglots in URL allowlist * Apply media+font allowlist to zip export sinks in theme-zip * Add positive end-to-end tests for media and font sinks * Verify downloaded fonts by magic bytes instead of libmagic MIME * Pass $font_src string to download_url instead of $font_face src array Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Stream downloaded media into zip and clean up tmp file Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Strip query string before deriving folder path from media URL Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Restore braces and is_wp_error check dropped by Copilot suggestions Two recent Copilot suggestion commits accidentally removed structural code while making single-line changes: -c045da7("Pass $font_src to download_url") dropped the `} else {` and the `is_wp_error( $tmp_file )` guard. -617f6ec("Stream downloaded media into zip") dropped the closing brace of the `foreach` in add_media_to_zip(). Both broke PHP parse on every CI matrix. Restoring the missing statements so the file is valid again. * lint: align assignment operators in get_media_folder_path_from_url * Strip query string from filename before renaming downloaded media Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Strip query string from font URL before deriving filename Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Strip query string from font URL before deriving filename in zip Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Restore loop brace and sanitize_title dropped by Copilot suggestions Two more Copilot suggestion commits dropped surrounding context lines: -ad31fe4("Strip query string from filename before renaming downloaded media") removed the closing brace of the foreach loop in add_media_to_local(), causing fatal lint errors. -860f1bc("Strip query string from font URL before deriving filename in zip") removed the `$font_family_dir_name = sanitize_title(...)` assignment, leaving an undefined variable used immediately below. * Remove dead pre-loop assignments in add_activated_fonts_to_zip `$font_filename = basename( $font_face['src'] )` threw TypeError on PHP 8+ when src was already an array (valid per Theme JSON spec) and was redefined inside the inner src loop anyway. The adjacent `$font_dir = wp_get_font_dir()` was likewise redundant — also redefined inside the inner loop. Removing both. * Verify downloaded media by magic bytes instead of libmagic MIME wp_check_filetype_and_ext() was inconsistent with the URL allowlist: SVG isn't in WP Core's default mime registry at all, and WP maps wmv to video/x-ms-wmv / avi to video/avi while the post-check allowlist had only video/x-msvideo. Legitimate SVG/WMV/AVI URLs passed preflight and then silently failed post-download. Switching media to magic-byte verification (matching the font sink) removes the dependency on WP Core's mime registry and libmagic versions. Recognised formats: JPEG, PNG, GIF, WebP, SVG, MP4/M4V/MOV/ 3GP/3G2 (via ftyp), WebM, OGV, WMV (ASF), AVI (RIFF), MPEG. * Add regression tests for media asset validation * Fix media validation bugs found by scruffian Three fixes for issues found via regression tests in8da66ff: 1. is_allowed_media_file() now requires the downloaded bytes to match the URL extension specifically — a .jpg URL with SVG/MP4/anything-else bytes is rejected. Previously any allowed magic was accepted, so a browser MIME-sniffing the on-disk .jpg containing SVG could render it as SVG (with all the script-execution surface that brings). 2. make_relative_media_url() now derives the filename from the parsed URL path instead of raw basename(). A URL like cat.jpg?/evil.php previously produced `/assets/images/evil.php` in exported template markup because basename() treats query-string slashes as path separators. 3. add_media_to_zip() reads the downloaded bytes into memory and unlinks the tmp file BEFORE adding to the archive via addFromStringToTheme(). The previous `addFileToTheme() + unlink` sequence broke because ZipArchive defers reading files added via addFile() until close() — by which time the tmp file was gone. * lint: suppress NoSilencedErrors warning on ZipArchive::close in test * Skip zip tests gracefully when ZipArchive extension is unavailable Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Guard localhost retry against missing host/port keys parse_url() omits the 'port' key when the URL has no explicit port (e.g. `http://localhost/foo`). The retry-on-localhost block in both add_media_to_zip() and add_media_to_local() read $parsed_url['port'] unconditionally, raising an undefined-index notice when the initial download failed for such a URL. Guard with isset() on both keys. * Match font magic bytes against URL extension specifically Previously any recognised font magic was accepted, so a .woff2 URL returning TTF/WOFF/OTF/EOT bytes would pass and be saved under the .woff2 filename. This defeated the extension+MIME allowlist intent and would produce broken assets in the exported theme/zip (browsers would fail to load a WOFF2-named file containing TTF content). The check now switches on the URL extension and validates the magic matches THAT specific format. Mirrors the same fix already applied to is_allowed_media_file(). * Align .mpeg handling across URL, folder, and file-content checks is_allowed_media_file() accepted both .mpg and .mpeg, but is_allowed_media_url() and get_media_folder_path_from_url() only listed .mpg. A template with a .mpeg video would get rewritten to a local asset URL, then skipped before download — leaving the exported theme pointing at a missing file. Add .mpeg to the URL allowlist and the folder mapping so all three lists agree (matching WP Core's wp_get_mime_types which maps mpeg|mpg|mpe to video/mpeg). Also drops a pre-existing duplicate 'ogv' entry in the folder mapping that was noticed while editing. * Stream font bytes into zip before unlinking tmp file Same fix as add_media_to_zip: ZipArchive::addFile() defers reading the file until close() is called, so unlinking the download_url tmp file immediately after addFileToTheme() left an empty entry in the final archive when the zip was finalised. Read the bytes into memory, unlink, then addFromStringToTheme() — mirrors the pattern in add_media_to_zip(). Only the remote-download branch is affected; the local-copy branch points at a permanent file in the font library so its addFileToTheme() call stays. * Tweak comment * Allow AVIF images alongside JPEG/PNG/GIF/WebP/SVG AVIF is a WordPress-supported image format but was missing from the URL allowlist, folder mapping, and magic-byte check. The result: make_template_images_local() rewrote .avif URLs to local references, but the post-rewrite download was rejected — leaving exported themes pointing at missing assets. AVIF uses the ISO BMFF container (like MP4) with 'avif' or 'avis' as the major brand at offset 8. Added the brand-specific magic check alongside the URL/folder allowlist entries, plus tests for both AVIF still-image ('avif') and image-sequence ('avis') brands. * Drop rejected font sources from exported families Previously a font source that failed the URL allowlist or post-download MIME check was skipped via continue, but the original $font_src stayed in $font_face['src']. copy_activated_fonts_to_theme() then merged that family into theme.json — leaving the user's activated font pointing at an uncopied (and possibly disallowed) source while the user custom settings were cleared. Both copy_font_assets_to_theme() and add_activated_fonts_to_zip() now build a fresh srcs list and only retain entries that successfully copied or were already file:./ asset paths. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Only rewrite media URLs after successful asset validation * Accept AVIF compatible-brands in addition to the major brand Some AVIF files (typically those emitted by HEIF-derived tooling) set the major brand to mif1/miaf and only list avif/avis in the compatible brands. Parsing only the major brand at offset 8 would false-reject them. Scan the full FileTypeBox brand list (major brand plus compatible brands at offsets 16+) for any AVIF brand. HEIC files (no avif/avis anywhere) are still rejected. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Magic-byte check the local-copy font branch too Both copy_font_assets_to_theme() and add_activated_fonts_to_zip() treated a font src under the WP user-fonts directory as trusted — copying / zipping the bytes without ever inspecting them. Anything that ended up in that directory through a separate flow (a polyglot upload via another plugin, manual write, etc.) would be promoted to the exported theme as-is just because the URL extension was on the allowlist. Run the same is_allowed_font_file() check on the local source before copying / zipping. If the bytes don't match the URL extension's font format, drop the source from the returned families. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Localize media URLs via the block parser, not str_replace Plain str_replace on the whole template would substring-match a shorter validated URL inside a longer rejected URL when one is a prefix of the other (e.g. `photo.png` inside `photo.png.php`). That silently rewrote the rejected URL to a missing local asset and bypassed the validated-media guard. Walk the parsed block tree instead: WP_HTML_Tag_Processor handles img / video src+poster and inline `background-image:url(...)`, while direct array access handles block-comment JSON attrs. Replacements only touch values that exactly match a validated URL. The rewrites embed literal PHP open/close tags that must end up verbatim in the export, but both set_attribute() and wp_json_encode() (inside serialize_blocks) would escape `<` / `>` / quotes. Route the rewrites through opaque, URL-shaped placeholders so they survive both passes, then strtr() them back to the raw PHP at the end. The placeholder is a root-relative path because set_attribute prefixes schemeless values with `http://`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Ben Dwyer <ben@scruffian.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
130 lines
4.5 KiB
PHP
130 lines
4.5 KiB
PHP
<?php
|
|
/**
|
|
* @package Create_Block_Theme
|
|
*/
|
|
class Test_Create_Block_Theme_Zip extends WP_UnitTestCase {
|
|
|
|
/**
|
|
* Create a CBT_Zip_Archive backed by a temporary file. Returns array(
|
|
* $zip, $tmp_path ) so the caller can clean up.
|
|
*/
|
|
private function make_temp_zip( $slug = 'cbt-test' ) {
|
|
$tmp_path = wp_tempnam( $slug . '.zip' );
|
|
// wp_tempnam() creates the file; ZipArchive::OVERWRITE will replace it.
|
|
$zip = CBT_Theme_Zip::create_zip( $tmp_path, $slug );
|
|
if ( is_wp_error( $zip ) ) {
|
|
@unlink( $tmp_path );
|
|
$this->markTestSkipped( $zip->get_error_message() );
|
|
}
|
|
return array( $zip, $tmp_path );
|
|
}
|
|
|
|
public function test_add_media_to_zip_skips_php_url_without_downloading() {
|
|
list( $zip, $tmp_path ) = $this->make_temp_zip( 'cbt-test' );
|
|
|
|
$attempted = false;
|
|
// phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
|
|
$tracker = function ( $preempt, $args, $url ) use ( &$attempted ) {
|
|
$attempted = true;
|
|
return new WP_Error( 'cbt_test_intercept', 'blocked by test' );
|
|
};
|
|
add_filter( 'pre_http_request', $tracker, 10, 3 );
|
|
|
|
CBT_Theme_Zip::add_media_to_zip( $zip, array( 'http://example.com/evil.php' ) );
|
|
|
|
remove_filter( 'pre_http_request', $tracker, 10 );
|
|
$zip->close();
|
|
@unlink( $tmp_path );
|
|
|
|
$this->assertFalse( $attempted, 'download_url() must NOT be called for a disallowed-extension URL' );
|
|
}
|
|
|
|
public function test_add_media_to_zip_preserves_downloaded_file_until_close() {
|
|
list( $zip, $tmp_path ) = $this->make_temp_zip( 'cbt-test' );
|
|
|
|
$png_bytes = file_get_contents( __DIR__ . '/data/tiny.png' );
|
|
|
|
// phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
|
|
$mock = function ( $preempt, $args, $url ) use ( $png_bytes ) {
|
|
$tmp = isset( $args['filename'] ) ? $args['filename'] : null;
|
|
if ( $tmp ) {
|
|
file_put_contents( $tmp, $png_bytes );
|
|
}
|
|
return array(
|
|
'headers' => array(),
|
|
'response' => array(
|
|
'code' => 200,
|
|
'message' => 'OK',
|
|
),
|
|
'body' => '',
|
|
'cookies' => array(),
|
|
'filename' => $tmp,
|
|
);
|
|
};
|
|
add_filter( 'pre_http_request', $mock, 10, 3 );
|
|
|
|
$added_media = CBT_Theme_Zip::add_media_to_zip( $zip, array( 'http://example.com/tinyzip.png' ) );
|
|
|
|
remove_filter( 'pre_http_request', $mock, 10 );
|
|
|
|
$this->assertSame( array( 'http://example.com/tinyzip.png' ), $added_media );
|
|
|
|
// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- ZipArchive::close() may emit a warning if the archive ended up empty; the test asserts the return value instead.
|
|
$closed = @$zip->close();
|
|
$reader = null;
|
|
try {
|
|
$this->assertTrue( $closed, 'ZIP should close successfully after downloaded media is added.' );
|
|
|
|
$reader = new ZipArchive();
|
|
$this->assertTrue( $reader->open( $tmp_path ), 'ZIP should be readable after close.' );
|
|
$this->assertSame( $png_bytes, $reader->getFromName( 'cbt-test/assets/images/tinyzip.png' ) );
|
|
} finally {
|
|
if ( $reader instanceof ZipArchive ) {
|
|
$reader->close();
|
|
}
|
|
@unlink( $tmp_path );
|
|
}
|
|
}
|
|
|
|
public function test_add_media_to_zip_does_not_return_mime_mismatch_url() {
|
|
list( $zip, $tmp_path ) = $this->make_temp_zip( 'cbt-test' );
|
|
|
|
// phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
|
|
$mock = function ( $preempt, $args, $url ) {
|
|
$tmp = isset( $args['filename'] ) ? $args['filename'] : null;
|
|
if ( $tmp ) {
|
|
file_put_contents( $tmp, "<?php echo 'pwned'; ?>" );
|
|
}
|
|
return array(
|
|
'headers' => array(),
|
|
'response' => array(
|
|
'code' => 200,
|
|
'message' => 'OK',
|
|
),
|
|
'body' => '',
|
|
'cookies' => array(),
|
|
'filename' => $tmp,
|
|
);
|
|
};
|
|
add_filter( 'pre_http_request', $mock, 10, 3 );
|
|
|
|
$added_media = CBT_Theme_Zip::add_media_to_zip( $zip, array( 'http://example.com/disguised.png' ) );
|
|
|
|
remove_filter( 'pre_http_request', $mock, 10 );
|
|
$zip->close();
|
|
@unlink( $tmp_path );
|
|
|
|
$this->assertSame( array(), $added_media, 'MIME mismatch URLs should not be reported as added to the ZIP.' );
|
|
}
|
|
|
|
/**
|
|
* Integration-level test for the font sink would require seeding the
|
|
* user global-styles post and getting WP_Theme_JSON_Resolver to surface
|
|
* the family via get_user_activated_fonts(); the resolver caches per
|
|
* request, so the test setup is too fragile to assert anything
|
|
* meaningful without further plumbing. The font URL validator itself is
|
|
* covered by tests in test-theme-fonts.php; the wiring in
|
|
* CBT_Theme_Zip::add_activated_fonts_to_zip() mirrors the already-tested
|
|
* wiring in CBT_Theme_Fonts::copy_font_assets_to_theme().
|
|
*/
|
|
}
|