mirror of
https://github.com/WordPress/create-block-theme.git
synced 2026-07-27 21:48:03 +08:00
* tests: add tiny.png and evil.php.txt fixtures for media validation * docs(agents): update test:php commands to test:unit:php * Add CBT_Theme_Media::is_allowed_media_url() extension allowlist * Add CBT_Theme_Media::is_allowed_media_file() MIME allowlist * Apply media URL + file allowlist in add_media_to_local * Add CBT_Theme_Fonts::is_allowed_font_url() extension allowlist * Add CBT_Theme_Fonts::is_allowed_font_file() MIME allowlist * Apply font URL + file allowlist in copy_font_assets_to_theme * Allow font/sfnt and application/vnd.ms-opentype for TTF/OTF MIME detection * Reject multi-extension polyglots in URL allowlist * Apply media+font allowlist to zip export sinks in theme-zip * Add positive end-to-end tests for media and font sinks * Verify downloaded fonts by magic bytes instead of libmagic MIME * Pass $font_src string to download_url instead of $font_face src array Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Stream downloaded media into zip and clean up tmp file Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Strip query string before deriving folder path from media URL Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Restore braces and is_wp_error check dropped by Copilot suggestions Two recent Copilot suggestion commits accidentally removed structural code while making single-line changes: -c045da7("Pass $font_src to download_url") dropped the `} else {` and the `is_wp_error( $tmp_file )` guard. -617f6ec("Stream downloaded media into zip") dropped the closing brace of the `foreach` in add_media_to_zip(). Both broke PHP parse on every CI matrix. Restoring the missing statements so the file is valid again. * lint: align assignment operators in get_media_folder_path_from_url * Strip query string from filename before renaming downloaded media Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Strip query string from font URL before deriving filename Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Strip query string from font URL before deriving filename in zip Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Restore loop brace and sanitize_title dropped by Copilot suggestions Two more Copilot suggestion commits dropped surrounding context lines: -ad31fe4("Strip query string from filename before renaming downloaded media") removed the closing brace of the foreach loop in add_media_to_local(), causing fatal lint errors. -860f1bc("Strip query string from font URL before deriving filename in zip") removed the `$font_family_dir_name = sanitize_title(...)` assignment, leaving an undefined variable used immediately below. * Remove dead pre-loop assignments in add_activated_fonts_to_zip `$font_filename = basename( $font_face['src'] )` threw TypeError on PHP 8+ when src was already an array (valid per Theme JSON spec) and was redefined inside the inner src loop anyway. The adjacent `$font_dir = wp_get_font_dir()` was likewise redundant — also redefined inside the inner loop. Removing both. * Verify downloaded media by magic bytes instead of libmagic MIME wp_check_filetype_and_ext() was inconsistent with the URL allowlist: SVG isn't in WP Core's default mime registry at all, and WP maps wmv to video/x-ms-wmv / avi to video/avi while the post-check allowlist had only video/x-msvideo. Legitimate SVG/WMV/AVI URLs passed preflight and then silently failed post-download. Switching media to magic-byte verification (matching the font sink) removes the dependency on WP Core's mime registry and libmagic versions. Recognised formats: JPEG, PNG, GIF, WebP, SVG, MP4/M4V/MOV/ 3GP/3G2 (via ftyp), WebM, OGV, WMV (ASF), AVI (RIFF), MPEG. * Add regression tests for media asset validation * Fix media validation bugs found by scruffian Three fixes for issues found via regression tests in8da66ff: 1. is_allowed_media_file() now requires the downloaded bytes to match the URL extension specifically — a .jpg URL with SVG/MP4/anything-else bytes is rejected. Previously any allowed magic was accepted, so a browser MIME-sniffing the on-disk .jpg containing SVG could render it as SVG (with all the script-execution surface that brings). 2. make_relative_media_url() now derives the filename from the parsed URL path instead of raw basename(). A URL like cat.jpg?/evil.php previously produced `/assets/images/evil.php` in exported template markup because basename() treats query-string slashes as path separators. 3. add_media_to_zip() reads the downloaded bytes into memory and unlinks the tmp file BEFORE adding to the archive via addFromStringToTheme(). The previous `addFileToTheme() + unlink` sequence broke because ZipArchive defers reading files added via addFile() until close() — by which time the tmp file was gone. * lint: suppress NoSilencedErrors warning on ZipArchive::close in test * Skip zip tests gracefully when ZipArchive extension is unavailable Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Guard localhost retry against missing host/port keys parse_url() omits the 'port' key when the URL has no explicit port (e.g. `http://localhost/foo`). The retry-on-localhost block in both add_media_to_zip() and add_media_to_local() read $parsed_url['port'] unconditionally, raising an undefined-index notice when the initial download failed for such a URL. Guard with isset() on both keys. * Match font magic bytes against URL extension specifically Previously any recognised font magic was accepted, so a .woff2 URL returning TTF/WOFF/OTF/EOT bytes would pass and be saved under the .woff2 filename. This defeated the extension+MIME allowlist intent and would produce broken assets in the exported theme/zip (browsers would fail to load a WOFF2-named file containing TTF content). The check now switches on the URL extension and validates the magic matches THAT specific format. Mirrors the same fix already applied to is_allowed_media_file(). * Align .mpeg handling across URL, folder, and file-content checks is_allowed_media_file() accepted both .mpg and .mpeg, but is_allowed_media_url() and get_media_folder_path_from_url() only listed .mpg. A template with a .mpeg video would get rewritten to a local asset URL, then skipped before download — leaving the exported theme pointing at a missing file. Add .mpeg to the URL allowlist and the folder mapping so all three lists agree (matching WP Core's wp_get_mime_types which maps mpeg|mpg|mpe to video/mpeg). Also drops a pre-existing duplicate 'ogv' entry in the folder mapping that was noticed while editing. * Stream font bytes into zip before unlinking tmp file Same fix as add_media_to_zip: ZipArchive::addFile() defers reading the file until close() is called, so unlinking the download_url tmp file immediately after addFileToTheme() left an empty entry in the final archive when the zip was finalised. Read the bytes into memory, unlink, then addFromStringToTheme() — mirrors the pattern in add_media_to_zip(). Only the remote-download branch is affected; the local-copy branch points at a permanent file in the font library so its addFileToTheme() call stays. * Tweak comment * Allow AVIF images alongside JPEG/PNG/GIF/WebP/SVG AVIF is a WordPress-supported image format but was missing from the URL allowlist, folder mapping, and magic-byte check. The result: make_template_images_local() rewrote .avif URLs to local references, but the post-rewrite download was rejected — leaving exported themes pointing at missing assets. AVIF uses the ISO BMFF container (like MP4) with 'avif' or 'avis' as the major brand at offset 8. Added the brand-specific magic check alongside the URL/folder allowlist entries, plus tests for both AVIF still-image ('avif') and image-sequence ('avis') brands. * Drop rejected font sources from exported families Previously a font source that failed the URL allowlist or post-download MIME check was skipped via continue, but the original $font_src stayed in $font_face['src']. copy_activated_fonts_to_theme() then merged that family into theme.json — leaving the user's activated font pointing at an uncopied (and possibly disallowed) source while the user custom settings were cleared. Both copy_font_assets_to_theme() and add_activated_fonts_to_zip() now build a fresh srcs list and only retain entries that successfully copied or were already file:./ asset paths. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Only rewrite media URLs after successful asset validation * Accept AVIF compatible-brands in addition to the major brand Some AVIF files (typically those emitted by HEIF-derived tooling) set the major brand to mif1/miaf and only list avif/avis in the compatible brands. Parsing only the major brand at offset 8 would false-reject them. Scan the full FileTypeBox brand list (major brand plus compatible brands at offsets 16+) for any AVIF brand. HEIC files (no avif/avis anywhere) are still rejected. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Magic-byte check the local-copy font branch too Both copy_font_assets_to_theme() and add_activated_fonts_to_zip() treated a font src under the WP user-fonts directory as trusted — copying / zipping the bytes without ever inspecting them. Anything that ended up in that directory through a separate flow (a polyglot upload via another plugin, manual write, etc.) would be promoted to the exported theme as-is just because the URL extension was on the allowlist. Run the same is_allowed_font_file() check on the local source before copying / zipping. If the bytes don't match the URL extension's font format, drop the source from the returned families. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Localize media URLs via the block parser, not str_replace Plain str_replace on the whole template would substring-match a shorter validated URL inside a longer rejected URL when one is a prefix of the other (e.g. `photo.png` inside `photo.png.php`). That silently rewrote the rejected URL to a missing local asset and bypassed the validated-media guard. Walk the parsed block tree instead: WP_HTML_Tag_Processor handles img / video src+poster and inline `background-image:url(...)`, while direct array access handles block-comment JSON attrs. Replacements only touch values that exactly match a validated URL. The rewrites embed literal PHP open/close tags that must end up verbatim in the export, but both set_attribute() and wp_json_encode() (inside serialize_blocks) would escape `<` / `>` / quotes. Route the rewrites through opaque, URL-shaped placeholders so they survive both passes, then strtr() them back to the raw PHP at the end. The placeholder is a root-relative path because set_attribute prefixes schemeless values with `http://`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Ben Dwyer <ben@scruffian.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
253 lines
9.4 KiB
PHP
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( '*/', '*/', $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( '*/', '*/', $pattern->title );
|
|
$pattern_categories = str_replace( '*/', '*/', $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 );
|
|
}
|
|
}
|
|
}
|
|
}
|