mirror of
https://github.com/WordPress/create-block-theme.git
synced 2026-07-27 21:48:03 +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>
553 lines
23 KiB
PHP
553 lines
23 KiB
PHP
<?php
|
||
/**
|
||
* @package Create_Block_Theme
|
||
*/
|
||
class Test_Create_Block_Theme_Media extends WP_UnitTestCase {
|
||
|
||
public function test_make_images_block_local() {
|
||
$template = new stdClass();
|
||
$template->content = '
|
||
<!-- wp:image -->
|
||
<figure class="wp-block-image"><img src="http://example.com/image.jpg" alt="Alternative Text" /></figure>
|
||
<!-- /wp:image -->
|
||
';
|
||
$new_template = CBT_Theme_Media::make_template_images_local( $template );
|
||
|
||
// The image should be replaced with a relative URL
|
||
$this->assertStringNotContainsString( 'http://example.com/image.jpg', $new_template->content );
|
||
$this->assertStringContainsString( 'get_template_directory_uri', $new_template->content );
|
||
$this->assertStringContainsString( '/assets/images', $new_template->content );
|
||
|
||
}
|
||
|
||
public function test_make_cover_block_local() {
|
||
$template = new stdClass();
|
||
$template->content = '
|
||
<!-- wp:cover {"url":"http://example.com/image.jpg"} -->
|
||
<div class="wp-block-cover">
|
||
<img class="wp-block-cover__image-background wp-image-628" alt="" src="http://example.com/image.jpg" data-object-fit="cover"/>
|
||
<div class="wp-block-cover__inner-container">
|
||
</div>
|
||
</div>
|
||
<!-- /wp:cover -->
|
||
';
|
||
$new_template = CBT_Theme_Media::make_template_images_local( $template );
|
||
|
||
// The image should be replaced with a relative URL
|
||
$this->assertStringNotContainsString( 'http://example.com/image.jpg', $new_template->content );
|
||
$this->assertStringContainsString( 'get_template_directory_uri', $new_template->content );
|
||
$this->assertStringContainsString( '/assets/images', $new_template->content );
|
||
}
|
||
|
||
public function test_template_with_media_correctly_prepared() {
|
||
$template = new stdClass();
|
||
$template->slug = 'test-template';
|
||
$template->content = '
|
||
<!-- wp:image -->
|
||
<figure class="wp-block-image"><img src="http://example.com/image.jpg" alt="Alternative Text" /></figure>
|
||
<!-- /wp:image -->
|
||
';
|
||
$new_template = CBT_Theme_Templates::prepare_template_for_export( $template );
|
||
|
||
// Content should be replaced with a pattern block
|
||
$this->assertStringContainsString( '<!-- wp:pattern', $new_template->content );
|
||
|
||
// The media to install should be in the collection
|
||
$this->assertContains( 'http://example.com/image.jpg', $new_template->media );
|
||
|
||
// The pattern is correctly encoded
|
||
$this->assertStringContainsString( '<img src="<?php echo esc_url( get_template_directory_uri() ); ?>/assets/images/image.jpg"', $new_template->pattern );
|
||
|
||
}
|
||
|
||
public function test_make_group_block_local() {
|
||
$template = new stdClass();
|
||
$template->slug = 'test-template';
|
||
$template->content = '
|
||
<!-- wp:group {"style":{"background":{"backgroundImage":{"url":"http://example.com/image.jpg","id":31,"source":"file","title":"Screenshot 2024-04-18 at 14-08-49 Blog Home ‹ Template ‹ a8c-wp-env ‹ Editor — WordPress"}}},"layout":{"type":"constrained"}} -->
|
||
<div class="wp-block-group"></div>
|
||
<!-- /wp:group -->
|
||
';
|
||
$new_template = CBT_Theme_Templates::prepare_template_for_export( $template );
|
||
|
||
// Content should be replaced with a pattern block
|
||
$this->assertStringContainsString( '<!-- wp:pattern', $new_template->content );
|
||
|
||
// The media to install should be in the collection
|
||
$this->assertContains( 'http://example.com/image.jpg', $new_template->media );
|
||
|
||
// The pattern is correctly encoded
|
||
$this->assertStringContainsString( '{"backgroundImage":{"url":"<?php echo esc_url( get_template_directory_uri() ); ?>/assets/images/image.jpg"', $new_template->pattern );
|
||
|
||
}
|
||
|
||
public function test_is_allowed_media_url_accepts_image_extension() {
|
||
$this->assertTrue( CBT_Theme_Media::is_allowed_media_url( 'http://example.com/cat.jpg' ) );
|
||
$this->assertTrue( CBT_Theme_Media::is_allowed_media_url( 'http://example.com/path/photo.png' ) );
|
||
$this->assertTrue( CBT_Theme_Media::is_allowed_media_url( 'https://example.com/clip.webp' ) );
|
||
}
|
||
|
||
public function test_is_allowed_media_url_accepts_video_extension() {
|
||
$this->assertTrue( CBT_Theme_Media::is_allowed_media_url( 'http://example.com/movie.mp4' ) );
|
||
$this->assertTrue( CBT_Theme_Media::is_allowed_media_url( 'http://example.com/movie.webm' ) );
|
||
}
|
||
|
||
public function test_is_allowed_media_url_rejects_php_extension() {
|
||
$this->assertFalse( CBT_Theme_Media::is_allowed_media_url( 'http://example.com/evil.php' ) );
|
||
}
|
||
|
||
public function test_is_allowed_media_url_rejects_other_dangerous_extensions() {
|
||
$urls = array(
|
||
'http://example.com/evil.phtml',
|
||
'http://example.com/evil.phar',
|
||
'http://example.com/evil.html',
|
||
'http://example.com/.htaccess',
|
||
'http://example.com/evil.php5',
|
||
'http://example.com/no-extension',
|
||
);
|
||
foreach ( $urls as $url ) {
|
||
$this->assertFalse( CBT_Theme_Media::is_allowed_media_url( $url ), "Should reject: $url" );
|
||
}
|
||
}
|
||
|
||
public function test_is_allowed_media_url_is_case_insensitive() {
|
||
$this->assertFalse( CBT_Theme_Media::is_allowed_media_url( 'http://example.com/EVIL.PHP' ) );
|
||
$this->assertTrue( CBT_Theme_Media::is_allowed_media_url( 'http://example.com/CAT.JPG' ) );
|
||
}
|
||
|
||
public function test_is_allowed_media_url_ignores_query_string() {
|
||
$this->assertTrue( CBT_Theme_Media::is_allowed_media_url( 'http://example.com/cat.jpg?v=2' ) );
|
||
$this->assertFalse( CBT_Theme_Media::is_allowed_media_url( 'http://example.com/evil.php?disguised=cat.jpg' ) );
|
||
}
|
||
|
||
public function test_is_allowed_media_file_accepts_real_png() {
|
||
$tmp = wp_tempnam( 'cbt-test-png' );
|
||
copy( __DIR__ . '/data/tiny.png', $tmp );
|
||
$ok = CBT_Theme_Media::is_allowed_media_file( $tmp, 'http://example.com/cat.png' );
|
||
@unlink( $tmp );
|
||
$this->assertTrue( $ok );
|
||
}
|
||
|
||
public function test_is_allowed_media_file_rejects_php_body_with_image_url() {
|
||
$tmp = wp_tempnam( 'cbt-test-php' );
|
||
copy( __DIR__ . '/data/evil.php.txt', $tmp );
|
||
$ok = CBT_Theme_Media::is_allowed_media_file( $tmp, 'http://example.com/evil.jpg' );
|
||
@unlink( $tmp );
|
||
$this->assertFalse( $ok );
|
||
}
|
||
|
||
public function test_is_allowed_media_file_rejects_missing_file() {
|
||
$this->assertFalse( CBT_Theme_Media::is_allowed_media_file( '/nonexistent/tmp/file', 'http://example.com/cat.jpg' ) );
|
||
}
|
||
|
||
/**
|
||
* Helper: write magic bytes to a tmp file and assert acceptance.
|
||
*/
|
||
private function assert_media_magic_accepted( $bytes, $url ) {
|
||
$tmp = wp_tempnam( 'cbt-test-magic' );
|
||
file_put_contents( $tmp, $bytes );
|
||
$ok = CBT_Theme_Media::is_allowed_media_file( $tmp, $url );
|
||
@unlink( $tmp );
|
||
$this->assertTrue( $ok, "Should accept magic bytes for $url" );
|
||
}
|
||
|
||
public function test_is_allowed_media_file_accepts_jpeg_magic() {
|
||
$this->assert_media_magic_accepted( "\xff\xd8\xff\xe0" . str_repeat( "\x00", 16 ), 'http://example.com/cat.jpg' );
|
||
}
|
||
|
||
public function test_is_allowed_media_file_accepts_gif_magic() {
|
||
$this->assert_media_magic_accepted( 'GIF89a' . str_repeat( "\x00", 16 ), 'http://example.com/cat.gif' );
|
||
}
|
||
|
||
public function test_is_allowed_media_file_accepts_webp_magic() {
|
||
// RIFF + 4-byte size (any) + 'WEBP' + payload.
|
||
$this->assert_media_magic_accepted( "RIFF\x00\x00\x00\x00WEBP" . str_repeat( "\x00", 16 ), 'http://example.com/cat.webp' );
|
||
}
|
||
|
||
public function test_is_allowed_media_file_accepts_avif_magic() {
|
||
// ISO BMFF: 4-byte size + 'ftyp' + 'avif' brand + payload.
|
||
$this->assert_media_magic_accepted( "\x00\x00\x00\x20" . 'ftypavif' . str_repeat( "\x00", 16 ), 'http://example.com/cat.avif' );
|
||
}
|
||
|
||
public function test_is_allowed_media_file_accepts_avif_sequence_brand() {
|
||
// AVIF image sequence uses 'avis' brand.
|
||
$this->assert_media_magic_accepted( "\x00\x00\x00\x20" . 'ftypavis' . str_repeat( "\x00", 16 ), 'http://example.com/cat.avif' );
|
||
}
|
||
|
||
public function test_is_allowed_media_file_accepts_avif_in_compatible_brands() {
|
||
// Some AVIF files (typically those emitted by HEIF-derived tooling)
|
||
// use `mif1` as the major brand and list `avif` only among the
|
||
// compatible brands. The check must accept these.
|
||
// Layout: size(4) + 'ftyp' + major='mif1' + minor='\x00\x00\x00\x00'
|
||
// + compatible brands = 'miaf' 'avif' (then padding).
|
||
$bytes = "\x00\x00\x00\x20" . 'ftyp' . 'mif1' . "\x00\x00\x00\x00" . 'miafavif' . str_repeat( "\x00", 8 );
|
||
$this->assert_media_magic_accepted( $bytes, 'http://example.com/cat.avif' );
|
||
}
|
||
|
||
public function test_is_allowed_media_file_accepts_avis_in_compatible_brands() {
|
||
// As above but with the sequence brand only in the compatible list.
|
||
$bytes = "\x00\x00\x00\x20" . 'ftyp' . 'mif1' . "\x00\x00\x00\x00" . 'miafavis' . str_repeat( "\x00", 8 );
|
||
$this->assert_media_magic_accepted( $bytes, 'http://example.com/cat.avif' );
|
||
}
|
||
|
||
public function test_is_allowed_media_file_rejects_heic_disguised_as_avif() {
|
||
// Pure HEIC: major brand `heic`, compatible brands `mif1` + `heic`
|
||
// (no AVIF brand anywhere). Must NOT pass the AVIF check even
|
||
// though the file extension claims .avif.
|
||
$bytes = "\x00\x00\x00\x20" . 'ftyp' . 'heic' . "\x00\x00\x00\x00" . 'mif1heic' . str_repeat( "\x00", 8 );
|
||
$tmp = wp_tempnam( 'cbt-test-heic' );
|
||
file_put_contents( $tmp, $bytes );
|
||
$ok = CBT_Theme_Media::is_allowed_media_file( $tmp, 'http://example.com/sneaky.avif' );
|
||
@unlink( $tmp );
|
||
$this->assertFalse( $ok, 'HEIC (no avif/avis brand) must be rejected when URL claims .avif' );
|
||
}
|
||
|
||
public function test_is_allowed_media_file_accepts_svg_content() {
|
||
$this->assert_media_magic_accepted( '<svg xmlns="http://www.w3.org/2000/svg"><circle cx="5" cy="5" r="3"/></svg>', 'http://example.com/cat.svg' );
|
||
}
|
||
|
||
public function test_is_allowed_media_file_accepts_svg_with_xml_declaration() {
|
||
// Use a `?` ` >` split for the closing tag so PHP does not exit the
|
||
// file's PHP mode inside this string literal.
|
||
$payload = '<' . '?xml version="1.0" encoding="UTF-8"?' . '>' . '<svg xmlns="http://www.w3.org/2000/svg"/>';
|
||
$this->assert_media_magic_accepted( $payload, 'http://example.com/cat.svg' );
|
||
}
|
||
|
||
public function test_is_allowed_media_file_accepts_mp4_ftyp() {
|
||
// ISO BMFF: 4-byte size + 'ftyp' + brand. Covers mp4, m4v, mov, 3gp, 3g2.
|
||
$this->assert_media_magic_accepted( "\x00\x00\x00\x20" . 'ftypisom' . str_repeat( "\x00", 16 ), 'http://example.com/cat.mp4' );
|
||
}
|
||
|
||
public function test_is_allowed_media_file_accepts_webm_magic() {
|
||
$this->assert_media_magic_accepted( "\x1a\x45\xdf\xa3" . str_repeat( "\x00", 16 ), 'http://example.com/cat.webm' );
|
||
}
|
||
|
||
public function test_is_allowed_media_file_accepts_ogv_magic() {
|
||
$this->assert_media_magic_accepted( 'OggS' . str_repeat( "\x00", 16 ), 'http://example.com/cat.ogv' );
|
||
}
|
||
|
||
public function test_is_allowed_media_file_accepts_wmv_asf_magic() {
|
||
$this->assert_media_magic_accepted( "\x30\x26\xb2\x75\x8e\x66\xcf\x11" . str_repeat( "\x00", 16 ), 'http://example.com/cat.wmv' );
|
||
}
|
||
|
||
public function test_is_allowed_media_file_accepts_avi_riff() {
|
||
// RIFF + 4-byte size (any) + 'AVI ' + payload.
|
||
$this->assert_media_magic_accepted( "RIFF\x00\x00\x00\x00AVI " . str_repeat( "\x00", 16 ), 'http://example.com/cat.avi' );
|
||
}
|
||
|
||
public function test_is_allowed_media_file_accepts_mpeg_magic() {
|
||
$this->assert_media_magic_accepted( "\x00\x00\x01\xb3" . str_repeat( "\x00", 16 ), 'http://example.com/cat.mpg' );
|
||
}
|
||
|
||
public function test_is_allowed_media_file_rejects_random_bytes() {
|
||
$tmp = wp_tempnam( 'cbt-test-random' );
|
||
file_put_contents( $tmp, 'this is just random text that does not match any magic' );
|
||
$ok = CBT_Theme_Media::is_allowed_media_file( $tmp, 'http://example.com/cat.jpg' );
|
||
@unlink( $tmp );
|
||
$this->assertFalse( $ok );
|
||
}
|
||
|
||
public function test_is_allowed_media_file_rejects_extension_mismatch() {
|
||
$tmp = wp_tempnam( 'cbt-test-svg-as-jpg' );
|
||
file_put_contents( $tmp, '<svg xmlns="http://www.w3.org/2000/svg"><circle cx="5" cy="5" r="3"/></svg>' );
|
||
$ok = CBT_Theme_Media::is_allowed_media_file( $tmp, 'http://example.com/cat.jpg' );
|
||
@unlink( $tmp );
|
||
$this->assertFalse( $ok, 'Downloaded media bytes should match the URL extension before being saved under that filename.' );
|
||
}
|
||
|
||
public function test_make_relative_media_url_uses_path_filename_not_query_basename() {
|
||
$relative_url = CBT_Theme_Media::make_relative_media_url( 'http://example.com/cat.jpg?/evil.php' );
|
||
|
||
$this->assertStringContainsString( '/assets/images/cat.jpg', $relative_url );
|
||
$this->assertStringNotContainsString( 'evil.php', $relative_url );
|
||
}
|
||
|
||
public function test_make_template_images_local_does_not_rewrite_disallowed_url() {
|
||
$template = new stdClass();
|
||
$template->content = '
|
||
<!-- wp:image -->
|
||
<figure class="wp-block-image"><img src="http://example.com/evil.php" alt="" /></figure>
|
||
<!-- /wp:image -->
|
||
';
|
||
|
||
$new_template = CBT_Theme_Media::make_template_images_local( $template );
|
||
|
||
$this->assertStringContainsString( 'http://example.com/evil.php', $new_template->content );
|
||
$this->assertStringNotContainsString( '/assets/', $new_template->content );
|
||
}
|
||
|
||
public function test_make_template_images_local_only_rewrites_validated_media() {
|
||
$template = new stdClass();
|
||
$template->content = '
|
||
<!-- wp:image -->
|
||
<figure class="wp-block-image"><img src="http://example.com/copied.png" alt="" /></figure>
|
||
<!-- /wp:image -->
|
||
<!-- wp:image -->
|
||
<figure class="wp-block-image"><img src="http://example.com/not-copied.png" alt="" /></figure>
|
||
<!-- /wp:image -->
|
||
';
|
||
|
||
$new_template = CBT_Theme_Media::make_template_images_local(
|
||
$template,
|
||
array( 'http://example.com/copied.png' )
|
||
);
|
||
|
||
$this->assertStringContainsString( '/assets/images/copied.png', $new_template->content );
|
||
$this->assertStringContainsString( 'http://example.com/not-copied.png', $new_template->content );
|
||
$this->assertStringNotContainsString( '/assets/images/not-copied.png', $new_template->content );
|
||
}
|
||
|
||
public function test_prepare_template_for_export_leaves_unvalidated_media_remote() {
|
||
$template = new stdClass();
|
||
$template->slug = 'test-template';
|
||
$template->content = '
|
||
<!-- wp:image -->
|
||
<figure class="wp-block-image"><img src="http://example.com/not-copied.png" alt="" /></figure>
|
||
<!-- /wp:image -->
|
||
';
|
||
|
||
$new_template = CBT_Theme_Templates::prepare_template_for_export(
|
||
$template,
|
||
null,
|
||
array(
|
||
'localizeText' => false,
|
||
'removeNavRefs' => true,
|
||
'localizeImages' => true,
|
||
'validatedMedia' => array(),
|
||
)
|
||
);
|
||
|
||
$this->assertStringContainsString( 'http://example.com/not-copied.png', $new_template->content );
|
||
$this->assertStringNotContainsString( '/assets/images/not-copied.png', $new_template->content );
|
||
}
|
||
|
||
public function test_add_media_to_local_skips_php_url_without_downloading() {
|
||
$theme_assets = get_stylesheet_directory() . DIRECTORY_SEPARATOR . 'assets' . DIRECTORY_SEPARATOR;
|
||
$malicious = $theme_assets . 'evil.php';
|
||
|
||
// Make sure the dir exists and the file is NOT pre-existing.
|
||
if ( file_exists( $malicious ) ) {
|
||
unlink( $malicious );
|
||
}
|
||
|
||
// Track whether any HTTP request gets attempted; allowlist short-circuits before download_url.
|
||
$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_Media::add_media_to_local( array( 'http://example.com/evil.php' ) );
|
||
|
||
remove_filter( 'pre_http_request', $tracker, 10 );
|
||
|
||
$this->assertFalse( $attempted, 'download_url() must NOT be called for a disallowed-extension URL' );
|
||
$this->assertFileDoesNotExist( $malicious );
|
||
}
|
||
|
||
public function test_is_allowed_media_url_rejects_multi_extension_polyglots() {
|
||
$urls = array(
|
||
'http://example.com/evil.php.jpg',
|
||
'http://example.com/evil.phtml.png',
|
||
'http://example.com/evil.phar.gif',
|
||
'http://example.com/sneaky.htaccess.jpg',
|
||
'http://example.com/inject.html.png',
|
||
'http://example.com/evil.PHP.jpg', // case-insensitive
|
||
);
|
||
foreach ( $urls as $url ) {
|
||
$this->assertFalse( CBT_Theme_Media::is_allowed_media_url( $url ), "Should reject polyglot: $url" );
|
||
}
|
||
}
|
||
|
||
public function test_is_allowed_media_url_accepts_multi_dot_filenames() {
|
||
// Legitimate multi-dot filenames where NO interior segment is dangerous.
|
||
$urls = array(
|
||
'http://example.com/image.full.size.jpg',
|
||
'http://example.com/photo.v2.png',
|
||
'http://example.com/clip.final.mp4',
|
||
);
|
||
foreach ( $urls as $url ) {
|
||
$this->assertTrue( CBT_Theme_Media::is_allowed_media_url( $url ), "Should accept legit multi-dot: $url" );
|
||
}
|
||
}
|
||
|
||
public function test_add_media_to_local_writes_legit_png() {
|
||
$theme_assets_dir = get_stylesheet_directory() . DIRECTORY_SEPARATOR . 'assets' . DIRECTORY_SEPARATOR . 'images' . DIRECTORY_SEPARATOR;
|
||
$expected_path = $theme_assets_dir . 'tinytest.png';
|
||
|
||
if ( file_exists( $expected_path ) ) {
|
||
unlink( $expected_path );
|
||
}
|
||
|
||
$png_bytes = file_get_contents( __DIR__ . '/data/tiny.png' );
|
||
$captured_tmp_path = null;
|
||
|
||
// Intercept download_url and write the real PNG bytes to its tmp file.
|
||
// download_url internally calls wp_safe_remote_get with stream=true and a
|
||
// `filename` arg; we short-circuit by returning a body, which download_url
|
||
// then writes to its tmp path via the WP HTTP API.
|
||
// phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
|
||
$mock = function ( $preempt, $args, $url ) use ( $png_bytes, &$captured_tmp_path ) {
|
||
$captured_tmp_path = isset( $args['filename'] ) ? $args['filename'] : null;
|
||
if ( $captured_tmp_path ) {
|
||
file_put_contents( $captured_tmp_path, $png_bytes );
|
||
}
|
||
return array(
|
||
'headers' => array(),
|
||
'response' => array(
|
||
'code' => 200,
|
||
'message' => 'OK',
|
||
),
|
||
'body' => '',
|
||
'cookies' => array(),
|
||
'filename' => $captured_tmp_path,
|
||
);
|
||
};
|
||
add_filter( 'pre_http_request', $mock, 10, 3 );
|
||
|
||
$added_media = CBT_Theme_Media::add_media_to_local( array( 'http://example.com/tinytest.png' ) );
|
||
|
||
remove_filter( 'pre_http_request', $mock, 10 );
|
||
|
||
$this->assertSame( array( 'http://example.com/tinytest.png' ), $added_media );
|
||
$this->assertFileExists( $expected_path, 'Legitimate PNG URL should have been written to the theme assets dir' );
|
||
if ( file_exists( $expected_path ) ) {
|
||
$this->assertSame( $png_bytes, file_get_contents( $expected_path ) );
|
||
unlink( $expected_path );
|
||
}
|
||
}
|
||
|
||
public function test_add_media_to_local_does_not_return_mime_mismatch_url() {
|
||
$theme_assets_dir = get_stylesheet_directory() . DIRECTORY_SEPARATOR . 'assets' . DIRECTORY_SEPARATOR . 'images' . DIRECTORY_SEPARATOR;
|
||
$expected_path = $theme_assets_dir . 'disguised.png';
|
||
|
||
if ( file_exists( $expected_path ) ) {
|
||
unlink( $expected_path );
|
||
}
|
||
|
||
// 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_Media::add_media_to_local( array( 'http://example.com/disguised.png' ) );
|
||
|
||
remove_filter( 'pre_http_request', $mock, 10 );
|
||
|
||
$this->assertSame( array(), $added_media, 'MIME mismatch URLs should not be reported as copied.' );
|
||
$this->assertFileDoesNotExist( $expected_path );
|
||
}
|
||
|
||
public function test_token_processor_src_uses_path_filename_after_successful_copy() {
|
||
$theme_assets_dir = get_stylesheet_directory() . DIRECTORY_SEPARATOR . 'assets' . DIRECTORY_SEPARATOR . 'images' . DIRECTORY_SEPARATOR;
|
||
$expected_path = $theme_assets_dir . 'cat.png';
|
||
|
||
if ( file_exists( $expected_path ) ) {
|
||
unlink( $expected_path );
|
||
}
|
||
|
||
$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 );
|
||
|
||
$processor = new CBT_Token_Processor( '<span><img src="http://example.com/cat.png?/evil.php" alt=""></span>' );
|
||
$processor->process_tokens();
|
||
|
||
remove_filter( 'pre_http_request', $mock, 10 );
|
||
|
||
$tokens = implode( '', $processor->get_tokens() );
|
||
$this->assertStringContainsString( '/assets/images/cat.png', $tokens );
|
||
$this->assertStringNotContainsString( 'evil.php', $tokens );
|
||
|
||
if ( file_exists( $expected_path ) ) {
|
||
unlink( $expected_path );
|
||
}
|
||
}
|
||
|
||
public function test_make_template_images_local_does_not_prefix_match_rejected_url() {
|
||
// Regression: plain str_replace on the whole template would
|
||
// substring-match the validated URL inside the rejected URL when
|
||
// one is a prefix of the other (e.g. `photo.png` inside
|
||
// `photo.png.php`). That would silently localize the rejected URL
|
||
// even though the validated-media guard rejected it. The parsed
|
||
// rewrite must only touch values that EXACTLY match a validated
|
||
// URL.
|
||
$template = new stdClass();
|
||
$template->content = '
|
||
<!-- wp:image -->
|
||
<figure><img src="http://example.com/photo.png" alt="" /></figure>
|
||
<!-- /wp:image -->
|
||
<!-- wp:image -->
|
||
<figure><img src="http://example.com/photo.png.php" alt="" /></figure>
|
||
<!-- /wp:image -->
|
||
';
|
||
// Only the safe URL is in the validated-media list — the .php
|
||
// polyglot was rejected upstream and must NOT be localized.
|
||
$new_template = CBT_Theme_Media::make_template_images_local(
|
||
$template,
|
||
array( 'http://example.com/photo.png' )
|
||
);
|
||
|
||
// The validated URL is rewritten.
|
||
$this->assertStringNotContainsString( 'src="http://example.com/photo.png"', $new_template->content );
|
||
$this->assertStringContainsString( '/assets/images/photo.png', $new_template->content );
|
||
|
||
// The rejected URL is left intact at its original (remote) value —
|
||
// neither partially rewritten nor pointed at a missing local asset.
|
||
$this->assertStringContainsString( 'http://example.com/photo.png.php', $new_template->content );
|
||
$this->assertStringNotContainsString( '/assets/images/photo.png.php', $new_template->content );
|
||
}
|
||
|
||
public function test_token_processor_src_leaves_rejected_media_remote() {
|
||
$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 );
|
||
|
||
$processor = new CBT_Token_Processor( '<span><img src="http://example.com/evil.php" alt=""></span>' );
|
||
$processor->process_tokens();
|
||
|
||
remove_filter( 'pre_http_request', $tracker, 10 );
|
||
|
||
$tokens = implode( '', $processor->get_tokens() );
|
||
$this->assertFalse( $attempted, 'download_url() must NOT be called for a rejected token src.' );
|
||
$this->assertStringContainsString( 'http://example.com/evil.php', $tokens );
|
||
$this->assertStringNotContainsString( '/assets/', $tokens );
|
||
}
|
||
}
|