Skip to content

Commit aea0747

Browse files
authored
fix(playground): verify preview package integrity (Automattic#857) [AI: OpenAI GPT-5.6 Sol via OpenCode] (Automattic#860)
1 parent 700fcad commit aea0747

5 files changed

Lines changed: 189 additions & 24 deletions

File tree

.github/workflows/release-php-wasm-zstd.yml

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ jobs:
5151
cache: npm
5252
- run: npm ci
5353
- name: Publish the immutable safe blueprint
54+
env:
55+
GH_TOKEN: ${{ github.token }}
5456
run: |
5557
pages_dir="$RUNNER_TEMP/static-site-importer-pages"
5658
if git fetch origin gh-pages; then
@@ -62,14 +64,27 @@ jobs:
6264
fi
6365
touch "$pages_dir/.nojekyll"
6466
mkdir -p "$pages_dir/playground"
67+
package_digest="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$RELEASE_TAG" --jq '.assets[] | select(.name == "static-site-importer.zip") | .digest')"
68+
package_sha256="${package_digest#sha256:}"
69+
if ! printf '%s' "$package_sha256" | grep -Eq '^[a-f0-9]{64}$'; then
70+
echo "Release asset static-site-importer.zip must expose a SHA-256 digest." >&2
71+
exit 1
72+
fi
6573
# shellcheck disable=SC2016
6674
node -e '
6775
const fs = require("node:fs");
68-
const [source, output, tag] = process.argv.slice(1);
76+
const [source, output, tag, digest] = process.argv.slice(1);
6977
const blueprint = JSON.parse(fs.readFileSync(source, "utf8"));
70-
blueprint.steps.find((step) => step.step === "installPlugin").pluginData.url = `https://github.com/Automattic/static-site-importer/releases/download/${tag}/static-site-importer.zip`;
78+
const replace = (value) => value.replaceAll("{{RELEASE_TAG}}", tag).replaceAll("{{PACKAGE_SHA256}}", digest);
79+
for (const step of blueprint.steps) {
80+
if (step.data?.url) step.data.url = replace(step.data.url);
81+
if (step.pluginData?.url) step.pluginData.url = replace(step.pluginData.url);
82+
if (step.pluginData?.path) step.pluginData.path = replace(step.pluginData.path);
83+
if (step.path) step.path = replace(step.path);
84+
if (step.code) step.code = replace(step.code);
85+
}
7186
fs.writeFileSync(output, JSON.stringify(blueprint, null, 2) + "\n");
72-
' docs/playground/blueprint.json "$pages_dir/playground/$RELEASE_TAG.blueprint.json" "$RELEASE_TAG"
87+
' docs/playground/blueprint.json "$pages_dir/playground/$RELEASE_TAG.blueprint.json" "$RELEASE_TAG" "$package_sha256"
7388
git -C "$pages_dir" add .nojekyll "playground/$RELEASE_TAG.blueprint.json"
7489
if ! git -C "$pages_dir" diff --cached --quiet; then
7590
git -C "$pages_dir" -c user.name='github-actions[bot]' -c user.email='41898282+github-actions[bot]@users.noreply.github.com' commit -m "Publish safe Playground blueprint for $RELEASE_TAG"
@@ -85,8 +100,10 @@ jobs:
85100
process.stdin.on("data", (chunk) => body += chunk);
86101
process.stdin.on("end", () => {
87102
const blueprint = JSON.parse(body);
103+
const download = blueprint.steps.find((step) => step.step === "writeFile");
88104
const plugin = blueprint.steps.find((step) => step.step === "installPlugin");
89-
if (plugin?.pluginData?.url !== process.env.EXPECTED_PLUGIN_URL) process.exit(1);
105+
const check = blueprint.steps.find((step) => step.step === "runPHP" && step.code.includes("hash_file"));
106+
if (download?.data?.url !== process.env.EXPECTED_PLUGIN_URL || plugin?.pluginData?.resource !== "vfs" || !check) process.exit(1);
90107
});
91108
'; then exit 0; fi
92109
echo "Waiting for immutable safe blueprint ($attempt/30)"
@@ -130,8 +147,10 @@ jobs:
130147
process.stdin.on("data", (chunk) => body += chunk);
131148
process.stdin.on("end", () => {
132149
const blueprint = JSON.parse(body);
150+
const download = blueprint.steps.find((step) => step.step === "writeFile");
133151
const plugin = blueprint.steps.find((step) => step.step === "installPlugin");
134-
if (plugin?.pluginData?.url !== process.env.EXPECTED_PLUGIN_URL) process.exit(1);
152+
const check = blueprint.steps.find((step) => step.step === "runPHP" && step.code.includes("hash_file"));
153+
if (download?.data?.url !== process.env.EXPECTED_PLUGIN_URL || plugin?.pluginData?.resource !== "vfs" || !check) process.exit(1);
135154
});
136155
'; then exit 0; fi
137156
echo "Waiting for safe README blueprint alias ($attempt/30)"

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@ Static Site Importer is a WordPress plugin. It requires the [Blocks Engine PHP t
1212

1313
The initial `website-artifact-import` profile provides the artifact import, validation, WordPress site-plan materialization, and manifest-inspection abilities. It includes SSI's WordPress/PHP runtime and the Blocks Engine PHP transformer dependency while excluding Figma, tests, tools, docs, Node dependencies, and other development-only trees. The same immutable contract is available from `static-site-importer/get-runtime-package-manifest` for runtime discovery.
1414

15+
## Playground package integrity
16+
17+
Generated Playground previews accept an SSI package only when it declares `url`, `version`, and a SHA-256 `sha256` (or `digest`) value. Production selection accepts either the exact GitHub release asset URL for that version or a content-addressed URL containing the declared digest. Playground downloads the archive to its virtual filesystem, verifies its SHA-256 before `installPlugin`, and records the version, digest, and URL in the preview request provenance.
18+
19+
Hosts provide the package through the `static_site_importer_playground_package` filter or the public blueprint primitive's explicit `package` option. A bundled runtime passes `install => false`; this remains the WordPress Build content-addressed package flow and never downloads a second SSI archive. Development-only package URLs require an explicit `development => true` selection and still require a valid digest. Mutable aliases such as `releases/latest` are rejected.
20+
1521
## Canonical Site Plans
1622

1723
`static-site-importer/materialize-wordpress-site-plan` is the generic plan-only boundary for a `blocks-engine/wordpress-site-plan/v2` produced by Blocks Engine 0.4.4. SSI calls the package's canonical validator and resolver, then owns WordPress/filesystem preflight, materialization, reconciliation, and the `static-site-importer/materialization-receipt/v1` response. It accepts no source HTML or transformer result envelope.

docs/playground/blueprint.json

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,22 @@
1313
"step": "login"
1414
},
1515
{
16-
"step": "installPlugin",
17-
"pluginData": {
16+
"step": "writeFile",
17+
"path": "/tmp/static-site-importer-{{PACKAGE_SHA256}}.zip",
18+
"data": {
1819
"resource": "url",
1920
"url": "https://github.com/Automattic/static-site-importer/releases/download/{{RELEASE_TAG}}/static-site-importer.zip"
21+
}
22+
},
23+
{
24+
"step": "runPHP",
25+
"code": "<?php if ( ! hash_equals( '{{PACKAGE_SHA256}}', hash_file( 'sha256', '/tmp/static-site-importer-{{PACKAGE_SHA256}}.zip' ) ) ) { throw new RuntimeException( 'Static Site Importer package integrity verification failed.' ); } ?>"
26+
},
27+
{
28+
"step": "installPlugin",
29+
"pluginData": {
30+
"resource": "vfs",
31+
"path": "/tmp/static-site-importer-{{PACKAGE_SHA256}}.zip"
2032
},
2133
"options": {
2234
"activate": true,

includes/rest.php

Lines changed: 99 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,14 @@ function static_site_importer_rest_import_figma_file( WP_REST_Request $request )
210210
* @return array<string,mixed>|WP_Error
211211
*/
212212
function static_site_importer_rest_create_playground_open( array $artifact, array $input, string $source = 'upload' ) {
213-
$blueprint = static_site_importer_rest_playground_blueprint( $input );
213+
$package = static_site_importer_playground_package();
214+
if ( is_wp_error( $package ) ) {
215+
return $package;
216+
}
217+
$blueprint = static_site_importer_playground_import_blueprint( $input, array( 'package' => $package ) );
218+
if ( is_wp_error( $blueprint ) ) {
219+
return $blueprint;
220+
}
214221
$blueprint_json = wp_json_encode( $blueprint );
215222
if ( ! is_string( $blueprint_json ) ) {
216223
return new WP_Error( 'static_site_importer_playground_blueprint_encode_failed', __( 'Could not encode the Playground preview blueprint.', 'static-site-importer' ), array( 'status' => 500 ) );
@@ -241,6 +248,7 @@ function static_site_importer_rest_create_playground_open( array $artifact, arra
241248
'blueprint' => array(
242249
'ref' => $ref,
243250
),
251+
'package' => static_site_importer_playground_package_provenance( $package ),
244252
),
245253
);
246254

@@ -263,19 +271,24 @@ function static_site_importer_rest_create_playground_open( array $artifact, arra
263271
*
264272
* The returned steps are:
265273
* - login
266-
* - installPlugin (SSI from GitHub releases) — omitted when $options['install'] is false
274+
* - writeFile + runPHP checksum verification + installPlugin (SSI package) —
275+
* omitted when $options['install'] is false
267276
* - runPHP — runs static_site_importer_ability_import( $input )
268277
*
269278
* Pass `'install' => false` for hosts/runtimes where SSI is already present
270279
* (for example, shipped as a mu-plugin in a sandbox runtime) so the blueprint
271280
* skips the GitHub release install step and imports against the bundled plugin.
272281
*
273282
* @param array<string,mixed> $input Import ability input.
274-
* @param array<string,mixed> $options Optional. { install: bool (default true) }.
275-
* @return array<int,array<string,mixed>>
283+
* @param array<string,mixed> $options Optional. { install: bool (default true), package: array }.
284+
* @return array<int,array<string,mixed>>|WP_Error
276285
*/
277-
function static_site_importer_playground_import_steps( array $input, array $options = array() ): array {
286+
function static_site_importer_playground_import_steps( array $input, array $options = array() ) {
278287
$install = ! array_key_exists( 'install', $options ) || ! empty( $options['install'] );
288+
$package = $install ? static_site_importer_playground_package( $options ) : null;
289+
if ( is_wp_error( $package ) ) {
290+
return $package;
291+
}
279292

280293
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_var_export -- Generates self-contained Playground import code.
281294
$input_literal = var_export( $input, true );
@@ -302,11 +315,24 @@ function static_site_importer_playground_import_steps( array $input, array $opti
302315
);
303316

304317
if ( $install ) {
318+
$package_path = '/tmp/static-site-importer-' . substr( $package['sha256'], 0, 16 ) . '.zip';
319+
$steps[] = array(
320+
'step' => 'writeFile',
321+
'path' => $package_path,
322+
'data' => array(
323+
'resource' => 'url',
324+
'url' => $package['url'],
325+
),
326+
);
327+
$steps[] = array(
328+
'step' => 'runPHP',
329+
'code' => '<?php if ( ! hash_equals( ' . var_export( $package['sha256'], true ) . ', hash_file( "sha256", ' . var_export( $package_path, true ) . ' ) ) ) { throw new RuntimeException( "Static Site Importer package integrity verification failed." ); } ?>',
330+
);
305331
$steps[] = array(
306332
'step' => 'installPlugin',
307333
'pluginData' => array(
308-
'resource' => 'url',
309-
'url' => 'https://github.com/Automattic/static-site-importer/releases/latest/download/static-site-importer.zip',
334+
'resource' => 'vfs',
335+
'path' => $package_path,
310336
),
311337
'options' => array(
312338
'activate' => true,
@@ -332,10 +358,15 @@ function static_site_importer_playground_import_steps( array $input, array $opti
332358
* {@see static_site_importer_rest_playground_blueprint()} returns today.
333359
*
334360
* @param array<string,mixed> $input Import ability input.
335-
* @param array<string,mixed> $options Optional. { install: bool (default true) }.
336-
* @return array<string,mixed>
361+
* @param array<string,mixed> $options Optional. { install: bool (default true), package: array }.
362+
* @return array<string,mixed>|WP_Error
337363
*/
338-
function static_site_importer_playground_import_blueprint( array $input, array $options = array() ): array {
364+
function static_site_importer_playground_import_blueprint( array $input, array $options = array() ) {
365+
$steps = static_site_importer_playground_import_steps( $input, $options );
366+
if ( is_wp_error( $steps ) ) {
367+
return $steps;
368+
}
369+
339370
return array(
340371
'$schema' => 'https://playground.wordpress.net/blueprint-schema.json',
341372
'landingPage' => '/',
@@ -346,7 +377,7 @@ function static_site_importer_playground_import_blueprint( array $input, array $
346377
'features' => array(
347378
'networking' => true,
348379
),
349-
'steps' => static_site_importer_playground_import_steps( $input, $options ),
380+
'steps' => $steps,
350381
);
351382
}
352383

@@ -355,15 +386,69 @@ function static_site_importer_playground_import_blueprint( array $input, array $
355386
*
356387
* Thin REST-internal wrapper retained for backward compatibility. Delegates to
357388
* the public {@see static_site_importer_playground_import_blueprint()} primitive
358-
* with the install step enabled so the public /import preview URL is unchanged.
389+
* with the install step enabled.
359390
*
360391
* @param array<string,mixed> $input Import ability input.
361-
* @return array<string,mixed>
392+
* @return array<string,mixed>|WP_Error
362393
*/
363-
function static_site_importer_rest_playground_blueprint( array $input ): array {
394+
function static_site_importer_rest_playground_blueprint( array $input ) {
364395
return static_site_importer_playground_import_blueprint( $input );
365396
}
366397

398+
/**
399+
* Select an integrity-verified package for a generated Playground preview.
400+
*
401+
* A production package must either be a GitHub release asset pinned to its
402+
* version or a URL whose path embeds the declared SHA-256. Hosts that bundle
403+
* SSI (including WordPress Build) retain the install=false path and never
404+
* download a second package.
405+
*
406+
* @param array<string,mixed> $options Blueprint options.
407+
* @return array{url:string,version:string,sha256:string}|WP_Error
408+
*/
409+
function static_site_importer_playground_package( array $options = array() ) {
410+
$package = isset( $options['package'] ) && is_array( $options['package'] ) ? $options['package'] : null;
411+
if ( null === $package && function_exists( 'apply_filters' ) ) {
412+
$package = apply_filters( 'static_site_importer_playground_package', null, $options );
413+
}
414+
if ( ! is_array( $package ) ) {
415+
return new WP_Error( 'static_site_importer_playground_package_missing', __( 'A pinned, integrity-verified Static Site Importer package is required for Playground previews.', 'static-site-importer' ), array( 'status' => 503 ) );
416+
}
417+
418+
$url = isset( $package['url'] ) ? (string) $package['url'] : '';
419+
$version = isset( $package['version'] ) ? (string) $package['version'] : '';
420+
$sha256 = strtolower( preg_replace( '/^sha256:/i', '', (string) ( $package['sha256'] ?? $package['digest'] ?? '' ) ) );
421+
$is_development = ! empty( $package['development'] );
422+
if ( '' === $url || '' === $version || ! preg_match( '/^[a-f0-9]{64}$/', $sha256 ) || ! filter_var( $url, FILTER_VALIDATE_URL ) ) {
423+
return new WP_Error( 'static_site_importer_playground_package_invalid', __( 'The Static Site Importer Playground package must provide a URL, version, and SHA-256 digest.', 'static-site-importer' ), array( 'status' => 500 ) );
424+
}
425+
426+
$release_asset = 'https://github.com/Automattic/static-site-importer/releases/download/' . rawurlencode( $version ) . '/static-site-importer.zip';
427+
$content_addressed = false !== strpos( strtolower( (string) parse_url( $url, PHP_URL_PATH ) ), $sha256 );
428+
if ( ! $is_development && $url !== $release_asset && ! $content_addressed ) {
429+
return new WP_Error( 'static_site_importer_playground_package_mutable', __( 'Static Site Importer Playground previews require a version-pinned release asset or content-addressed package URL.', 'static-site-importer' ), array( 'status' => 500 ) );
430+
}
431+
432+
return array(
433+
'url' => $url,
434+
'version' => $version,
435+
'sha256' => $sha256,
436+
);
437+
}
438+
439+
/**
440+
* Return package provenance for the current REST preview response.
441+
*
442+
* @return array<string,string>
443+
*/
444+
function static_site_importer_playground_package_provenance( array $package ): array {
445+
return array(
446+
'version' => $package['version'],
447+
'sha256' => $package['sha256'],
448+
'url' => $package['url'],
449+
);
450+
}
451+
367452
/**
368453
* Add CORS headers for the local Figma runner endpoint when explicitly enabled.
369454
*

0 commit comments

Comments
 (0)