status() ) ) { throw new \RuntimeException( 'Only published document templates may be rendered.' ); } $html = $template->template_html(); $css = $template->template_css(); $this->assert_utf8( $html ); $this->assert_utf8( $css ); $this->reject_html( $html ); $this->reject_css( $css ); preg_match_all( '/\{\{BLOCK:([A-Z0-9_]+)\}\}/', $html, $matches ); $found = $matches[1] ?? array(); if ( preg_match( '/\{\{(?!BLOCK:[A-Z0-9_]+\}\})/', $html ) ) { throw new \RuntimeException( 'Unknown template placeholder syntax is not allowed.' ); } $manifest = array_values( array_unique( array_map( 'strval', $template->semantic_manifest() ) ) ); foreach ( $found as $block ) { if ( ! in_array( $block, $manifest, true ) ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception text is not rendered; callers control any presentation. throw new \RuntimeException( 'Template contains a block that is missing from its semantic manifest: ' . $block ); } } foreach ( array_values( array_unique( $mandatory_blocks ) ) as $required ) { if ( 1 !== count( array_keys( $found, $required, true ) ) ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception text is not rendered; callers control any presentation. throw new \RuntimeException( 'Mandatory document block must appear exactly once: ' . $required ); } } } private function reject_html( string $html ): void { $patterns = array( '/<\s*(script|iframe|object|embed|form|base|link|meta|svg|math|style)\b/i', '/<\?/', '/\son[a-z0-9_-]+\s*=/i', '/\b(javascript|vbscript|file|ftp|https?)\s*:/i', '/(?:src|href)\s*=\s*["\']?\/\//i', '/data\s*:\s*text\/html/i', ); foreach ( $patterns as $pattern ) { if ( preg_match( $pattern, $html ) ) { throw new \RuntimeException( 'Template HTML contains a forbidden executable or remote-resource construct.' ); } } $allowed_tags = array( 'main', 'div', 'article', 'header', 'footer', 'p', 'span' ); preg_match_all( '/<\s*(\/?)\s*([a-z0-9-]+)\b([^>]*)>/i', $html, $tags, PREG_SET_ORDER ); foreach ( $tags as $tag ) { $name = strtolower( (string) ( $tag[2] ?? '' ) ); if ( ! in_array( $name, $allowed_tags, true ) ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception text is not rendered; callers control any presentation. throw new \RuntimeException( 'Template HTML tag is not in the approved layout allowlist: ' . $name ); } if ( '/' === ( $tag[1] ?? '' ) ) { continue; } $attributes = trim( (string) ( $tag[3] ?? '' ) ); if ( '' === $attributes ) { continue; } // Template-shell markup may use only simple class/id tokens. This prevents hidden/style/src/href tricks. $attributes = preg_replace( '/\b(?:class|id)\s*=\s*(?:"[A-Za-z0-9_ -]+"|\'[A-Za-z0-9_ -]+\')/i', '', $attributes ) ?? $attributes; if ( '' !== trim( $attributes ) ) { throw new \RuntimeException( 'Template HTML contains an attribute outside the approved layout allowlist.' ); } } } private function reject_css( string $css ): void { $normalized = preg_replace( '#/\*.*?\*/#s', '', strtolower( $css ) ) ?? strtolower( $css ); $patterns = array( '/@import\b/', '/url\s*\(/', '/expression\s*\(/', '/behavior\s*:/', '/-moz-binding\s*:/', '/\b(javascript|vbscript|file|ftp|https?)\s*:/', '/!\s*important\b/', '/\btransparent\b/', '/rgba?\s*\([^)]*,\s*0(?:\.0+)?\s*\)/', '/hsla?\s*\([^)]*,\s*0(?:\.0+)?\s*\)/', ); foreach ( $patterns as $pattern ) { if ( preg_match( $pattern, $normalized ) ) { throw new \RuntimeException( 'Template CSS contains a forbidden hiding, executable, or remote-resource construct.' ); } } if ( substr_count( $normalized, '{' ) !== substr_count( $normalized, '}' ) ) { throw new \RuntimeException( 'Template CSS has unbalanced rule braces.' ); } preg_match_all( '/@([a-z-]+)/', $normalized, $at_rules ); foreach ( $at_rules[1] ?? array() as $at_rule ) { if ( 'page' !== $at_rule ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception text is not rendered; callers control any presentation. throw new \RuntimeException( 'Template CSS at-rule is not allowed: @' . $at_rule ); } } preg_match_all( '/\{([^{}]*)\}/s', $normalized, $rules ); foreach ( $rules[1] ?? array() as $declarations ) { foreach ( explode( ';', $declarations ) as $declaration ) { $declaration = trim( $declaration ); if ( '' === $declaration ) { continue; } if ( ! str_contains( $declaration, ':' ) ) { throw new \RuntimeException( 'Template CSS declaration is malformed.' ); } list( $property, $value ) = array_map( 'trim', explode( ':', $declaration, 2 ) ); $this->assert_safe_css_declaration( $property, $value ); } } } private function assert_safe_css_declaration( string $property, string $value ): void { $allowed = array( 'margin', 'margin-top', 'margin-right', 'margin-bottom', 'margin-left', 'padding', 'padding-top', 'padding-right', 'padding-bottom', 'padding-left', 'font-family', 'font-size', 'font-weight', 'font-style', 'line-height', 'color', 'background-color', 'border', 'border-top', 'border-right', 'border-bottom', 'border-left', 'border-color', 'border-style', 'border-width', 'border-collapse', 'width', 'min-width', 'vertical-align', 'white-space', 'text-align', 'page-break-before', 'page-break-after', 'page-break-inside', 'word-break', 'overflow-wrap', ); if ( ! in_array( $property, $allowed, true ) ) { // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception text is not rendered; callers control any presentation. throw new \RuntimeException( 'Template CSS property is outside the approved allowlist: ' . $property ); } if ( '' === $value || preg_match( '/[{}<>]/', $value ) ) { throw new \RuntimeException( 'Template CSS value is malformed.' ); } if ( str_starts_with( $property, 'margin' ) || str_starts_with( $property, 'padding' ) ) { if ( preg_match( '/(^|\s)-(?:\d|\.)/', $value ) ) { throw new \RuntimeException( 'Negative template spacing is not allowed because it can move legal content outside the page.' ); } } if ( 'font-size' === $property ) { $this->assert_minimum_font_size( $value ); } if ( 'line-height' === $property && preg_match( '/^([0-9]*\.?[0-9]+)$/', $value, $matches ) && (float) $matches[1] < 0.8 ) { throw new \RuntimeException( 'Template line-height is too small for protected legal content.' ); } if ( 'width' === $property || 'min-width' === $property ) { if ( preg_match( '/^([0-9]*\.?[0-9]+)%$/', $value, $matches ) && (float) $matches[1] < 20.0 ) { throw new \RuntimeException( 'Template width is too small for protected legal content.' ); } } } private function assert_minimum_font_size( string $value ): void { if ( ! preg_match( '/^([0-9]*\.?[0-9]+)(pt|px|em|rem|%)$/', trim( $value ), $matches ) ) { throw new \RuntimeException( 'Template font-size must use an approved numeric unit.' ); } $amount = (float) $matches[1]; $unit = $matches[2]; $ok = match ( $unit ) { 'pt' => $amount >= 6.0, 'px' => $amount >= 8.0, 'em', 'rem' => $amount >= 0.6, '%' => $amount >= 60.0, default => false, }; if ( ! $ok ) { throw new \RuntimeException( 'Template font-size is too small for protected legal content.' ); } } private function assert_utf8( string $value ): void { if ( 1 !== preg_match( '//u', $value ) ) { throw new \RuntimeException( 'Document template content must be valid UTF-8.' ); } } }