Skip to content

Commit 57482bc

Browse files
committed
fix: enforce content-only static imports (#853)
AI: OpenAI GPT-5.6 Sol via OpenCode implemented the content-only intake and companion-plugin boundary with adversarial regression coverage.
1 parent 361725e commit 57482bc

13 files changed

Lines changed: 191 additions & 28 deletions

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ The conversion stack is split by responsibility:
3434
- **Static Site Importer** owns WordPress intake, safety checks, page/theme creation, asset placement, import reports, quality gates, and block-theme materialization.
3535
- **Blocks Engine PHP transformer** owns the generic `ArtifactCompiler`, its diagnostics, and the `source_reports.wordpress_site_plan` v2 output. SSI materializes that plan into WordPress and returns the receipt and import report.
3636

37+
## Content-Only Security Boundary
38+
39+
All HTML, folders, ZIPs, URLs, and website artifact objects are untrusted static content. SSI accepts only explicit static asset extensions and rejects server-side source markers before compilation. Compiler-produced companion payloads are independently revalidated before any generated plugin file is written or activated. Companion block renders accept static HTML only; SSI emits its own fixed PHP wrapper to output that markup, so source PHP cannot be preserved or executed. Existing payloads that relied on PHP render templates or PHP companion assets must migrate their behavior to blocks, data bindings, or client-side JavaScript.
40+
3741
When a generated artifact contains full-document HTML, Static Site Importer routes document metadata, head content, styles, scripts, and page body fragments to the right WordPress destinations before calling the conversion stack. A `core/html` block in imported page content is therefore a materialization/conversion quality issue to fix in this stack, not a product-layer workaround to hide upstream.
3842

3943
## What It Does

homeboy-test-manifest.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
"tests/smoke-ability-import-success-diagnostics.php": { "environment": "standalone-php" },
88
"tests/smoke-ability-registration-idempotent.php": { "environment": "standalone-php" },
99
"tests/smoke-canonical-import-ability.php": { "environment": "standalone-php" },
10+
"tests/smoke-content-only-policy.php": { "environment": "standalone-php" },
1011
"tests/smoke-companion-plugin-js.php": { "environment": "standalone-php" },
1112
"tests/smoke-companion-plugin.php": { "environment": "standalone-php" },
1213
"tests/smoke-contact-layout-transformer.php": { "environment": "standalone-php" },

includes/class-static-site-importer-companion-plugin.php

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@
3030
require_once __DIR__ . '/class-static-site-importer-site-identity.php';
3131
}
3232

