Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
68d900f
tests: add tiny.png and evil.php.txt fixtures for media validation
mikachan Jun 15, 2026
66f625c
docs(agents): update test:php commands to test:unit:php
mikachan Jun 15, 2026
b714990
Add CBT_Theme_Media::is_allowed_media_url() extension allowlist
mikachan Jun 15, 2026
8d06b1e
Add CBT_Theme_Media::is_allowed_media_file() MIME allowlist
mikachan Jun 15, 2026
a4591b6
Apply media URL + file allowlist in add_media_to_local
mikachan Jun 15, 2026
70da5bc
Add CBT_Theme_Fonts::is_allowed_font_url() extension allowlist
mikachan Jun 15, 2026
1a0c7cf
Add CBT_Theme_Fonts::is_allowed_font_file() MIME allowlist
mikachan Jun 15, 2026
e73ad6a
Apply font URL + file allowlist in copy_font_assets_to_theme
mikachan Jun 15, 2026
772b161
Allow font/sfnt and application/vnd.ms-opentype for TTF/OTF MIME dete…
mikachan Jun 15, 2026
e949c52
Reject multi-extension polyglots in URL allowlist
mikachan Jun 15, 2026
3b2232e
Apply media+font allowlist to zip export sinks in theme-zip
mikachan Jun 15, 2026
f48d0ed
Add positive end-to-end tests for media and font sinks
mikachan Jun 15, 2026
e6c1f1c
Verify downloaded fonts by magic bytes instead of libmagic MIME
mikachan Jun 16, 2026
c045da7
Pass $font_src string to download_url instead of $font_face src array
mikachan Jun 16, 2026
617f6ec
Stream downloaded media into zip and clean up tmp file
mikachan Jun 16, 2026
03dcbe1
Strip query string before deriving folder path from media URL
mikachan Jun 16, 2026
07783f0
Restore braces and is_wp_error check dropped by Copilot suggestions
mikachan Jun 16, 2026
63e7045
lint: align assignment operators in get_media_folder_path_from_url
mikachan Jun 16, 2026
ad31fe4
Strip query string from filename before renaming downloaded media
mikachan Jun 16, 2026
575eb1c
Strip query string from font URL before deriving filename
mikachan Jun 16, 2026
860f1bc
Strip query string from font URL before deriving filename in zip
mikachan Jun 16, 2026
e39fcca
Restore loop brace and sanitize_title dropped by Copilot suggestions
mikachan Jun 16, 2026
5b22c72
Remove dead pre-loop assignments in add_activated_fonts_to_zip
mikachan Jun 16, 2026
68f574c
Verify downloaded media by magic bytes instead of libmagic MIME
mikachan Jun 16, 2026
8da66ff
Add regression tests for media asset validation
scruffian Jun 16, 2026
754a000
Fix media validation bugs found by scruffian
mikachan Jun 16, 2026
56c0aa8
lint: suppress NoSilencedErrors warning on ZipArchive::close in test
mikachan Jun 16, 2026
aa017ad
Skip zip tests gracefully when ZipArchive extension is unavailable
mikachan Jun 17, 2026
5b550be
Guard localhost retry against missing host/port keys
mikachan Jun 17, 2026
a592c6a
Match font magic bytes against URL extension specifically
mikachan Jun 17, 2026
375663c
Align .mpeg handling across URL, folder, and file-content checks
mikachan Jun 17, 2026
ef205f7
Stream font bytes into zip before unlinking tmp file
mikachan Jun 17, 2026
3f974f5
Tweak comment
mikachan Jun 17, 2026
a98690c
Allow AVIF images alongside JPEG/PNG/GIF/WebP/SVG
mikachan Jun 18, 2026
ee84237
Drop rejected font sources from exported families
mikachan Jun 18, 2026
84966da
Only rewrite media URLs after successful asset validation
mikachan Jun 18, 2026
1e931c4
Accept AVIF compatible-brands in addition to the major brand
mikachan Jun 19, 2026
ffea01d
Magic-byte check the local-copy font branch too
mikachan Jun 19, 2026
86397ea
Localize media URLs via the block parser, not str_replace
mikachan Jun 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,9 @@ npm run build
# Watch for changes and rebuild the plugin
npm run start

