create-block-theme/tests/test-theme-media.php
Sarah Norris da693bf64a
Validate downloaded theme assets against extension and MIME allowlists (#852)
* 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 in 8da66ff:

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>
2026-06-19 15:25:43 +01:00

553 lines
23 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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 );
}
}