* 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>