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

382 lines
14 KiB
PHP

<?php
require_once( __DIR__ . '/theme-media.php' );
require_once( __DIR__ . '/theme-patterns.php' );
class CBT_Theme_Templates {
/**
* Build a collection of templates and template-parts that should be exported (and modified)
* based on the given export_type.
*
* @param string $export_type The type of export to perform. 'all', 'current', or 'user'.
* @param array $templates_to_export List of specific templates to export.
* @return object An object containing the templates and parts that should be exported.
*/
public static function get_theme_templates( $export_type, $templates_to_export = null ) {
$templates = get_block_templates( array( 'slug__in' => $templates_to_export ) );
$template_parts = get_block_templates( array( 'slug__in' => $templates_to_export ), 'wp_template_part' );
$exported_templates = array();
$exported_parts = array();
// build collection of templates/parts in currently activated theme
$templates_paths = get_block_theme_folders();
$templates_path = get_stylesheet_directory() . '/' . $templates_paths['wp_template'] . '/';
$parts_path = get_stylesheet_directory() . '/' . $templates_paths['wp_template_part'] . '/';
foreach ( $templates as $template ) {
if ( self::should_include_template(
$template,
$export_type,
$templates_path
) ) {
$exported_templates[] = self::cleanup_template( $template );
}
}
foreach ( $template_parts as $template ) {
if ( self::should_include_template(
$template,
$export_type,
$parts_path
) ) {
$exported_parts[] = self::cleanup_template( $template );
}
}
return (object) array(
'templates' => $exported_templates,
'parts' => $exported_parts,
);
}
/**
* Filter a template out (return false) based on the export_type expected and the templates origin.
*
* @param object $template The template to filter.
* @param string $export_type The type of export to perform. 'all', 'current', or 'user'.
* @param string $path The path to the templates folder.
* @return object|bool The template if it should be included, or false if it should be excluded.
*/
static function should_include_template( $template, $export_type, $path ) {
if ( 'theme' === $template->source && 'user' === $export_type ) {
return false;
}
if (
'theme' === $template->source &&
'current' === $export_type &&
! file_exists( $path . $template->slug . '.html' )
) {
return false;
}
return true;
}
/**
* Clean up the template content before exporting.
* @param object $template The template to clean up.
* @return object The cleaned up template.
*/
private static function cleanup_template( $template ) {
// NOTE: Dashes are encoded as \u002d in the content that we get (noteably in things like css variables used in templates)
// This replaces that with dashes again. We should consider decoding the entire string but that is proving difficult.
$template->content = str_replace( '\u002d', '-', $template->content );
return $template;
}
/**
* Replace the old theme slug with the new theme slug in the template content.
*
* @param object $template The template to replace the namespace in.
* @param string $new_slug The new theme slug.
* @return object The template with the namespace replaced.
*/
public static function replace_template_namespace( $template, $new_slug ) {
$old_slug = wp_get_theme()->get( 'TextDomain' );
if ( $new_slug ) {
$template->content = str_replace( $old_slug, $new_slug, $template->content );
}
return $template;
}
/**
* Clear all user templates customizations.
* This will remove all user templates from the database.
*/
public static function clear_user_templates_customizations() {
$templates = get_block_templates();
foreach ( $templates as $template ) {
if ( 'custom' !== $template->source ) {
continue;
}
wp_delete_post( $template->wp_id, true );
}
}
/**
* Clear all user template-parts customizations.
* This will remove all user template-parts from the database.
*/
public static function clear_user_template_parts_customizations() {
$template_parts = get_block_templates( array(), 'wp_template_part' );
foreach ( $template_parts as $template ) {
if ( 'custom' !== $template->source ) {
continue;
}
wp_delete_post( $template->wp_id, true );
}
}
/**
* Extract content from a template that need to be patternized.
* Return the modified template and the pattern that was created
*
* @param object $template The template to extract content from.
* @return object The template with the patternized content.
*/
public static function paternize_template( $template, $slug = null ) {
// If there is any PHP in the template then paternize
if ( str_contains( $template->content, '<?php' ) ) {
$pattern = CBT_Theme_Patterns::pattern_from_template( $template, $slug );
$pattern_link_attributes = array(
'slug' => $pattern['slug'],
);
$template->content = CBT_Theme_Patterns::create_pattern_link( $pattern_link_attributes );
$template->pattern = $pattern['content'];
}
return $template;
}
/**
* Prepare a template for export.
* This will escape text in the template, eliminate environment specific content,
* make template images local, and paternize the template.
*
* @param object $template The template to prepare for export.
* @param string $slug The slug of the theme.
* @return object The prepared template.
*/
public static function prepare_template_for_export( $template, $slug = null, $options = null ) {
if ( ! $options ) {
$options = array(
'localizeText' => false,
'removeNavRefs' => true,
'localizeImages' => true,
);
}
// Strip PHP tags from the raw template body BEFORE any
// trusted-PHP injection (escape_text_in_template below).
// Sanitising here preserves the legitimate localization output.
$template->content = CBT_Theme_Patterns::strip_php_tags( $template->content );
$template = self::eliminate_environment_specific_content( $template, $options );
if ( array_key_exists( 'localizeText', $options ) && $options['localizeText'] ) {
$template = self::escape_text_in_template( $template );
}
if ( array_key_exists( 'localizeImages', $options ) && $options['localizeImages'] ) {
$validated_media = array_key_exists( 'validatedMedia', $options ) ? $options['validatedMedia'] : null;
$template = CBT_Theme_Media::make_template_images_local( $template, $validated_media );
}
if ( $slug ) {
$template = self::replace_template_namespace( $template, $slug );
}
$template = self::paternize_template( $template, $slug );
return $template;
}
private static function should_localize_images( $options ) {
return ! $options || ( array_key_exists( 'localizeImages', $options ) && $options['localizeImages'] );
}
private static function add_validated_media_to_options( $options, $validated_media ) {
if ( ! $options ) {
$options = array(
'localizeText' => false,
'removeNavRefs' => true,
'localizeImages' => true,
);
}
$options['validatedMedia'] = $validated_media;
return $options;
}
/**
* Copy the templates and template-parts (including user customizations)
* as well as any media to the theme filesystem.
* If patterns need to be created for media or localizations they will also be added.
*
* @param string $export_type The type of export to perform. 'all', 'current', or 'user'.
* @param string $path The path to the theme folder. If null it is assumed to be the current theme.
* @param string $slug The slug of the theme. If null it is assumed to be the current theme.
* @param array $options An array of options to use when exporting the templates.
* @param array $templates_to_export List of specific templates to export. If null it will be fetched.
*/
public static function add_templates_to_local( $export_type, $path = null, $slug = null, $options = null, $templates_to_export = null ) {
$theme_templates = self::get_theme_templates( $export_type, $templates_to_export );
$template_folders = get_block_theme_folders();
$base_dir = $path ? $path : get_stylesheet_directory();
$template_dir = $base_dir . DIRECTORY_SEPARATOR . $template_folders['wp_template'];
$template_part_dir = $base_dir . DIRECTORY_SEPARATOR . $template_folders['wp_template_part'];
$patterns_dir = $base_dir . DIRECTORY_SEPARATOR . 'patterns';
// If there is no templates folder, and it is needed, create it.
if ( ! is_dir( $template_dir ) && count( $theme_templates->templates ) > 0 ) {
wp_mkdir_p( $template_dir );
}
// If there is no parts folder, and it is needed, create it.
if ( ! is_dir( $template_part_dir ) && count( $theme_templates->parts ) > 0 ) {
wp_mkdir_p( $template_part_dir );
}
foreach ( $theme_templates->templates as $template ) {
$template_options = $options;
if ( self::should_localize_images( $template_options ) ) {
$template->media = CBT_Theme_Media::get_media_absolute_urls_from_template( $template );
$validated_media = ! empty( $template->media ) ? CBT_Theme_Media::add_media_to_local( $template->media ) : array();
$template_options = self::add_validated_media_to_options( $template_options, $validated_media );
}
$template = self::prepare_template_for_export( $template, $slug, $template_options );
// Write the template content
file_put_contents(
$template_dir . DIRECTORY_SEPARATOR . $template->slug . '.html',
$template->content
);
// Write the pattern if it exists
if ( isset( $template->pattern ) ) {
// If there is no patterns folder, create it.
if ( ! is_dir( $patterns_dir ) ) {
wp_mkdir_p( $patterns_dir );
}
file_put_contents(
$patterns_dir . DIRECTORY_SEPARATOR . $template->slug . '.php',
$template->pattern
);
}
}
foreach ( $theme_templates->parts as $template ) {
$template_options = $options;
if ( self::should_localize_images( $template_options ) ) {
$template->media = CBT_Theme_Media::get_media_absolute_urls_from_template( $template );
$validated_media = ! empty( $template->media ) ? CBT_Theme_Media::add_media_to_local( $template->media ) : array();
$template_options = self::add_validated_media_to_options( $template_options, $validated_media );
}
$template = self::prepare_template_for_export( $template, $slug, $template_options );
// Write the template content
file_put_contents(
$template_part_dir . DIRECTORY_SEPARATOR . $template->slug . '.html',
$template->content
);
// Write the pattern if it exists
if ( isset( $template->pattern ) ) {
// If there is no patterns folder, create it.
if ( ! is_dir( $patterns_dir ) ) {
wp_mkdir_p( $patterns_dir );
}
file_put_contents(
$patterns_dir . DIRECTORY_SEPARATOR . $template->slug . '.php',
$template->pattern
);
}
}
}
/**
* Escape text in template content.
*
* @param object $template The template to escape text content in.
* @return object The template with the content escaped.
*/
public static function escape_text_in_template( $template ) {
$template_blocks = parse_blocks( $template->content );
$localized_blocks = CBT_Theme_Locale::escape_text_content_of_blocks( $template_blocks );
$updated_template_content = serialize_blocks( $localized_blocks );
// Process block attributes after serialization to avoid JSON encoding of PHP tags
$updated_template_content = CBT_Theme_Locale::escape_block_attribute_strings( $updated_template_content );
$template->content = $updated_template_content;
return $template;
}
private static function eliminate_environment_specific_content_from_block( $block, $options = null ) {
// remove theme attribute from template parts
if ( 'core/template-part' === $block['blockName'] && isset( $block['attrs']['theme'] ) ) {
unset( $block['attrs']['theme'] );
}
// (optionally) remove ref attribute from nav blocks
if ( 'core/navigation' === $block['blockName'] && isset( $block['attrs']['ref'] ) ) {
if ( ! $options || ( array_key_exists( 'removeNavRefs', $options ) && $options['removeNavRefs'] ) ) {
unset( $block['attrs']['ref'] );
}
}
// remove id attributes and classes from image and cover blocks
if ( in_array( $block['blockName'], array( 'core/image', 'core/cover' ), true ) ) {
// remove id attribute from image and cover blocks
if ( isset( $block['attrs']['id'] ) ) {
$image_id = $block['attrs']['id'];
unset( $block['attrs']['id'] );
// remove wp-image-[id] class from inner content
foreach ( $block['innerContent'] as $inner_key => $inner_content ) {
if ( is_null( $inner_content ) ) {
continue;
}
$block['innerContent'][ $inner_key ] = str_replace( 'wp-image-' . $image_id, '', $inner_content );
}
}
}
// (optionally) remove taxQuery attribute from query blocks
if ( 'core/query' === $block['blockName'] && isset( $block['attrs']['query']['taxQuery'] ) ) {
if ( ! $options || ( array_key_exists( 'removeTaxQuery', $options ) && $options['removeTaxQuery'] ) ) {
unset( $block['attrs']['query']['taxQuery'] );
}
}
// process any inner blocks
if ( ! empty( $block['innerBlocks'] ) ) {
foreach ( $block['innerBlocks'] as $inner_block_key => $inner_block ) {
$block['innerBlocks'][ $inner_block_key ] = static::eliminate_environment_specific_content_from_block( $inner_block, $options );
}
}
return $block;
}
public static function eliminate_environment_specific_content( $template, $options = null ) {
$template_blocks = parse_blocks( $template->content );
$parsed_content = '';
foreach ( $template_blocks as $block ) {
$parsed_block = static::eliminate_environment_specific_content_from_block( $block, $options );
$parsed_content .= serialize_block( $parsed_block );
}
$template->content = $parsed_content;
return $template;
}
}