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

339 lines
11 KiB
PHP

<?php
require_once( __DIR__ . '/theme-media.php' );
require_once( __DIR__ . '/theme-templates.php' );
require_once( __DIR__ . '/theme-patterns.php' );
require_once( __DIR__ . '/cbt-zip-archive.php' );
class CBT_Theme_Zip {
public static function create_zip( $filename, $theme_slug = null ) {
if ( ! class_exists( 'ZipArchive' ) ) {
return new WP_Error( 'Zip Export not supported.' );
}
if ( ! $theme_slug ) {
$theme_slug = get_stylesheet();
}
$zip = new CBT_Zip_Archive( $theme_slug );
$zip->open( $filename, ZipArchive::CREATE | ZipArchive::OVERWRITE );
return $zip;
}
public static function add_theme_json_to_zip( $zip, $theme_json ) {
$zip->addFromStringToTheme(
'theme.json',
$theme_json
);
return $zip;
}
public static function add_activated_fonts_to_zip( $zip, $theme_json_string ) {
$theme_json = json_decode( $theme_json_string, true );
$font_families_to_copy = CBT_Theme_Fonts::get_user_activated_fonts();
$theme_font_asset_location = 'assets/fonts';
$font_slugs_to_remove = array();
if ( ! $font_families_to_copy ) {
return $theme_json_string;
}
foreach ( $font_families_to_copy as &$font_family ) {
if ( ! isset( $font_family['fontFace'] ) ) {
continue;
}
$font_slugs_to_remove[] = $font_family['slug'];
foreach ( $font_family['fontFace'] as &$font_face ) {
$font_face['src'] = (array) $font_face['src'];
// Build a fresh srcs list so rejected sources (disallowed URL,
// failed download, MIME mismatch) are dropped rather than
// persisted into the exported theme.json.
$kept_srcs = array();
foreach ( $font_face['src'] as $font_src_index => $font_src ) {
if ( is_string( $font_src ) && str_starts_with( $font_src, 'file:' ) ) {
// Already a theme asset — keep as-is.
$kept_srcs[] = $font_src;
continue;
}
// Pre-download URL extension allowlist — see CBT_Theme_Fonts::is_allowed_font_url().
if ( ! CBT_Theme_Fonts::is_allowed_font_url( $font_src ) ) {
continue;
}
$font_src_path = (string) wp_parse_url( $font_src, PHP_URL_PATH );
$font_filename = basename( $font_src_path );
$font_pretty_filename = CBT_Theme_Fonts::make_filename_from_fontface( $font_face, $font_src_path, $font_src_index );
$font_family_dir_name = sanitize_title( $font_family['name'] );
$font_family_dir_path = path_join( $theme_font_asset_location, $font_family_dir_name );
$font_face_path = path_join( $font_family_dir_path, $font_pretty_filename );
$font_dir = wp_get_font_dir();
if ( str_contains( $font_src, $font_dir['url'] ) ) {
$local_source = path_join( $font_dir['path'], $font_filename );
// Magic-byte allowlist on the local source too — the
// WP user-fonts directory is the input we don't trust:
// anything that ended up there (via a separate flow,
// or a polyglot) shouldn't be zipped verbatim into
// the exported theme just because the URL extension
// looked OK.
if ( ! CBT_Theme_Fonts::is_allowed_font_file( $local_source, $font_src ) ) {
continue;
}
$zip->addFileToTheme( $local_source, $font_face_path );
} 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 ( ! CBT_Theme_Fonts::is_allowed_font_file( $tmp_file, $font_src ) ) {
@unlink( $tmp_file );
continue;
}
// Read the bytes into memory before unlinking.
$bytes = file_get_contents( $tmp_file );
@unlink( $tmp_file );
if ( false === $bytes ) {
continue;
}
$zip->addFromStringToTheme( $font_face_path, $bytes );
}
$kept_srcs[] = 'file:./assets/fonts/' . path_join( $font_family_dir_name, $font_pretty_filename );
}
$font_face['src'] = $kept_srcs;
}
}
if ( ! isset( $theme_json['settings']['typography']['fontFamilies'] ) ) {
$theme_json['settings']['typography']['fontFamilies'] = array();
}
// Remove user fonts that have already been added to the theme_json
// otherwise they will be duplicated when we add them next
foreach ( $theme_json['settings']['typography']['fontFamilies'] as $key => $theme_font_family ) {
if ( in_array( $theme_font_family['slug'], $font_slugs_to_remove, true ) ) {
unset( $theme_json['settings']['typography']['fontFamilies'][ $key ] );
}
}
// Copy user fonts to theme
$theme_json['settings']['typography']['fontFamilies'] = array_merge(
$theme_json['settings']['typography']['fontFamilies'],
$font_families_to_copy
);
return wp_json_encode( $theme_json, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
}
public static function copy_theme_to_zip( $zip, $new_slug, $new_name ) {
$theme = wp_get_theme();
$old_slug = $theme->get( 'TextDomain' );
$old_name = $theme->get( 'Name' );
// Get real path for our folder
$theme_path = get_stylesheet_directory();
// Create recursive directory iterator
/** @var SplFileInfo[] $files */
$files = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator( $theme_path ),
RecursiveIteratorIterator::LEAVES_ONLY
);
// Add all the files (except for templates)
foreach ( $files as $file ) {
// Skip directories (they would be added automatically)
if ( ! $file->isDir() ) {
// Get real and relative path for current file
$file_path = wp_normalize_path( $file );
// If the path is for templates/parts ignore it
if (
strpos( $file_path, 'block-template-parts/' ) ||
strpos( $file_path, 'block-templates/' ) ||
strpos( $file_path, 'templates/' ) ||
strpos( $file_path, 'parts/' )
) {
continue;
}
$relative_path = substr( $file_path, strlen( $theme_path ) + 1 );
// Replace only text files, skip png's and other stuff.
$valid_extensions = array( 'php', 'css', 'scss', 'js', 'txt', 'html' );
$valid_extensions_regex = implode( '|', $valid_extensions );
if ( ! preg_match( "/\.({$valid_extensions_regex})$/", $relative_path ) ) {
$zip->addFileToTheme( $file_path, $relative_path );
} else {
$contents = file_get_contents( $file_path );
// Replace namespace values if provided
if ( $new_slug ) {
$contents = CBT_Theme_Utils::replace_namespace( $contents, $old_slug, $new_slug, $old_name, $new_name );
}
// Add current file to archive
$zip->addFromStringToTheme( $relative_path, $contents );
}
}
}
return $zip;
}
/**
* Add block templates and parts to the zip.
*
* @since 0.0.2
* @param object $zip The zip archive to add the templates to.
* @param string $export_type Determine the templates that should be exported.
* current = templates from currently activated theme (but not a parent theme if there is one) as well as user edited templates
* user = only user edited templates
* all = all templates no matter what
*/
public static function add_templates_to_zip( $zip, $export_type ) {
$theme_templates = CBT_Theme_Templates::get_theme_templates( $export_type );
$template_folders = get_block_theme_folders();
if ( $theme_templates->templates ) {
$zip->addThemeDir( $template_folders['wp_template'] );
}
if ( $theme_templates->parts ) {
$zip->addThemeDir( $template_folders['wp_template_part'] );
}
foreach ( $theme_templates->templates as $template ) {
$template->media = CBT_Theme_Media::get_media_absolute_urls_from_template( $template );
$validated_media = array();
if ( ! empty( $template->media ) ) {
$validated_media = self::add_media_to_zip( $zip, $template->media );
}
$template = CBT_Theme_Templates::prepare_template_for_export(
$template,
null,
array(
'localizeText' => false,
'removeNavRefs' => true,
'localizeImages' => true,
'validatedMedia' => $validated_media,
)
);
// Write the template content
$zip->addFromStringToTheme(
path_join( $template_folders['wp_template'], $template->slug . '.html' ),
$template->content
);
// Write the pattern if it exists
if ( isset( $template->pattern ) ) {
$zip->addFromStringToTheme(
'patterns/' . $template->slug . '.php',
$template->pattern
);
}
}
foreach ( $theme_templates->parts as $template ) {
$template->media = CBT_Theme_Media::get_media_absolute_urls_from_template( $template );
$validated_media = array();
if ( ! empty( $template->media ) ) {
$validated_media = self::add_media_to_zip( $zip, $template->media );
}
$template = CBT_Theme_Templates::prepare_template_for_export(
$template,
null,
array(
'localizeText' => false,
'removeNavRefs' => true,
'localizeImages' => true,
'validatedMedia' => $validated_media,
)
);
// Write the template content
$zip->addFromStringToTheme(
path_join( $template_folders['wp_template_part'], $template->slug . '.html' ),
$template->content
);
// Write the pattern if it exists
if ( isset( $template->pattern ) ) {
$zip->addFromStringToTheme(
'patterns/' . $template->slug . '.php',
$template->pattern
);
}
}
return $zip;
}
static function add_media_to_zip( $zip, $media ) {
$media = array_unique( $media );
$added_media = array();
foreach ( $media as $url ) {
// Pre-download URL extension allowlist — see CBT_Theme_Media::is_allowed_media_url().
if ( ! CBT_Theme_Media::is_allowed_media_url( $url ) ) {
continue;
}
$folder_path = CBT_Theme_Media::get_media_folder_path_from_url( $url );
$download_file = download_url( $url );
if ( is_wp_error( $download_file ) ) {
//we're going to try again with a new URL
//see, we might be running this in a docker container
//and if that's the case let's try again on port 80
$parsed_url = parse_url( $url );
if ( isset( $parsed_url['host'], $parsed_url['port'] )
&& 'localhost' === $parsed_url['host']
&& '80' !== $parsed_url['port'] ) {
$download_file = download_url( str_replace( 'localhost:' . $parsed_url['port'], 'localhost:80', $url ) );
}
}
// If there was an error downloading the file, skip it.
// TODO: Implement a warning if the file is missing
if ( is_wp_error( $download_file ) ) {
continue;
}
// Post-download MIME allowlist.
if ( ! CBT_Theme_Media::is_allowed_media_file( $download_file, $url ) ) {
@unlink( $download_file );
continue;
}
// Read the bytes into memory before unlinking — ZipArchive defers
// reading files added via addFile() until close() is called, so
// removing the tmp file immediately would leave an empty entry
// in the final archive.
$file_name = basename( (string) wp_parse_url( $url, PHP_URL_PATH ) );
$bytes = file_get_contents( $download_file );
@unlink( $download_file );
if ( false === $bytes ) {
continue;
}
if ( $zip->addFromStringToTheme( ltrim( $folder_path, '/' ) . $file_name, $bytes ) ) {
$added_media[] = $url;
}
}
return $added_media;
}
}