33+
if ( ! class_exists( 'Static_Site_Importer_Content_Policy' ) ) {
34+
require_once __DIR__ . '/class-static-site-importer-content-policy.php';
35+
}
36+
3337
/**
3438
* Scaffolds a one-per-site companion plugin from a generated block payload.
3539
*/
@@ -94,14 +98,17 @@ public static function validate_payload( array $payload ) {
9498
}
9599
$block_names[ $effective_name ] = true;
96100
$assets = $block['assets'] ?? array();
97-
if ( ! is_array( $assets ) || array_is_list( $assets ) ) {
101+
if ( ! is_array( $assets ) || ( ! empty( $assets ) && array_is_list( $assets ) ) ) {
98102
return new WP_Error( 'static_site_importer_companion_plugin_assets_invalid', sprintf( 'Block %s assets must be an object.', $name ) );
99103
}
100104
foreach ( $assets as $path => $content ) {
101-
if ( ! is_string( $path ) || self::sanitize_relative_path( $path ) !== $path || ! is_scalar( $content ) ) {
105+
if ( ! is_string( $path ) || self::sanitize_relative_path( $path ) !== $path || ! Static_Site_Importer_Content_Policy::is_companion_asset_path( $path ) || ! is_scalar( $content ) || Static_Site_Importer_Content_Policy::contains_server_code( (string) $content ) ) {
102106
return new WP_Error( 'static_site_importer_companion_plugin_asset_path_invalid', sprintf( 'Block %s has an unsafe asset path.', $name ) );
103107
}
104108
}
109+
if ( isset( $block['render'] ) && is_scalar( $block['render'] ) && Static_Site_Importer_Content_Policy::contains_server_code( (string) $block['render'] ) ) {
110+
return new WP_Error( 'static_site_importer_companion_plugin_render_invalid', sprintf( 'Block %s render markup must be static HTML.', $name ) );
111+
}
105112
$metadata = $block['block_json'];
106113
if ( isset( $block['render'] ) && is_scalar( $block['render'] ) ) {
107114
$metadata['render'] = 'file:./render.php';
@@ -143,6 +150,10 @@ public static function validate_payload( array $payload ) {
143150
* @return array<string,mixed>|WP_Error
144151
*/
145152
public static function scaffold( array $payload ) {
153+
$validation = self::validate_payload( $payload );
154+
if ( is_wp_error( $validation ) ) {
155+
return $validation;
156+
}
146157
$site_slug = self::site_slug( $payload );
147158
if ( '' === $site_slug ) {
148159
return new WP_Error(
@@ -924,11 +935,9 @@ private static function normalize_render( string $render ): string {
924935
return "<?php\n/**\n * Generated companion block render (server-rendered dynamic block).\n *\n * @package StaticSiteImporterCompanion\n *\n * @var array<string,mixed> \$attributes Block attributes.\n * @var string \$content Inner block content.\n * @var WP_Block \$block Block instance.\n */\n\necho \$content; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Inner block content is already sanitized by WordPress.\n";
925936
}
926937

927-
if ( str_starts_with( $trimmed, '<?php' ) || str_starts_with( $trimmed, '<?=' ) ) {
928-
return $render;
929-
}
930-
931-
return "<?php\n/**\n * Generated companion block render (server-rendered dynamic block).\n *\n * @package StaticSiteImporterCompanion\n *\n * @var array<string,mixed> \$attributes Block attributes.\n * @var string \$content Inner block content.\n * @var WP_Block \$block Block instance.\n */\n?>\n" . $render;
938+
// Static source markup is data, never executable template source. The only
939+
// PHP in this file is SSI-generated code that emits an escaped literal.
940+
return "<?php\n/** Generated companion block render. */\n\necho '" . self::php_single_quote( $render ) . "'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Static source markup was validated before compilation.\n";
932941
}
933942

934943
/**
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
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+
}

includes/class-static-site-importer-plugin-materializer.php

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -151,15 +151,12 @@ public static function ensure_generated_plugin(
151151
array $payload,
152152
?callable $availability_check = null
153153
): array {
154-
// Canonical compiler payloads are validated before deriving an install plan
155-
// or touching the filesystem. Schema-less callers retain the legacy
156-
// PHP-only scaffold compatibility path.
157-
if ( array_key_exists( 'schema', $payload ) ) {
158-
$validation = Static_Site_Importer_Companion_Plugin::validate_payload( $payload );
159-
if ( is_wp_error( $validation ) ) {
160-
$report = self::new_generated_report( '', '' );
161-
return self::failed_report( $report, $validation );
162-
}
154+
// All compiler output is untrusted until the complete canonical payload has
155+
// passed the content-only boundary. Schema-less PHP scaffold input is gone.
156+
$validation = Static_Site_Importer_Companion_Plugin::validate_payload( $payload );
157+
if ( is_wp_error( $validation ) ) {
158+
$report = self::new_generated_report( '', '' );
159+
return self::failed_report( $report, $validation );
163160
}
164161
$descriptor = Static_Site_Importer_Companion_Plugin::scaffold( $payload );
165162
if ( is_wp_error( $descriptor ) ) {

includes/class-static-site-importer-theme-generator.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@
1515
require_once __DIR__ . '/class-static-site-importer-site-identity.php';
1616
}
1717

18+
if ( ! class_exists( 'Static_Site_Importer_Content_Policy' ) ) {
19+
require_once __DIR__ . '/class-static-site-importer-content-policy.php';
20+
}
21+
1822
if ( ! class_exists( 'Static_Site_Importer_Block_Document_Reporter' ) ) {
1923
require_once __DIR__ . '/class-static-site-importer-block-document-reporter.php';
2024
}
@@ -86,6 +90,10 @@ public static function import_website_artifact( array $artifact, array $args = a
8690

8791
/** Compile an artifact into its immutable canonical WordPress site plan. */
8892
public static function compile_website_artifact( array $artifact, array $args = array() ) {
93+
$source_policy = Static_Site_Importer_Content_Policy::validate_artifact( $artifact );
94+
if ( is_wp_error( $source_policy ) ) {
95+
return $source_policy;
96+
}
8997
$compiler_class = 'Automattic\\BlocksEngine\\PhpTransformer\\ArtifactCompiler\\ArtifactCompiler';
9098
if ( ! class_exists( $compiler_class ) ) {
9199
return new WP_Error( 'static_site_importer_missing_transformer', 'Blocks Engine php-transformer is required to import a website artifact.' );

includes/rest.php

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@
1313
require_once __DIR__ . '/class-static-site-importer-site-identity.php';
1414
}
1515

16+
if ( ! class_exists( 'Static_Site_Importer_Content_Policy' ) ) {
17+
require_once __DIR__ . '/class-static-site-importer-content-policy.php';
18+
}
19+
1620
if ( ! class_exists( 'Static_Site_Importer_URL_Import_Runtime' ) ) {
1721
require_once __DIR__ . '/class-static-site-importer-url-import-runtime.php';
1822
}
@@ -899,15 +903,21 @@ function static_site_importer_source_runtime( array $source, array $input = arra
899903

900904
$metadata = isset( $source['metadata'] ) && is_array( $source['metadata'] ) ? $source['metadata'] : array();
901905

902-
return array(
903-
'artifact' => array_merge(
906+
$artifact = array_merge(
904907
$metadata,
905908
array(
906909
'schema' => 'blocks-engine/php-transformer/site-artifact/v1',
907910
'entrypoint' => $entrypoint,
908911
'files' => $files,
909912
)
910-
),
913+
);
914+
$source_policy = Static_Site_Importer_Content_Policy::validate_artifact( $artifact );
915+
if ( is_wp_error( $source_policy ) ) {
916+
return $source_policy;
917+
}
918+
919+
return array(
920+
'artifact' => $artifact,
911921
'source_metadata' => array(),
912922
'provider' => 'rest-source',
913923
);
@@ -1043,6 +1053,14 @@ function static_site_importer_rest_archive_files( array $archive ) {
10431053
continue;
10441054
}
10451055

1056+
if ( ! Static_Site_Importer_Content_Policy::is_static_path( $path ) ) {
1057+
$zip->close();
1058+
if ( file_exists( $tmp ) ) {
1059+
wp_delete_file( $tmp );
1060+
}
1061+
return new WP_Error( 'static_site_importer_executable_source_rejected', __( 'ZIP archives may contain static content only.', 'static-site-importer' ), array( 'status' => 400, 'path' => $path ) );
1062+
}
1063+
10461064
$file_content = $zip->getFromIndex( $i );
10471065
if ( false === $file_content ) {
10481066
$zip->close();

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
"test:all": "node tools/run-test-manifest.mjs --all",
1010
"test:inventory": "node tools/run-test-manifest.mjs --check",
1111
"test:runtime-package": "node --test tools/runtime-package-manifest.test.mjs && php tests/smoke-ability-registration-idempotent.php",
12-
"test:companion-plugin": "php tests/smoke-companion-plugin.php && php tests/smoke-companion-plugin-js.php",
12+
"test:companion-plugin": "php tests/smoke-content-only-policy.php && php tests/smoke-companion-plugin.php && php tests/smoke-companion-plugin-js.php",
1313
"test:site-plan-materializer": "php tests/smoke-wordpress-site-plan-materializer.php",
1414
"test:webfont-producer-consumer": "php tests/smoke-webfont-producer-consumer.php",
1515
"test:fig-fixture-e2e": "node --test tools/run-fig-fixture-e2e.test.mjs",

static-site-importer.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
require_once STATIC_SITE_IMPORTER_PATH . 'includes/class-static-site-importer-url-fetcher.php';
5454
require_once STATIC_SITE_IMPORTER_PATH . 'includes/class-static-site-importer-artifact-run.php';
5555
require_once STATIC_SITE_IMPORTER_PATH . 'includes/class-static-site-importer-source-normalizer.php';
56+
require_once STATIC_SITE_IMPORTER_PATH . 'includes/class-static-site-importer-content-policy.php';
5657
require_once STATIC_SITE_IMPORTER_PATH . 'includes/class-static-site-importer-url-site-collector.php';
5758
require_once STATIC_SITE_IMPORTER_PATH . 'includes/class-static-site-importer-url-import-runtime.php';
5859
require_once STATIC_SITE_IMPORTER_PATH . 'includes/class-static-site-importer-companion-plugin.php';

test-manifest.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
{ "path": "tests/smoke-ability-import-success-diagnostics.php", "environment": "standalone-php" },
1313
{ "path": "tests/smoke-ability-registration-idempotent.php", "environment": "standalone-php" },
1414
{ "path": "tests/smoke-canonical-import-ability.php", "environment": "standalone-php" },
15+
{ "path": "tests/smoke-content-only-policy.php", "environment": "standalone-php" },
1516
{ "path": "tests/smoke-companion-plugin-js.php", "environment": "standalone-php" },
1617
{ "path": "tests/smoke-companion-plugin.php", "environment": "standalone-php" },
1718
{ "path": "tests/smoke-contact-layout-transformer.php", "environment": "standalone-php" },

0 commit comments

Comments
 (0)