# Run the PHP unit tests (requires Docker; test:php:setup must succeed first)
npm run test:php:setup
npm run test:php
# Run the PHP unit tests (requires Docker; setup must succeed first)
npm run test:unit:php:setup
npm run test:unit:php

# Run the JavaScript unit tests
npm run test:unit
Expand Down
143 changes: 143 additions & 0 deletions includes/create-theme/theme-fonts.php
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,133 @@ public static function make_filename_from_fontface( $font_face, $src, $src_index
return $font_filename;
}

/**
* Allowlist check on the URL's path extension before we attempt to download a font.
*
* Defends against multi-extension polyglots (`evil.php.woff2`) by rejecting
* any URL whose basename contains a dangerous extension segment anywhere,
* not just at the end.
*
* @param string $url Absolute URL pointing at a font face source.
* @return bool True if the basename is safe and the final extension is in the font allowlist.
*/
public static function is_allowed_font_url( $url ) {
if ( ! is_string( $url ) || '' === $url ) {
return false;
}
$path = wp_parse_url( $url, PHP_URL_PATH );
$basename = strtolower( basename( (string) $path ) );

// Reject if ANY dot-separated segment is a dangerous extension.
// Mirrors the denylist in CBT_Theme_Media::is_allowed_media_url().
$dangerous = array(
'php',
'phtml',
'phar',
'php3',
'php4',
'php5',
'php7',
'php8',
'phps',
'html',
'htm',
'xhtml',
'htaccess',
'htpasswd',
'cgi',
'pl',
'py',
'rb',
'sh',
'asp',
'aspx',
'jsp',
'js',
'mjs',
);
foreach ( explode( '.', $basename ) as $segment ) {
if ( in_array( $segment, $dangerous, true ) ) {
return false;
}
}

$extension = pathinfo( $basename, PATHINFO_EXTENSION );
$allowed = array( 'ttf', 'otf', 'woff', 'woff2', 'eot' );
return in_array( $extension, $allowed, true );
}

/**
* Magic-byte verification of a downloaded font file.
*
* libmagic-based MIME detection (via finfo or wp_check_filetype_and_ext)
* is unreliable for font formats: WordPress Core has no font MIMEs in
* its registry, and PHP base images ship with varying libmagic versions
* — older builds return application/octet-stream for WOFF/WOFF2 rather
* than the modern font/woff(2) types. Direct magic-byte verification is
* version-independent and provides a stronger content guarantee.
*
* Recognised formats:
* - WOFF2: magic `wOF2` at offset 0
* - WOFF: magic `wOFF` at offset 0
* - OTF / OpenType (CFF): magic `OTTO` at offset 0
* - TTF: magic `\x00\x01\x00\x00` at offset 0 (or `true` for legacy Mac)
* - EOT: Version field (offset 8) is 0x00010000 / 0x00020001 / 0x00020002
*
* @param string $tmp_file Local path to the downloaded file.
* @param string $url The originating URL (kept for signature symmetry
* with CBT_Theme_Media::is_allowed_media_file()).
* @return bool True if the file's leading bytes match a known font signature.
*/
// phpcs:ignore VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
public static function is_allowed_font_file( $tmp_file, $url ) {
if ( ! is_string( $tmp_file ) || ! file_exists( $tmp_file ) ) {
return false;
}

// Read 12 bytes — covers magic-at-offset-0 formats (4 bytes) and the
// EOT Version field at offset 8 (4 bytes).
$fp = fopen( $tmp_file, 'rb' );
if ( false === $fp ) {
return false;
}
$head = fread( $fp, 12 );
fclose( $fp );

if ( false === $head || strlen( $head ) < 4 ) {
return false;
}

// Most font formats have a 4-byte magic at offset 0.
$first_four = substr( $head, 0, 4 );
$magic_signatures = array(
'wOF2', // WOFF2
'wOFF', // WOFF
'OTTO', // OpenType (CFF flavour)
"\x00\x01\x00\x00", // TrueType
'true', // Mac TrueType
);
if ( in_array( $first_four, $magic_signatures, true ) ) {
return true;
}

// EOT has no fixed magic at offset 0. Its Version field at offset 8
// takes one of three known values (little-endian uint32):
// 0x00010000, 0x00020001, 0x00020002.
if ( 12 === strlen( $head ) ) {
$version_le_bytes = array(
"\x00\x00\x01\x00", // 0x00010000
"\x01\x00\x02\x00", // 0x00020001
"\x02\x00\x02\x00", // 0x00020002
);
if ( in_array( substr( $head, 8, 4 ), $version_le_bytes, true ) ) {
return true;
}
}
Comment thread
mikachan marked this conversation as resolved.
Outdated

return false;
}

