Skip to content

Commit 1c2fa1c

Browse files
authored
fix: enforce explicit client script policy (Automattic#855) [AI: OpenAI GPT-5.6 Sol via OpenCode] (Automattic#863)
1 parent aea0747 commit 1c2fa1c

12 files changed

Lines changed: 251 additions & 18 deletions

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,12 @@ For an isolated runtime matrix, invoke the ability with `plan`, `slug`, and opti
2828
wp static-site-importer materialize-wordpress-site-plan --plan=/path/to/plan.json --slug=generated-site
2929
```
3030

31+
## Client Script Policy
32+
33+
Every artifact is passed through `client_script_policy` before Blocks Engine compilation and WordPress materialization. The default is `inert`: SSI removes executable inline, local, remote, module, telemetry, and `data:` script markup, removes bundled JavaScript assets, and records each disposition in `import_report.client_script_policy`. JSON data scripts are quarantined in the report and are not emitted into the generated site.
34+
35+
`isolated_preview` is the sole preservation opt-in. It requires an explicit `client_script_provenance` object with a non-empty `ref` and a runtime isolation assertion. It is intended only for an isolated disposable preview runtime. Preserved scripts remain `untrusted_imported_code`; artifact carriage, local paths, and source type never establish trust. Current-site REST imports forcibly use `inert`. Existing `include_scripts` URL collection callers no longer preserve scripts; callers must request `script_policy: isolated_preview`, supply provenance, and run only in an isolated preview environment.
36+
3137
## Architecture Stack
3238

3339
Static Site Importer is the WordPress materialization layer for static website inputs. It accepts two related shapes:

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-client-script-policy.php": { "environment": "standalone-php" },
1011
"tests/smoke-content-only-policy.php": { "environment": "standalone-php" },
1112
"tests/smoke-companion-plugin-js.php": { "environment": "standalone-php" },
1213
"tests/smoke-companion-plugin.php": { "environment": "standalone-php" },
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
<?php
2+
/**
3+
* Client script trust policy for imported website artifacts.
4+
*
5+
* @package StaticSiteImporter
6+
*/
7+
8+
if ( ! defined( 'ABSPATH' ) ) {
9+
exit;
10+
}
11+
12+
/** Applies an explicit, provenance-bound client-script policy before compilation. */
13+
class Static_Site_Importer_Client_Script_Policy {
14+
/**
15+
* Make executable client code inert unless an isolated preview explicitly opts in.
16+
*
17+
* @return array{artifact:array<string,mixed>,report:array<string,mixed>}
18+
*/
19+
public static function apply( array $artifact, array $args ): array {
20+
$policy = self::policy_name( $args );
21+
$provenance = self::provenance( $args );
22+
$preserve = 'isolated_preview' === $policy && ! empty( $args['client_script_isolated'] ) && '' !== $provenance;
23+
$report = array(
24+
'schema' => 'static-site-importer/client-script-policy-report/v1',
25+
'policy' => $preserve ? 'isolated_preview' : 'inert',
26+
'trust' => 'untrusted_imported_code',
27+
'provenance' => $preserve ? $provenance : '',
28+
'dropped' => array(),
29+
'quarantined' => array(),
30+
'preserved' => array(),
31+
);
32+
$files = isset( $artifact['files'] ) && is_array( $artifact['files'] ) ? $artifact['files'] : array();
33+
$filtered = array();
34+
35+
foreach ( $files as $file ) {
36+
if ( ! is_array( $file ) ) {
37+
continue;
38+
}
39+
$path = isset( $file['path'] ) && is_scalar( $file['path'] ) ? (string) $file['path'] : '';
40+
if ( self::is_script_file( $file ) ) {
41+
self::record( $report, $preserve ? 'preserved' : 'dropped', self::file_row( $path, $file ) );
42+
if ( ! $preserve ) {
43+
continue;
44+
}
45+
}
46+
if ( self::is_html_file( $file ) ) {
47+
$file['content'] = self::filter_html( (string) ( $file['content'] ?? '' ), $path, $preserve, $report );
48+
}
49+
$filtered[] = $file;
50+
}
51+
52+
$artifact['files'] = $filtered;
53+
return array( 'artifact' => $artifact, 'report' => $report );
54+
}
55+
56+
private static function policy_name( array $args ): string {
57+
return 'isolated_preview' === (string) ( $args['client_script_policy'] ?? '' ) ? 'isolated_preview' : 'inert';
58+
}
59+
60+
private static function provenance( array $args ): string {
61+
$provenance = $args['client_script_provenance'] ?? null;
62+
if ( is_scalar( $provenance ) ) {
63+
return trim( (string) $provenance );
64+
}
65+
if ( is_array( $provenance ) && isset( $provenance['ref'] ) && is_scalar( $provenance['ref'] ) ) {
66+
return trim( (string) $provenance['ref'] );
67+
}
68+
return '';
69+
}
70+
71+
private static function is_html_file( array $file ): bool {
72+
$path = strtolower( (string) ( $file['path'] ?? '' ) );
73+
$mime = strtolower( (string) ( $file['mime_type'] ?? '' ) );
74+
return str_ends_with( $path, '.html' ) || str_ends_with( $path, '.htm' ) || str_contains( $mime, 'html' );
75+
}
76+
77+
private static function is_script_file( array $file ): bool {
78+
$path = strtolower( (string) ( $file['path'] ?? '' ) );
79+
$mime = strtolower( (string) ( $file['mime_type'] ?? '' ) );
80+
return (bool) preg_match( '/\.(?:js|mjs|cjs)$/', $path ) || str_contains( $mime, 'javascript' ) || str_contains( $mime, 'ecmascript' );
81+
}
82+
83+
private static function filter_html( string $html, string $path, bool $preserve, array &$report ): string {
84+
return (string) preg_replace_callback(
85+
'#<script\b([^>]*)>(.*?)</script\s*>#is',
86+
static function ( array $matches ) use ( $path, $preserve, &$report ): string {
87+
$attributes = $matches[1];
88+
$source = self::attribute( $attributes, 'src' );
89+
$type = strtolower( trim( (string) self::attribute( $attributes, 'type' ) ) );
90+
$row = array(
91+
'path' => $path,
92+
'class' => self::script_class( $source, $type, $matches[2] ),
93+
'type' => '' !== $type ? $type : 'classic',
94+
'sha256' => hash( 'sha256', $matches[0] ),
95+
);
96+
if ( null !== $source ) {
97+
$row['src'] = $source;
98+
}
99+
if ( $preserve ) {
100+
self::record( $report, 'preserved', $row );
101+
return $matches[0];
102+
}
103+
self::record( $report, 'data' === $row['class'] ? 'quarantined' : 'dropped', $row );
104+
return '';
105+
},
106+
$html
107+
);
108+
}
109+
110+
private static function attribute( string $attributes, string $name ): ?string {
111+
if ( ! preg_match( '/\s' . preg_quote( $name, '/' ) . '\s*=\s*(?:"([^"]*)"|\'([^\']*)\'|([^\s>]+))/i', $attributes, $matches ) ) {
112+
return null;
113+
}
114+
return '' !== (string) ( $matches[1] ?? '' ) ? $matches[1] : ( '' !== (string) ( $matches[2] ?? '' ) ? $matches[2] : (string) ( $matches[3] ?? '' ) );
115+
}
116+
117+
private static function script_class( ?string $source, string $type, string $content ): string {
118+
if ( in_array( $type, array( 'application/json', 'application/ld+json', 'application/manifest+json' ), true ) || ( null !== $source && str_starts_with( strtolower( $source ), 'data:' ) ) ) {
119+
return 'data';
120+
}
121+
if ( 'module' === $type ) {
122+
return 'module';
123+
}
124+
if ( preg_match( '/(?:google-analytics|googletagmanager|gtag\s*\(|segment\.|mixpanel|hotjar|clarity|sentry|telemetry|analytics)/i', (string) $source . "\n" . $content ) ) {
125+
return 'telemetry';
126+
}
127+
if ( null === $source ) {
128+
return 'inline';
129+
}
130+
return preg_match( '#^(?:https?:)?//#i', $source ) ? 'remote' : 'local';
131+
}
132+
133+
private static function file_row( string $path, array $file ): array {
134+
return array(
135+
'path' => $path,
136+
'class' => 'local',
137+
'type' => 'asset',
138+
'sha256' => hash( 'sha256', (string) ( $file['content'] ?? '' ) ),
139+
);
140+
}
141+
142+
private static function record( array &$report, string $disposition, array $row ): void {
143+
$report[ $disposition ][] = $row;
144+
}
145+
}

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@
2626
if ( ! class_exists( 'Static_Site_Importer_Report_Diagnostics' ) ) {
2727
require_once __DIR__ . '/class-static-site-importer-report-diagnostics.php';
2828
}
29+
if ( ! class_exists( 'Static_Site_Importer_Client_Script_Policy' ) ) {
30+
require_once __DIR__ . '/class-static-site-importer-client-script-policy.php';
31+
}
2932

3033
/**
3134
* Generates a block theme from a static HTML document.
@@ -94,6 +97,9 @@ public static function compile_website_artifact( array $artifact, array $args =
9497
if ( is_wp_error( $source_policy ) ) {
9598
return $source_policy;
9699
}
100+
$script_policy = Static_Site_Importer_Client_Script_Policy::apply( $artifact, $args );
101+
$artifact = $script_policy['artifact'];
102+
$args['client_script_policy_report'] = $script_policy['report'];
97103
$compiler_class = 'Automattic\\BlocksEngine\\PhpTransformer\\ArtifactCompiler\\ArtifactCompiler';
98104
if ( ! class_exists( $compiler_class ) ) {
99105
return new WP_Error( 'static_site_importer_missing_transformer', 'Blocks Engine php-transformer is required to import a website artifact.' );
@@ -414,6 +420,7 @@ private static function public_result_from_wordpress_site_plan_receipt( array $r
414420
'gutenberg_gaps' => $gutenberg_gaps,
415421
),
416422
'quality' => $quality,
423+
'client_script_policy' => $args['client_script_policy_report'] ?? array(),
417424
'diagnostics' => $diagnostics,
418425
'entity_lifecycle' => $entity_lifecycle,
419426
'companion_plugin_materialization' => $receipt['completed']['companion_plugin'] ?? array(

includes/class-static-site-importer-url-site-collector.php

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -588,21 +588,15 @@ private static function html_asset_urls( string $html, string $base_url, array $
588588
return array_values( array_unique( array_merge( $urls, self::html_css_asset_urls( $html, $base_url ) ) ) );
589589
}
590590

591-
/** Resolve the explicit script retention contract for public HTML collection. */
591+
/** Resolve the isolated, provenance-bound script retention contract for public HTML collection. */
592592
private static function script_policy( array $args ): string {
593-
if ( array_key_exists( 'include_scripts', $args ) ) {
594-
return ! empty( $args['include_scripts'] ) ? 'full' : 'none';
595-
}
596-
$policy = isset( $args['script_policy'] ) ? (string) $args['script_policy'] : 'static';
597-
return in_array( $policy, array( 'static', 'full', 'none' ), true ) ? $policy : 'static';
593+
$policy = isset( $args['script_policy'] ) ? (string) $args['script_policy'] : 'inert';
594+
return 'isolated_preview' === $policy && ! empty( $args['client_script_isolated'] ) && ! empty( $args['client_script_provenance'] ) ? 'isolated_preview' : 'inert';
598595
}
599596

600597
/**
601598
* Omit scripts from the frozen server-rendered document unless a caller supplies
602-
* the full runtime-preservation contract.
603-
*
604-
* Full retention remains an explicit compatibility mode for callers that supply
605-
* their own runtime-preservation contract.
599+
* the isolated-preview policy and explicit source provenance.
606600
*
607601
* @return array{html:string,asset_urls:array<int,string>,exclusions:array<int,array<string,string>>}
608602
*/
@@ -617,7 +611,7 @@ static function ( array $matches ) use ( $base_url, $policy, &$asset_urls, &$exc
617611
$type = strtolower( trim( (string) self::tag_attribute_value( $tag, 'type' ) ) );
618612
$kind = null === $source ? 'inline' : 'external';
619613
$is_data = in_array( $type, array( 'application/json', 'application/ld+json', 'application/manifest+json' ), true );
620-
$keep = 'full' === $policy;
614+
$keep = 'isolated_preview' === $policy;
621615
if ( $keep ) {
622616
if ( 'external' === $kind ) {
623617
$url = self::resolve_url( (string) $source, $base_url );
@@ -630,7 +624,7 @@ static function ( array $matches ) use ( $base_url, $policy, &$asset_urls, &$exc
630624

631625
$exclusion = array(
632626
'kind' => $kind,
633-
'reason_code' => 'none' === $policy ? 'script_omitted_by_caller_policy' : ( $is_data ? 'data_script_omitted_from_static_artifact' : 'script_omitted_without_runtime_declaration' ),
627+
'reason_code' => $is_data ? 'data_script_quarantined_by_inert_policy' : 'script_dropped_by_inert_policy',
634628
'sha256' => hash( 'sha256', $matches[0] ),
635629
'type' => '' !== $type ? $type : 'classic',
636630
);

includes/class-static-site-importer-website-artifact-import-input.php

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@ class Static_Site_Importer_Website_Artifact_Import_Input {
4444
'compiler_options' => array( 'type' => 'object' ),
4545
'source_metadata' => array( 'type' => 'object' ),
4646
'validation_artifacts' => array( 'type' => 'object' ),
47+
'client_script_policy' => array( 'type' => 'string', 'enum' => array( 'inert', 'isolated_preview' ) ),
48+
'client_script_provenance' => array( 'type' => 'object' ),
49+
'client_script_isolated' => array( 'type' => 'boolean' ),
4750
);
4851

4952
/**
@@ -78,6 +81,9 @@ public static function normalize( array $input, array $defaults = array() ): arr
7881
'compiler_options' => array(),
7982
'source_metadata' => array(),
8083
'validation_artifacts' => array(),
84+
'client_script_policy' => 'inert',
85+
'client_script_provenance' => array(),
86+
'client_script_isolated' => false,
8187
),
8288
$defaults
8389
);
@@ -88,13 +94,13 @@ public static function normalize( array $input, array $defaults = array() ): arr
8894
}
8995
}
9096

91-
foreach ( array( 'slug', 'name', 'site_title', 'stale_page_action', 'report', 'asset_materialization_policy' ) as $field ) {
97+
foreach ( array( 'slug', 'name', 'site_title', 'stale_page_action', 'report', 'asset_materialization_policy', 'client_script_policy' ) as $field ) {
9298
$values[ $field ] = is_scalar( $values[ $field ] ) ? (string) $values[ $field ] : '';
9399
}
94-
foreach ( array( 'activate', 'overwrite', 'disable_smilies', 'fail_on_quality', 'allow_missing_woocommerce', 'allow_missing_jetpack', 'materialize_dependencies', 'require_proven_dynamic_client_assets', 'seed_entities', 'write_theme_report_artifacts' ) as $field ) {
100+
foreach ( array( 'activate', 'overwrite', 'disable_smilies', 'fail_on_quality', 'allow_missing_woocommerce', 'allow_missing_jetpack', 'materialize_dependencies', 'require_proven_dynamic_client_assets', 'seed_entities', 'write_theme_report_artifacts', 'client_script_isolated' ) as $field ) {
95101
$values[ $field ] = (bool) $values[ $field ];
96102
}
97-
foreach ( array( 'products_manifest', 'commerce_context', 'asset_map', 'compiler_options', 'source_metadata', 'validation_artifacts' ) as $field ) {
103+
foreach ( array( 'products_manifest', 'commerce_context', 'asset_map', 'compiler_options', 'source_metadata', 'validation_artifacts', 'client_script_provenance' ) as $field ) {
98104
$values[ $field ] = is_array( $values[ $field ] ) ? $values[ $field ] : array();
99105
}
100106

includes/rest.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -654,6 +654,8 @@ function static_site_importer_rest_should_apply_to_current_site( array $params )
654654
function static_site_importer_rest_open_in_playground( array $source, array $input ) {
655655
$input['activate'] = true;
656656
$input['overwrite'] = true;
657+
// This request is serialized into a disposable Playground runtime, never this site.
658+
$input['client_script_isolated'] = true;
657659

658660
$runtime = static_site_importer_rest_source_runtime( $source, $input );
659661
if ( is_wp_error( $runtime ) ) {
@@ -777,6 +779,10 @@ function static_site_importer_build_playground_preview( array $artifact, array $
777779
* @return array<string,mixed>|WP_Error
778780
*/
779781
function static_site_importer_rest_apply_to_current_site( array $source, array $input ) {
782+
// Current-site materialization is always inert even when a request carries preview options.
783+
$input['client_script_policy'] = 'inert';
784+
$input['client_script_isolated'] = false;
785+
$input['client_script_provenance'] = array();
780786
$decorate_current_site_preview = static function ( $result ) {
781787
if ( ! is_array( $result ) ) {
782788
return $result;

static-site-importer.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848

4949
require_once STATIC_SITE_IMPORTER_PATH . 'includes/class-static-site-importer-site-identity.php';
5050
require_once STATIC_SITE_IMPORTER_PATH . 'includes/class-static-site-importer-website-artifact-import-input.php';
51+
require_once STATIC_SITE_IMPORTER_PATH . 'includes/class-static-site-importer-client-script-policy.php';
5152
require_once STATIC_SITE_IMPORTER_PATH . 'includes/class-static-site-importer-document.php';
5253
require_once STATIC_SITE_IMPORTER_PATH . 'includes/class-static-site-importer-source-page.php';
5354
require_once STATIC_SITE_IMPORTER_PATH . 'includes/class-static-site-importer-url-fetcher.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-client-script-policy.php", "environment": "standalone-php" },
1516
{ "path": "tests/smoke-content-only-policy.php", "environment": "standalone-php" },
1617
{ "path": "tests/smoke-companion-plugin-js.php", "environment": "standalone-php" },
1718
{ "path": "tests/smoke-companion-plugin.php", "environment": "standalone-php" },

0 commit comments

Comments
 (0)