create-block-theme/tests/test-theme-fonts.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

873 lines
30 KiB
PHP

<?php
/**
* Class WP_Create_Block_Theme_Admin
*
* @package Create_Block_Theme
*/
class Test_Create_Block_Theme_Fonts extends WP_UnitTestCase {
protected static $admin_id;
protected static $editor_id;
public static function wpSetUpBeforeClass( WP_UnitTest_Factory $factory ) {
self::$admin_id = $factory->user->create(
array(
'role' => 'administrator',
)
);
self::$editor_id = $factory->user->create(
array(
'role' => 'editor',
)
);
}
public function test_copy_activated_fonts_to_theme() {
wp_set_current_user( self::$admin_id );
$user_data_begin = CBT_Theme_JSON_Resolver::get_user_data()->get_settings();
$test_theme_slug = $this->create_blank_theme();
$this->activate_user_font();
$user_data_before = CBT_Theme_JSON_Resolver::get_user_data()->get_settings();
$theme_data_before = CBT_Theme_JSON_Resolver::get_theme_data()->get_settings();
$this->save_theme();
$user_data_after = CBT_Theme_JSON_Resolver::get_user_data()->get_settings();
$theme_data_after = CBT_Theme_JSON_Resolver::get_theme_data()->get_settings();
// ensure that the font was added and then removed from user space
$this->assertarraynothaskey( 'typography', $user_data_begin );
$this->assertequals( 'open-sans', $user_data_before['typography']['fontFamilies']['custom'][0]['slug'] );
$this->assertarraynothaskey( 'typography', $user_data_after );
// Ensure that the font was added to the theme
$this->assertCount( 1, $theme_data_before['typography']['fontFamilies']['theme'] );
$this->assertCount( 2, $theme_data_after['typography']['fontFamilies']['theme'] );
$this->assertEquals( 'open-sans', $theme_data_after['typography']['fontFamilies']['theme'][1]['slug'] );
// Ensure that the URL was changed to a local file and that it was copied to where it should be
$this->assertEquals( 'file:./assets/fonts/open-sans/open-sans-400-normal.ttf', $theme_data_after['typography']['fontFamilies']['theme'][1]['fontFace'][0]['src'][0] );
$this->assertTrue( file_exists( get_stylesheet_directory() . '/assets/fonts/open-sans/open-sans-400-normal.ttf' ) );
$this->uninstall_theme( $test_theme_slug );
}
public function test_remove_deactivated_fonts_from_theme() {
wp_set_current_user( self::$admin_id );
$test_theme_slug = $this->create_blank_theme();
// Create a theme with multiple fonts
$theme_json = CBT_Theme_JSON_Resolver::get_theme_file_contents();
$theme_json['settings']['typography']['fontFamilies'] = array(
array(
'slug' => 'open-sans',
'name' => 'Open Sans',
'fontFamily' => 'Open Sans',
'fontFace' => array(
array(
'fontFamily' => 'Open Sans',
'fontStyle' => 'normal',
'fontWeight' => '400',
'src' => 'file:./assets/fonts/open-sans-normal-400.ttf',
),
),
),
array(
'slug' => 'deactivated-font',
'name' => 'Deactivated Font',
'fontFamily' => 'Deactivated Font',
'fontFace' => array(
array(
'fontFamily' => 'Deactivated Font',
'fontStyle' => 'normal',
'fontWeight' => '400',
'src' => 'file:./assets/fonts/deactivated-font.ttf',
),
),
),
);
CBT_Theme_JSON_Resolver::write_theme_file_contents( $theme_json );
// Create the font files
$font_dir = get_stylesheet_directory() . '/assets/fonts/';
if ( ! file_exists( $font_dir ) ) {
mkdir( $font_dir, 0777, true );
}
file_put_contents( $font_dir . 'open-sans-normal-400.ttf', 'dummy content' );
file_put_contents( $font_dir . 'deactivated-font.ttf', 'dummy content' );
// Simulate user deactivating the 'deactivated-font' font
$user_settings = array();
$user_settings['typography']['fontFamilies']['theme'] = array(
array(
'slug' => 'open-sans',
'name' => 'Open Sans',
'fontFamily' => 'Open Sans',
),
);
CBT_Theme_JSON_Resolver::write_user_settings( $user_settings );
$user_data_before = CBT_Theme_JSON_Resolver::get_user_data()->get_settings();
$theme_data_before = CBT_Theme_JSON_Resolver::get_theme_data()->get_settings();
$merged_data_before = CBT_Theme_JSON_Resolver::get_merged_data()->get_settings();
$theme_file_exists_before = file_exists( $font_dir . 'open-sans-normal-400.ttf' );
// Call the method to remove deactivated fonts
CBT_Theme_Fonts::remove_deactivated_fonts_from_theme();
$user_data_after = CBT_Theme_JSON_Resolver::get_user_data()->get_settings();
$theme_data_after = CBT_Theme_JSON_Resolver::get_theme_data()->get_settings();
$merged_data_after = CBT_Theme_JSON_Resolver::get_merged_data()->get_settings();
$theme_file_exists_after = file_exists( $font_dir . 'open-sans-normal-400.ttf' );
// ensure that the font was added to the theme settings and removed in user settings and therefore missing in merged settings
$this->assertCount( 2, $theme_data_before['typography']['fontFamilies']['theme'] );
$this->assertEquals( 'open-sans', $theme_data_before['typography']['fontFamilies']['theme'][0]['slug'] );
$this->assertEquals( 'deactivated-font', $theme_data_before['typography']['fontFamilies']['theme'][1]['slug'] );
$this->assertCount( 1, $user_data_before['typography']['fontFamilies']['theme'] );
$this->assertCount( 1, $merged_data_before['typography']['fontFamilies']['theme'] );
// ensure that the font was removed from the user settings and removed from the theme settings and therefore missing in merged settings
$this->assertCount( 1, $theme_data_after['typography']['fontFamilies']['theme'] );
$this->assertEquals( 'open-sans', $theme_data_after['typography']['fontFamilies']['theme'][0]['slug'] );
$this->assertarraynothaskey( 'typography', $user_data_after );
$this->assertCount( 1, $merged_data_after['typography']['fontFamilies']['theme'] );
// ensure that the font asset was removed
$this->assertTrue( $theme_file_exists_before );
$this->assertFalse( file_exists( $font_dir . 'deactivated-font.ttf' ) );
$this->assertTrue( $theme_file_exists_after );
$this->uninstall_theme( $test_theme_slug );
$theme_file_exists_after_uninstall = file_exists( $font_dir . 'open-sans-normal-400.ttf' );
// ensure that the font asset was removed after uninstalling the theme
$this->assertFalse( $theme_file_exists_after_uninstall );
}
public function test_get_all_fonts_just_theme() {
wp_set_current_user( self::$admin_id );
$test_theme_slug = $this->create_blank_theme();
$theme_json = CBT_Theme_JSON_Resolver::get_theme_file_contents();
$theme_json['settings']['typography']['fontFamilies'] = array(
array(
'slug' => 'open-sans',
'name' => 'Open Sans',
'fontFamily' => 'Open Sans',
'fontFace' => array(
array(
'fontFamily' => 'Open Sans',
'fontStyle' => 'normal',
'fontWeight' => '400',
'src' => 'file:./assets/fonts/open-sans-normal-400.ttf',
),
),
),
array(
'slug' => 'closed-sans',
'name' => 'Closed Sans',
'fontFamily' => 'Closed Sans',
'fontFace' => array(
array(
'fontFamily' => 'Closed Sans',
'fontStyle' => 'normal',
'fontWeight' => '400',
'src' => 'http://example.com/closed-sans-normal-400.ttf',
),
),
),
);
CBT_Theme_JSON_Resolver::write_theme_file_contents( $theme_json );
$fonts = CBT_Theme_Fonts::get_all_fonts();
$this->assertCount( 2, $fonts );
$this->assertEquals( 'open-sans', $fonts[0]['slug'] );
$this->assertEquals( 'closed-sans', $fonts[1]['slug'] );
$this->assertStringNotContainsString( 'file:.', $fonts[0]['fontFace'][0]['src'] );
$this->assertStringNotContainsString( 'file:.', $fonts[1]['fontFace'][0]['src'] );
$this->uninstall_theme( $test_theme_slug );
}
public function test_get_all_fonts_from_theme_and_variation() {
wp_set_current_user( self::$admin_id );
$test_theme_slug = $this->create_blank_theme();
$theme_json = CBT_Theme_JSON_Resolver::get_theme_file_contents();
$theme_json['settings']['typography']['fontFamilies'] = array(
array(
'slug' => 'open-sans',
'name' => 'Open Sans',
'fontFamily' => 'Open Sans',
'fontFace' => array(
array(
'fontFamily' => 'Open Sans',
'fontStyle' => 'normal',
'fontWeight' => '400',
'src' => 'file:./assets/fonts/open-sans-normal-400.ttf',
),
),
),
);
CBT_Theme_JSON_Resolver::write_theme_file_contents( $theme_json );
$variation_json = array(
'version' => '2',
'title' => 'Variation',
);
$variation_json['settings']['typography']['fontFamilies'] = array(
array(
'slug' => 'closed-sans',
'name' => 'Closed Sans',
'fontFamily' => 'Closed Sans',
'fontFace' => array(
array(
'fontFamily' => 'Closed Sans',
'fontStyle' => 'normal',
'fontWeight' => '400',
'src' => 'http://example.com/closed-sans-normal-400.ttf',
),
),
),
);
// Save the variation
$variation_path = get_stylesheet_directory() . DIRECTORY_SEPARATOR . 'styles' . DIRECTORY_SEPARATOR;
$variation_slug = 'variation';
wp_mkdir_p( $variation_path );
file_put_contents(
$variation_path . $variation_slug . '.json',
wp_json_encode( $variation_json, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE )
);
$fonts = CBT_Theme_Fonts::get_all_fonts();
$this->assertCount( 2, $fonts );
$this->assertEquals( 'open-sans', $fonts[0]['slug'] );
$this->assertEquals( 'closed-sans', $fonts[1]['slug'] );
$this->assertStringNotContainsString( 'file:.', $fonts[0]['fontFace'][0]['src'] );
$this->assertStringNotContainsString( 'file:.', $fonts[1]['fontFace'][0]['src'] );
$this->uninstall_theme( $test_theme_slug );
}
public function test_non_array_font_src() {
wp_set_current_user( self::$admin_id );
$test_theme_slug = $this->create_blank_theme();
// Create a theme with a non-array font src
$theme_json = CBT_Theme_JSON_Resolver::get_theme_file_contents();
$theme_json['settings']['typography']['fontFamilies'] = array(
array(
'slug' => 'single-src-font',
'name' => 'Single Src Font',
'fontFamily' => 'Single Src Font',
'fontFace' => array(
array(
'fontFamily' => 'Single Src Font',
'fontStyle' => 'normal',
'fontWeight' => '400',
'src' => 'file:./assets/fonts/single-src-font.ttf',
),
),
),
);
CBT_Theme_JSON_Resolver::write_theme_file_contents( $theme_json );
// Attempt to get all fonts
$fonts = CBT_Theme_Fonts::get_all_fonts();
// Verify that the src is correctly handled
$this->assertCount( 1, $fonts );
$this->assertEquals( 'single-src-font', $fonts[0]['slug'] );
$this->assertEquals( get_stylesheet_directory_uri() . '/assets/fonts/single-src-font.ttf', $fonts[0]['fontFace'][0]['src'] );
$this->uninstall_theme( $test_theme_slug );
}
public function test_array_font_src() {
wp_set_current_user( self::$admin_id );
$test_theme_slug = $this->create_blank_theme();
// Create a theme with an array font src
$theme_json = CBT_Theme_JSON_Resolver::get_theme_file_contents();
$theme_json['settings']['typography']['fontFamilies'] = array(
array(
'slug' => 'array-src-font',
'name' => 'Array Src Font',
'fontFamily' => 'Array Src Font',
'fontFace' => array(
array(
'fontFamily' => 'Array Src Font',
'fontStyle' => 'normal',
'fontWeight' => '400',
'src' => array(
'file:./assets/fonts/array-src-font.ttf',
'file:./assets/fonts/array-src-font-bold.ttf',
),
),
),
),
);
CBT_Theme_JSON_Resolver::write_theme_file_contents( $theme_json );
// Attempt to get all fonts
$fonts = CBT_Theme_Fonts::get_all_fonts();
// Verify that the src is correctly handled
$this->assertCount( 1, $fonts );
$this->assertEquals( 'array-src-font', $fonts[0]['slug'] );
$this->assertIsArray( $fonts[0]['fontFace'][0]['src'] );
$this->assertCount( 2, $fonts[0]['fontFace'][0]['src'] );
$this->assertEquals( get_stylesheet_directory_uri() . '/assets/fonts/array-src-font.ttf', $fonts[0]['fontFace'][0]['src'][0] );
$this->assertEquals( get_stylesheet_directory_uri() . '/assets/fonts/array-src-font-bold.ttf', $fonts[0]['fontFace'][0]['src'][1] );
$this->uninstall_theme( $test_theme_slug );
}
public function test_absolute_url_handling() {
wp_set_current_user( self::$admin_id );
$test_theme_slug = $this->create_blank_theme();
// Create a theme with an absolute URL
$theme_json = CBT_Theme_JSON_Resolver::get_theme_file_contents();
$theme_json['settings']['typography']['fontFamilies'] = array(
array(
'slug' => 'absolute-url-font',
'name' => 'Absolute URL Font',
'fontFamily' => 'Absolute URL Font',
'fontFace' => array(
array(
'fontFamily' => 'Absolute URL Font',
'fontStyle' => 'normal',
'fontWeight' => '400',
'src' => 'http://example.com/fonts/absolute-url-font.ttf',
),
),
),
);
CBT_Theme_JSON_Resolver::write_theme_file_contents( $theme_json );
// Attempt to get all fonts
$fonts = CBT_Theme_Fonts::get_all_fonts();
// Verify that the absolute URL remains unchanged
$this->assertCount( 1, $fonts );
$this->assertEquals( 'absolute-url-font', $fonts[0]['slug'] );
$this->assertEquals( 'http://example.com/fonts/absolute-url-font.ttf', $fonts[0]['fontFace'][0]['src'] );
$this->uninstall_theme( $test_theme_slug );
}
private function save_theme() {
CBT_Theme_Fonts::persist_font_settings();
}
private function create_blank_theme() {
$test_theme_slug = 'cbttesttheme';
delete_theme( $test_theme_slug );
$request = new WP_REST_Request( 'POST', '/create-block-theme/v1/create-blank' );
$request->set_param( 'name', $test_theme_slug );
$request->set_param( 'description', '' );
$request->set_param( 'uri', '' );
$request->set_param( 'author', '' );
$request->set_param( 'author_uri', '' );
$request->set_param( 'tags_custom', '' );
$request->set_param( 'recommended_plugins', '' );
rest_do_request( $request );
CBT_Theme_JSON_Resolver::clean_cached_data();
return $test_theme_slug;
}
private function uninstall_theme( $theme_slug ) {
CBT_Theme_JSON_Resolver::write_user_settings( array() );
delete_theme( $theme_slug );
}
private function activate_user_font() {
$font_dir = wp_get_font_dir();
$font_test_url = $font_dir['url'] . '/open-sans-normal-400.ttf';
$font_test_source = __DIR__ . '/data/fonts/OpenSans-Regular.ttf';
$font_test_destination = $font_dir['path'] . '/open-sans-normal-400.ttf';
if ( ! file_exists( $font_dir['path'] ) ) {
mkdir( $font_dir['path'] );
}
copy( $font_test_source, $font_test_destination );
$settings = array();
$settings['typography']['fontFamilies']['custom'] = array(
array(
'slug' => 'open-sans',
'name' => 'Open Sans',
'fontFamily' => 'Open Sans',
'fontFace' => array(
array(
'fontFamily' => 'Open Sans',
'fontStyle' => 'normal',
'fontWeight' => '400',
'src' => $font_test_url,
),
),
),
);
CBT_Theme_JSON_Resolver::write_user_settings( $settings );
}
private function activate_font_in_theme_and_override_in_user() {
// Copy the font asset
$font_dir = get_stylesheet_directory() . '/assets/fonts/';
$font_test_source = __DIR__ . '/data/fonts/OpenSans-Regular.ttf';
$font_test_destination = $font_dir . '/open-sans-normal-400.ttf';
if ( ! file_exists( get_stylesheet_directory() . '/assets/' ) ) {
mkdir( get_stylesheet_directory() . '/assets/' );
}
if ( ! file_exists( get_stylesheet_directory() . '/assets/fonts/' ) ) {
mkdir( get_stylesheet_directory() . '/assets/fonts/' );
}
copy( $font_test_source, $font_test_destination );
// Add the font to the theme
$theme_json = CBT_Theme_JSON_Resolver::get_theme_file_contents();
$theme_original_font_families = $theme_json['settings']['typography']['fontFamilies'];
$theme_json['settings']['typography']['fontFamilies'][] = array(
'slug' => 'open-sans',
'name' => 'Open Sans',
'fontFamily' => 'Open Sans',
'fontFace' => array(
array(
'fontFamily' => 'Open Sans',
'fontStyle' => 'normal',
'fontWeight' => '400',
'src' => array(
'file:./assets/fonts/open-sans-normal-400.ttf',
'file:./assets/fonts/open-sans-normal-400.ttf',
),
),
),
);
CBT_Theme_JSON_Resolver::write_theme_file_contents( $theme_json );
// Deactivate the test font in the theme. To do this the 'theme' collection
// is overwritten to declare the intention of having it gone.
// Here we're writing the font family settings as they existed BEFORE we added
// the test font family to the theme.
$settings = array();
$settings['typography']['fontFamilies']['theme'] = $theme_original_font_families;
CBT_Theme_JSON_Resolver::write_user_settings( $settings );
}
public function test_make_filename_from_fontface() {
$font_face = array(
'fontFamily' => 'Open Sans',
'fontWeight' => '400',
'fontStyle' => 'normal',
'unicodeRange' => 'U+0000-00FF',
);
$src = 'https://example.com/assets/fonts/open-sans-regular.ttf';
$src_index = 0;
$expected = 'open-sans-400-normal-u0000-00ff.ttf';
$actual = CBT_Theme_Fonts::make_filename_from_fontface( $font_face, $src, $src_index );
$this->assertEquals( $expected, $actual );
}
public function test_is_allowed_font_url_accepts_standard_extensions() {
$urls = array(
'http://fonts.example.com/foo.ttf',
'http://fonts.example.com/foo.otf',
'http://fonts.example.com/foo.woff',
'http://fonts.example.com/foo.woff2',
'http://fonts.example.com/foo.eot',
);
foreach ( $urls as $url ) {
$this->assertTrue( CBT_Theme_Fonts::is_allowed_font_url( $url ), "Should accept: $url" );
}
}
public function test_is_allowed_font_url_rejects_php_and_other_extensions() {
$urls = array(
'http://fonts.example.com/evil.php',
'http://fonts.example.com/evil.phtml',
'http://fonts.example.com/evil.phar',
'http://fonts.example.com/evil.txt',
'http://fonts.example.com/evil.bin',
'http://fonts.example.com/no-extension',
);
foreach ( $urls as $url ) {
$this->assertFalse( CBT_Theme_Fonts::is_allowed_font_url( $url ), "Should reject: $url" );
}
}
public function test_is_allowed_font_url_is_case_insensitive() {
$this->assertFalse( CBT_Theme_Fonts::is_allowed_font_url( 'http://fonts.example.com/EVIL.PHP' ) );
$this->assertTrue( CBT_Theme_Fonts::is_allowed_font_url( 'http://fonts.example.com/FOO.WOFF2' ) );
}
public function test_is_allowed_font_url_ignores_query_string() {
$this->assertTrue( CBT_Theme_Fonts::is_allowed_font_url( 'http://fonts.example.com/foo.woff2?v=2' ) );
$this->assertFalse( CBT_Theme_Fonts::is_allowed_font_url( 'http://fonts.example.com/evil.php?disguised=foo.woff2' ) );
}
public function test_is_allowed_font_file_accepts_real_woff2() {
$tmp = wp_tempnam( 'cbt-test-font' );
copy( __DIR__ . '/data/fonts/OpenSans-Regular.woff2', $tmp );
$ok = CBT_Theme_Fonts::is_allowed_font_file( $tmp, 'http://fonts.example.com/foo.woff2' );
@unlink( $tmp );
$this->assertTrue( $ok );
}
public function test_is_allowed_font_file_accepts_real_ttf() {
$tmp = wp_tempnam( 'cbt-test-ttf' );
copy( __DIR__ . '/data/fonts/OpenSans-Regular.ttf', $tmp );
$ok = CBT_Theme_Fonts::is_allowed_font_file( $tmp, 'http://fonts.example.com/foo.ttf' );
@unlink( $tmp );
$this->assertTrue( $ok );
}
public function test_is_allowed_font_file_accepts_real_otf() {
$tmp = wp_tempnam( 'cbt-test-otf' );
copy( __DIR__ . '/data/fonts/OpenSans-Regular.otf', $tmp );
$ok = CBT_Theme_Fonts::is_allowed_font_file( $tmp, 'http://fonts.example.com/foo.otf' );
@unlink( $tmp );
$this->assertTrue( $ok );
}
public function test_is_allowed_font_file_accepts_real_woff() {
$tmp = wp_tempnam( 'cbt-test-woff' );
copy( __DIR__ . '/data/fonts/OpenSans-Regular.woff', $tmp );
$ok = CBT_Theme_Fonts::is_allowed_font_file( $tmp, 'http://fonts.example.com/foo.woff' );
@unlink( $tmp );
$this->assertTrue( $ok );
}
public function test_is_allowed_font_file_rejects_php_body_with_woff2_url() {
$tmp = wp_tempnam( 'cbt-test-font-evil' );
copy( __DIR__ . '/data/evil.php.txt', $tmp );
$ok = CBT_Theme_Fonts::is_allowed_font_file( $tmp, 'http://fonts.example.com/evil.woff2' );
@unlink( $tmp );
$this->assertFalse( $ok );
}
public function test_is_allowed_font_file_rejects_missing_file() {
$this->assertFalse( CBT_Theme_Fonts::is_allowed_font_file( '/nonexistent/tmp/file', 'http://fonts.example.com/foo.woff2' ) );
}
public function test_copy_font_assets_to_theme_skips_php_src_without_downloading() {
wp_set_current_user( self::$admin_id );
$test_theme_slug = $this->create_blank_theme();
// Spy on HTTP — should NOT be called for the disallowed 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 );
$families = array(
array(
'name' => 'Evil Family',
'slug' => 'evil-family',
'fontFace' => array(
array(
'fontFamily' => 'Evil Family',
'fontWeight' => '400',
'fontStyle' => 'normal',
'src' => array( 'http://fonts.example.com/evil.php' ),
),
),
),
);
CBT_Theme_Fonts::copy_font_assets_to_theme( $families );
remove_filter( 'pre_http_request', $tracker, 10 );
$malicious = get_stylesheet_directory() . '/assets/fonts/evil-family/evil-family-400-normal.php';
$this->assertFalse( $attempted, 'download_url() must NOT be called for a disallowed-extension font src' );
$this->assertFileDoesNotExist( $malicious );
$this->uninstall_theme( $test_theme_slug );
}
public function test_is_allowed_font_url_rejects_multi_extension_polyglots() {
$urls = array(
'http://fonts.example.com/evil.php.woff2',
'http://fonts.example.com/evil.phtml.ttf',
'http://fonts.example.com/sneaky.htaccess.woff',
'http://fonts.example.com/inject.html.otf',
'http://fonts.example.com/evil.PHP.woff2', // case-insensitive
);
foreach ( $urls as $url ) {
$this->assertFalse( CBT_Theme_Fonts::is_allowed_font_url( $url ), "Should reject polyglot: $url" );
}
}
public function test_is_allowed_font_url_accepts_multi_dot_filenames() {
// Legitimate multi-dot filenames where NO interior segment is dangerous.
$urls = array(
'http://fonts.example.com/font.bold.italic.woff2',
'http://fonts.example.com/family.v2.ttf',
);
foreach ( $urls as $url ) {
$this->assertTrue( CBT_Theme_Fonts::is_allowed_font_url( $url ), "Should accept legit multi-dot: $url" );
}
}
public function test_copy_font_assets_to_theme_writes_legit_woff2() {
wp_set_current_user( self::$admin_id );
$test_theme_slug = $this->create_blank_theme();
$woff2_bytes = file_get_contents( __DIR__ . '/data/fonts/OpenSans-Regular.woff2' );
// phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
$mock = function ( $preempt, $args, $url ) use ( $woff2_bytes ) {
$tmp = isset( $args['filename'] ) ? $args['filename'] : null;
if ( $tmp ) {
file_put_contents( $tmp, $woff2_bytes );
}
return array(
'headers' => array(),
'response' => array(
'code' => 200,
'message' => 'OK',
),
'body' => '',
'cookies' => array(),
'filename' => $tmp,
);
};
add_filter( 'pre_http_request', $mock, 10, 3 );
$families = array(
array(
'name' => 'Test Sans',
'slug' => 'test-sans',
'fontFace' => array(
array(
'fontFamily' => 'Test Sans',
'fontWeight' => '400',
'fontStyle' => 'normal',
'src' => array( 'http://fonts.example.com/test-sans.woff2' ),
),
),
),
);
CBT_Theme_Fonts::copy_font_assets_to_theme( $families );
remove_filter( 'pre_http_request', $mock, 10 );
$expected = get_stylesheet_directory() . '/assets/fonts/test-sans/test-sans-400-normal.woff2';
$this->assertFileExists( $expected, 'Legitimate WOFF2 URL should have been written to assets/fonts/' );
if ( file_exists( $expected ) ) {
$this->assertSame( $woff2_bytes, file_get_contents( $expected ) );
}
$this->uninstall_theme( $test_theme_slug );
}
public function test_copy_font_assets_to_theme_drops_disallowed_url_from_returned_src() {
wp_set_current_user( self::$admin_id );
$test_theme_slug = $this->create_blank_theme();
$woff2_bytes = file_get_contents( __DIR__ . '/data/fonts/OpenSans-Regular.woff2' );
// phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
$mock = function ( $preempt, $args, $url ) use ( $woff2_bytes ) {
$tmp = isset( $args['filename'] ) ? $args['filename'] : null;
if ( $tmp ) {
file_put_contents( $tmp, $woff2_bytes );
}
return array(
'headers' => array(),
'response' => array(
'code' => 200,
'message' => 'OK',
),
'body' => '',
'cookies' => array(),
'filename' => $tmp,
);
};
add_filter( 'pre_http_request', $mock, 10, 3 );
$families = array(
array(
'name' => 'Mixed Family',
'slug' => 'mixed-family',
'fontFace' => array(
array(
'fontFamily' => 'Mixed Family',
'fontWeight' => '400',
'fontStyle' => 'normal',
'src' => array(
'http://fonts.example.com/legit.woff2',
'http://fonts.example.com/evil.php',
),
),
),
),
);
$result = CBT_Theme_Fonts::copy_font_assets_to_theme( $families );
remove_filter( 'pre_http_request', $mock, 10 );
$returned_src = $result[0]['fontFace'][0]['src'];
$this->assertCount( 1, $returned_src, 'Disallowed URL should be dropped from the returned src list.' );
$this->assertStringStartsWith( 'file:./assets/fonts/', $returned_src[0] );
$this->assertNotContains( 'http://fonts.example.com/evil.php', $returned_src, 'Rejected URL must not be persisted to the returned families.' );
$this->uninstall_theme( $test_theme_slug );
}
public function test_copy_font_assets_to_theme_drops_mime_mismatch_src_from_returned_families() {
wp_set_current_user( self::$admin_id );
$test_theme_slug = $this->create_blank_theme();
// Serve PHP bytes for a .woff2 URL — passes URL allowlist, fails MIME.
// 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 );
$families = array(
array(
'name' => 'Polyglot Family',
'slug' => 'polyglot-family',
'fontFace' => array(
array(
'fontFamily' => 'Polyglot Family',
'fontWeight' => '400',
'fontStyle' => 'normal',
'src' => array( 'http://fonts.example.com/disguised.woff2' ),
),
),
),
);
$result = CBT_Theme_Fonts::copy_font_assets_to_theme( $families );
remove_filter( 'pre_http_request', $mock, 10 );
$returned_src = $result[0]['fontFace'][0]['src'];
$this->assertSame( array(), $returned_src, 'Source with mismatched MIME must be dropped from the returned src list.' );
$this->uninstall_theme( $test_theme_slug );
}
public function test_copy_font_assets_to_theme_rejects_local_non_font_source() {
// A font src pointing at the WP user-fonts directory whose body is
// NOT a font (e.g. a polyglot left behind by a separate flow) should
// be dropped from the returned src list. The local-copy branch must
// not trust the URL extension alone.
wp_set_current_user( self::$admin_id );
$test_theme_slug = $this->create_blank_theme();
$font_dir = wp_get_font_dir();
if ( ! file_exists( $font_dir['path'] ) ) {
mkdir( $font_dir['path'], 0777, true );
}
$disguised_name = 'evil-disguised-400-normal.woff2';
$disguised_path = $font_dir['path'] . '/' . $disguised_name;
// PHP body, woff2 extension — passes the URL allowlist, must be
// rejected by the magic-byte check.
file_put_contents( $disguised_path, "<?php echo 'pwned'; ?>" );
$families = array(
array(
'name' => 'Evil Local',
'slug' => 'evil-local',
'fontFace' => array(
array(
'fontFamily' => 'Evil Local',
'fontWeight' => '400',
'fontStyle' => 'normal',
'src' => array( $font_dir['url'] . '/' . $disguised_name ),
),
),
),
);
$result = CBT_Theme_Fonts::copy_font_assets_to_theme( $families );
$returned_src = $result[0]['fontFace'][0]['src'];
$this->assertSame( array(), $returned_src, 'Non-font local source must be dropped from the returned src list.' );
$this->assertFileDoesNotExist(
get_stylesheet_directory() . '/assets/fonts/evil-local/' . $disguised_name,
'Non-font local source must NOT be copied into the theme.'
);
@unlink( $disguised_path );
$this->uninstall_theme( $test_theme_slug );
}
public function test_copy_font_assets_to_theme_preserves_existing_file_src() {
wp_set_current_user( self::$admin_id );
$test_theme_slug = $this->create_blank_theme();
$families = array(
array(
'name' => 'Local Family',
'slug' => 'local-family',
'fontFace' => array(
array(
'fontFamily' => 'Local Family',
'fontWeight' => '400',
'fontStyle' => 'normal',
'src' => array( 'file:./assets/fonts/local-family.woff2' ),
),
),
),
);
$result = CBT_Theme_Fonts::copy_font_assets_to_theme( $families );
$this->assertSame(
array( 'file:./assets/fonts/local-family.woff2' ),
$result[0]['fontFace'][0]['src'],
'Pre-existing file: src should be preserved unchanged.'
);
$this->uninstall_theme( $test_theme_slug );
}
}