/*
* Copy the font assets to the theme.
*
Expand Down Expand Up @@ -142,6 +269,14 @@ public static function copy_font_assets_to_theme( $font_families ) {
// If the font source starts with 'file:' then it's already a theme asset.
continue;
}

// Pre-download URL extension allowlist — applies to both
// the local-copy and remote-download branches because the
// URL itself is the input we don't trust.
if ( ! self::is_allowed_font_url( $font_src ) ) {
continue;
}

$font_filename = basename( $font_src );
$font_pretty_filename = self::make_filename_from_fontface( $font_face, $font_src, $font_src_index );
$font_face_path = path_join( $font_family_dir_path, $font_pretty_filename );
Comment thread
Copilot marked this conversation as resolved.
Expand All @@ -152,6 +287,14 @@ public static function copy_font_assets_to_theme( $font_families ) {
} else {
// otherwise download it from wherever it is hosted
$tmp_file = download_url( $font_src );
if ( is_wp_error( $tmp_file ) ) {
continue;
}
// Post-download MIME allowlist.
if ( ! self::is_allowed_font_file( $tmp_file, $font_src ) ) {
@unlink( $tmp_file );
continue;
}
copy( $tmp_file, $font_face_path );
unlink( $tmp_file );
}
Expand Down
154 changes: 148 additions & 6 deletions includes/create-theme/theme-media.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,19 @@

class CBT_Theme_Media {

/**
* Map a media URL to its target folder under the theme.
*
* Note: as of the validation added in add_media_to_local(), the `else`
* branch (unknown extension → `/assets/`) is unreachable from the
* download path because `is_allowed_media_url()` rejects unknown
* extensions before this function is consulted. The branch remains
* here because `make_relative_media_url()` also calls this function
* to rewrite URLs of already-local media in exported templates.
*
* @param string $url Media URL.
* @return string Relative folder path starting with `/assets/`.
*/
public static function get_media_folder_path_from_url( $url ) {
$extension = strtolower( pathinfo( $url, PATHINFO_EXTENSION ) );
$folder_path = '';
Comment thread
Copilot marked this conversation as resolved.
Expand All @@ -19,6 +32,121 @@ public static function get_media_folder_path_from_url( $url ) {
return $folder_path;
}

/**
* Allowlist check on the URL's path extension before we attempt to download it.
*
* Defends against two bypass classes:
* 1. Query string disguise: strips the query before extracting the extension
* so `evil.php?disguised=cat.jpg` is correctly identified as `.php`.
* 2. Multi-extension polyglots: rejects URLs whose basename contains ANY
* dangerous extension segment (`evil.php.jpg` → rejected) regardless of
* the final extension — defends against historical Apache configs that
* execute any filename containing `.php` anywhere.
*
* @param string $url Absolute URL.
* @return bool True if the basename is safe and the final extension is in the media allowlist.
*/
public static function is_allowed_media_url( $url ) {
if ( ! is_string( $url ) || '' === $url ) {
return false;
}
$path = wp_parse_url( $url, PHP_URL_PATH );
$basename = strtolower( basename( (string) $path ) );

// Reject if ANY dot-separated segment of the basename is a dangerous
// extension. This blocks multi-extension polyglots like `evil.php.jpg`
// that execute on Apache hosts with `AddHandler ... .php`.
$dangerous = array(
'php',
'phtml',
'phar',
'php3',
'php4',
'php5',
'php7',
'php8',
'phps',
'html',
'htm',
'xhtml',
'htaccess',
'htpasswd',
'cgi',
'pl',
'py',
'rb',
'sh',
'asp',
'aspx',
'jsp',
'js',
'mjs',
);
foreach ( explode( '.', $basename ) as $segment ) {
if ( in_array( $segment, $dangerous, true ) ) {
return false;
}
}

$extension = pathinfo( $basename, PATHINFO_EXTENSION );
$allowed = array(
// images
'jpg',
'jpeg',
'png',
'gif',
'svg',
'webp',
// videos
'mp4',
'm4v',
'webm',
'ogv',
'wmv',
'avi',
'mov',
'mpg',
'3gp',
'3g2',
);
return in_array( $extension, $allowed, true );
}

/**
* Post-download MIME-type allowlist for downloaded media bodies.
*
* Uses wp_check_filetype_and_ext() to detect the real type of the bytes
* on disk so that, e.g., a `.jpg`-named file whose body is PHP source
* is rejected before we move it into the theme directory.
*
* @param string $tmp_file Local path to the downloaded file.
* @param string $url The originating URL (used to hint the basename).
* @return bool True if the file's detected type is in the allowlist.
*/
public static function is_allowed_media_file( $tmp_file, $url ) {
if ( ! is_string( $tmp_file ) || ! file_exists( $tmp_file ) ) {
return false;
}
$allowed = array(
Comment thread
scruffian marked this conversation as resolved.
Outdated
'image/jpeg',
'image/png',
'image/gif',
'image/svg+xml',
'image/webp',
'video/mp4',
'video/webm',
'video/ogg',
'video/x-msvideo',
'video/quicktime',
'video/mpeg',
'video/3gpp',
'video/3gpp2',
);
$check = wp_check_filetype_and_ext( $tmp_file, basename( (string) wp_parse_url( $url, PHP_URL_PATH ) ) );
$type = isset( $check['type'] ) ? $check['type'] : false;
return is_string( $type ) && in_array( $type, $allowed, true );
}

/**
* Get the absolute URLs of all media files for a template
*/
Expand Down Expand Up @@ -120,6 +248,11 @@ public static function add_media_to_local( $media ) {

foreach ( $media as $url ) {

// Pre-download URL extension allowlist — see is_allowed_media_url().
if ( ! self::is_allowed_media_url( $url ) ) {
continue;
}

$download_file = download_url( $url );

if ( is_wp_error( $download_file ) ) {
Expand All @@ -133,13 +266,22 @@ public static function add_media_to_local( $media ) {
}

// TODO: implement a warning if the file is missing
if ( ! is_wp_error( $download_file ) ) {
$media_path = get_stylesheet_directory() . DIRECTORY_SEPARATOR . self::get_media_folder_path_from_url( $url );
if ( ! is_dir( $media_path ) ) {
wp_mkdir_p( $media_path );
}
rename( $download_file, $media_path . basename( $url ) );
if ( is_wp_error( $download_file ) ) {
continue;
Comment thread
mikachan marked this conversation as resolved.
}

// Post-download MIME allowlist — defence-in-depth against
// content/extension mismatch.
if ( ! self::is_allowed_media_file( $download_file, $url ) ) {
@unlink( $download_file );
continue;
}

$media_path = get_stylesheet_directory() . DIRECTORY_SEPARATOR . self::get_media_folder_path_from_url( $url );
if ( ! is_dir( $media_path ) ) {
wp_mkdir_p( $media_path );
}
rename( $download_file, $media_path . basename( $url ) );
}
Comment thread
Copilot marked this conversation as resolved.

}
Expand Down
Loading
Loading