|
| 1 | +<?php |
| 2 | +/** |
| 3 | + * Content-only boundary for untrusted website artifacts. |
| 4 | + * |
| 5 | + * @package StaticSiteImporter |
| 6 | + */ |
| 7 | + |
| 8 | +if ( ! defined( 'ABSPATH' ) ) { |
| 9 | + return; |
| 10 | +} |
| 11 | + |
| 12 | +final class Static_Site_Importer_Content_Policy { |
| 13 | + /** Files that can be copied from an untrusted static-site artifact. */ |
| 14 | + private const STATIC_EXTENSIONS = array( |
| 15 | + 'html', 'htm', 'css', 'js', 'mjs', 'json', 'map', 'xml', 'txt', 'md', 'markdown', |
| 16 | + 'svg', 'png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'ico', 'bmp', |
| 17 | + 'woff', 'woff2', 'ttf', 'otf', 'eot', 'mp3', 'mp4', 'webm', 'ogg', 'wav', 'pdf', |
| 18 | + ); |
| 19 | + |
| 20 | + /** Assets that a compiler may carry into a generated companion plugin. */ |
| 21 | + private const COMPANION_ASSET_EXTENSIONS = array( 'js', 'mjs', 'css', 'json', 'svg', 'png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'ico', 'woff', 'woff2', 'ttf', 'otf', 'eot' ); |
| 22 | + |
| 23 | + /** @return true|WP_Error */ |
| 24 | + public static function validate_artifact( array $artifact ) { |
| 25 | + $files = $artifact['files'] ?? null; |
| 26 | + if ( ! is_array( $files ) ) { |
| 27 | + return new WP_Error( 'static_site_importer_artifact_files_invalid', 'Website artifacts must declare files as an array.' ); |
| 28 | + } |
| 29 | + foreach ( $files as $file ) { |
| 30 | + if ( ! is_array( $file ) || ! isset( $file['path'] ) || ! is_scalar( $file['path'] ) ) { |
| 31 | + return new WP_Error( 'static_site_importer_artifact_file_invalid', 'Website artifacts must declare a path for every file.' ); |
| 32 | + } |
| 33 | + $path = (string) $file['path']; |
| 34 | + if ( ! self::is_static_path( $path ) ) { |
| 35 | + return new WP_Error( 'static_site_importer_executable_source_rejected', sprintf( 'Untrusted artifact file %s is not static content.', $path ), array( 'path' => $path ) ); |
| 36 | + } |
| 37 | + $content = self::file_content( $file ); |
| 38 | + if ( null !== $content && self::contains_server_code( $content ) ) { |
| 39 | + return new WP_Error( 'static_site_importer_executable_source_rejected', sprintf( 'Untrusted artifact file %s contains server-side code.', $path ), array( 'path' => $path ) ); |
| 40 | + } |
| 41 | + } |
| 42 | + return true; |
| 43 | + } |
| 44 | + |
| 45 | + public static function is_static_path( string $path ): bool { |
| 46 | + $extension = strtolower( pathinfo( $path, PATHINFO_EXTENSION ) ); |
| 47 | + return '' !== $extension && in_array( $extension, self::STATIC_EXTENSIONS, true ); |
| 48 | + } |
| 49 | + |
| 50 | + public static function is_companion_asset_path( string $path ): bool { |
| 51 | + $extension = strtolower( pathinfo( $path, PATHINFO_EXTENSION ) ); |
| 52 | + return '' !== $extension && in_array( $extension, self::COMPANION_ASSET_EXTENSIONS, true ); |
| 53 | + } |
| 54 | + |
| 55 | + public static function contains_server_code( string $content ): bool { |
| 56 | + return preg_match( '/<\?(?:php|=|[[:space:]])/i', $content ) === 1; |
| 57 | + } |
| 58 | + |
| 59 | + /** @param array<string,mixed> $file */ |
| 60 | + private static function file_content( array $file ): ?string { |
| 61 | + if ( isset( $file['content'] ) && is_scalar( $file['content'] ) ) { |
| 62 | + return (string) $file['content']; |
| 63 | + } |
| 64 | + if ( ! isset( $file['content_base64'] ) || ! is_scalar( $file['content_base64'] ) ) { |
| 65 | + return null; |
| 66 | + } |
| 67 | + $decoded = base64_decode( (string) $file['content_base64'], true ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- Validates untrusted artifact bytes. |
| 68 | + return false === $decoded ? null : $decoded; |
| 69 | + } |
| 70 | +} |
0 commit comments