create-block-theme/includes/create-theme/theme-patterns.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

253 lines
9.4 KiB
PHP

<?php
class CBT_Theme_Patterns {
/**
* Strip PHP execution tags from user-supplied pattern body content.
*
* Block patterns are HTML/block markup, not PHP. Any `<?php` (or short/
* legacy variants) in user content is treated as malicious and removed
* before the body is interpolated into the exported `.php` pattern file.
*
* This helper is `public static` because it is invoked from two pipelines:
* 1. `pattern_from_wp_block()` in this class (wp_block patterns), where
* sanitisation happens BEFORE `prepare_pattern_for_export()` injects
* trusted `<?php esc_*_e(...);?>` markers.
* 2. `CBT_Theme_Templates::prepare_template_for_export()` (templates and
* template parts), where sanitisation must happen at the very start —
* BEFORE `escape_text_in_template()` injects the same trusted markers.
*
* In both cases the rule is: sanitise first, inject trusted PHP second,
* build the heredoc third. Calling this AFTER the trusted-PHP injection
* would strip the plugin's own localization helpers and break the
* "Make text translation-ready" feature.
*
* @param mixed $content User-supplied body content.
* @return mixed Same content with PHP open tags removed (when input is a non-empty string).
*/
public static function strip_php_tags( $content ) {
if ( ! is_string( $content ) || '' === $content ) {
return $content;
}
// Strip ANY `<?` open tag. On hosts with `short_open_tag=1`, PHP parses
// `<?` followed by `$`, `(`, `"`, `//`, `/*`, `;`, or `xml` as an open
// tag — preserving any of them would either re-execute as PHP or
// produce a fatal parse error when the exported `.php` file is loaded.
// Block patterns are HTML/block markup, so there's no legitimate
// `<?xml` content to preserve.
$content = preg_replace( '/<\?/', '', $content );
// Strip legacy `<script language="php">…</script>` blocks. PHP 7+
// removed this parser, but custom SAPIs / polyfills could still
// honour it. Match the entire block (opening tag → closing tag,
// inclusive of inner content).
$content = preg_replace( '#<script\s+language\s*=\s*["\']?php["\']?[^>]*>.*?</script>#is', '', $content );
return $content;
}
/**
* Build a pattern .php file from a template stdClass.
*
* IMPORTANT: this function expects `$template->content` to be already
* sanitised by the caller. The pipeline entry point is
* `CBT_Theme_Templates::prepare_template_for_export`, which strips PHP
* tags from `$template->content` BEFORE the trusted-PHP injection done
* by `escape_text_in_template`. Calling `pattern_from_template` with
* un-sanitised user content would re-introduce the PHP injection bug.
*/
public static function pattern_from_template( $template, $new_slug = null ) {
$theme_slug = $new_slug ? $new_slug : wp_get_theme()->get( 'TextDomain' );
$template_slug = str_replace( '*/', '*&#47;', $template->slug );
$pattern_slug = $theme_slug . '/' . $template_slug;
$pattern_content = <<<PHP
<?php
/**
* Title: {$template_slug}
* Slug: {$pattern_slug}
* Inserter: no
*/
?>
{$template->content}
PHP;
return array(
'slug' => $pattern_slug,
'content' => $pattern_content,
);
}
public static function pattern_from_wp_block( $pattern_post ) {
$pattern = new stdClass();
$pattern->id = $pattern_post->ID;
$pattern->title = $pattern_post->post_title;
$pattern->name = sanitize_title_with_dashes( $pattern_post->post_title );
$pattern->slug = wp_get_theme()->get( 'TextDomain' ) . '/' . $pattern->name;
$pattern_category_list = get_the_terms( $pattern->id, 'wp_pattern_category' );
$pattern->categories = ! empty( $pattern_category_list ) ? join( ', ', wp_list_pluck( $pattern_category_list, 'name' ) ) : '';
$pattern_title = str_replace( '*/', '*&#47;', $pattern->title );
$pattern_categories = str_replace( '*/', '*&#47;', $pattern->categories );
$safe_body = self::strip_php_tags( $pattern_post->post_content );
$pattern->content = <<<PHP
<?php
/**
* Title: {$pattern_title}
* Slug: {$pattern->slug}
* Categories: {$pattern_categories}
*/
?>
{$safe_body}
PHP;
return $pattern;
}
public static function escape_alt_for_pattern( $html ) {
if ( empty( $html ) ) {
return $html;
}
$html = new WP_HTML_Tag_Processor( $html );
while ( $html->next_tag( 'img' ) ) {
$alt_attribute = $html->get_attribute( 'alt' );
if ( ! empty( $alt_attribute ) ) {
$html->set_attribute( 'alt', self::escape_text_for_pattern( $alt_attribute ) );
}
}
return $html->__toString();
}
public static function escape_text_for_pattern( $text ) {
if ( $text && trim( $text ) !== '' ) {
$escaped_text = addslashes( $text );
return "<?php esc_attr_e('" . $escaped_text . "', '" . wp_get_theme()->get( 'Name' ) . "');?>";
}
}
public static function create_pattern_link( $attributes ) {
$block_attributes = array_filter( $attributes );
$attributes_json = json_encode( $block_attributes, JSON_UNESCAPED_SLASHES );
return '<!-- wp:pattern ' . $attributes_json . ' /-->';
}
public static function replace_local_pattern_references( $pattern, $options = null ) {
// Find any references to pattern in templates
$templates_to_update = array();
$args = array(
'post_type' => array( 'wp_template', 'wp_template_part' ),
'posts_per_page' => -1,
's' => 'wp:block {"ref":' . $pattern->id . '}',
);
$find_pattern_refs = new WP_Query( $args );
if ( $find_pattern_refs->have_posts() ) {
foreach ( $find_pattern_refs->posts as $post ) {
$slug = $post->post_name;
array_push( $templates_to_update, $slug );
}
}
$templates_to_update = array_unique( $templates_to_update );
// Only update templates that reference the pattern
CBT_Theme_Templates::add_templates_to_local( 'all', null, null, $options, $templates_to_update );
// List all template and pattern files in the theme
$base_dir = get_stylesheet_directory();
$patterns = glob( $base_dir . DIRECTORY_SEPARATOR . 'patterns' . DIRECTORY_SEPARATOR . '*.php' );
$templates = glob( $base_dir . DIRECTORY_SEPARATOR . 'templates' . DIRECTORY_SEPARATOR . '*.html' );
$template_parts = glob( $base_dir . DIRECTORY_SEPARATOR . 'template-parts' . DIRECTORY_SEPARATOR . '*.html' );
// Replace references to the local patterns in the theme
foreach ( array_merge( $patterns, $templates, $template_parts ) as $file ) {
$file_content = file_get_contents( $file );
$file_content = str_replace( 'wp:block {"ref":' . $pattern->id . '}', 'wp:pattern {"slug":"' . $pattern->slug . '"}', $file_content );
file_put_contents( $file, $file_content );
}
CBT_Theme_Templates::clear_user_templates_customizations();
CBT_Theme_Templates::clear_user_template_parts_customizations();
}
public static function prepare_pattern_for_export( $pattern, $options = null ) {
if ( ! $options ) {
$options = array(
'localizeText' => false,
'removeNavRefs' => true,
'localizeImages' => true,
);
}
$pattern = CBT_Theme_Templates::eliminate_environment_specific_content( $pattern, $options );
if ( array_key_exists( 'localizeText', $options ) && $options['localizeText'] ) {
$pattern = CBT_Theme_Templates::escape_text_in_template( $pattern );
}
if ( array_key_exists( 'localizeImages', $options ) && $options['localizeImages'] ) {
$pattern->media = CBT_Theme_Media::get_media_absolute_urls_from_template( $pattern );
$validated_media = ! empty( $pattern->media ) ? CBT_Theme_Media::add_media_to_local( $pattern->media ) : array();
$pattern = CBT_Theme_Media::make_template_images_local( $pattern, $validated_media );
}
return $pattern;
}
/**
* Copy the local patterns as well as any media to the theme filesystem.
*/
public static function add_patterns_to_theme( $options = null ) {
$base_dir = get_stylesheet_directory();
$patterns_dir = $base_dir . DIRECTORY_SEPARATOR . 'patterns';
$pattern_query = new WP_Query(
array(
'post_type' => 'wp_block',
'posts_per_page' => -1,
)
);
if ( $pattern_query->have_posts() ) {
// If there is no patterns folder, create it.
if ( ! is_dir( $patterns_dir ) ) {
wp_mkdir_p( $patterns_dir );
}
foreach ( $pattern_query->posts as $pattern ) {
$pattern = self::pattern_from_wp_block( $pattern );
$pattern = self::prepare_pattern_for_export( $pattern, $options );
$pattern_exists = false;
// Check pattern name doesn't already exist before creating the file.
$existing_patterns = glob( $patterns_dir . DIRECTORY_SEPARATOR . '*.php' );
foreach ( $existing_patterns as $existing_pattern ) {
if ( strpos( $existing_pattern, $pattern->name . '.php' ) !== false ) {
$pattern_exists = true;
}
}
if ( $pattern_exists ) {
return new WP_Error(
'pattern_already_exists',
sprintf(
/* Translators: Pattern name. */
__(
'A pattern with this name already exists: "%s".',
'create-block-theme'
),
$pattern->name
)
);
}
// Create the pattern file.
file_put_contents(
$patterns_dir . DIRECTORY_SEPARATOR . $pattern->name . '.php',
$pattern->content
);
self::replace_local_pattern_references( $pattern, $options );
// Remove it from the database to ensure that these patterns are loaded from the theme.
wp_delete_post( $pattern->id, true );
}
}
}
}