Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
964 changes: 943 additions & 21 deletions src/wp-includes/interactivity-api/class-wp-interactivity-api.php

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<?php
/**
* Interactivity API: PHP token constant shims.
*
* The Interactivity API's server-side expression validator inspects a token
* stream produced by `token_get_all()` and rejects dangerous constructs by
* comparing each token's ID against a reject-list of `T_*` constants. Several
* of those constants were introduced after PHP 7.4, which is the minimum
* supported version for WordPress 7.0. On PHP 7.4 those constants are
* undefined and would raise "Use of undefined constant" notices, so this file
* defines them with large sentinel integer values that cannot collide with
* any real token ID produced by PHP's lexer.
*
* The strategy follows the recommendation in the PHP manual's "List of Parser
* Tokens" (see `phptokens.md`): use `defined() || define()` with large
* integers. The sentinel values are intentionally spaced far apart from one
* another and from PHP's actual token values (which historically occupy the
* low hundreds) so that they cannot be misidentified on any PHP version that
* does define them natively — `defined()` short-circuits before `define()` on
* versions where the constant already exists.
*
* @package WordPress
* @subpackage Interactivity API
* @since 6.9.0
*/

if ( ! defined( 'ABSPATH' ) ) {
exit;
}

/*
* Each line targets one PHP-version-gated token that the Interactivity API
* validator references. The `defined() || define()` pattern is a no-op on PHP
* versions where the constant already exists, and provides a stable sentinel
* on versions where it does not.
*
* Sentinel values are grouped by introducing PHP version for clarity. Do not
* change a sentinel once assigned: doing so could break existing serialized
* state that referenced those values, and the validation logic relies on
* `in_array( $token_id, $dangerous, true )` matching the same integer that
* `token_get_all()` returns (which is the native value on PHP versions where
* the constant exists, and the sentinel here on versions where it does not).
*/

// PHP 8.0.0.
defined( 'T_NAME_FULLY_QUALIFIED' ) || define( 'T_NAME_FULLY_QUALIFIED', 10001 );
defined( 'T_NAME_QUALIFIED' ) || define( 'T_NAME_QUALIFIED', 10002 );
defined( 'T_NAME_RELATIVE' ) || define( 'T_NAME_RELATIVE', 10003 );
defined( 'T_MATCH' ) || define( 'T_MATCH', 10004 );
defined( 'T_NULLSAFE_OBJECT_OPERATOR' ) || define( 'T_NULLSAFE_OBJECT_OPERATOR', 10005 );
defined( 'T_ATTRIBUTE' ) || define( 'T_ATTRIBUTE', 10006 );

// PHP 8.1.0.
defined( 'T_READONLY' ) || define( 'T_READONLY', 10011 );
defined( 'T_ENUM' ) || define( 'T_ENUM', 10012 );
defined( 'T_AMPERSAND_FOLLOWED_BY_VAR_OR_VARARG' ) || define( 'T_AMPERSAND_FOLLOWED_BY_VAR_OR_VARARG', 10013 );
defined( 'T_AMPERSAND_NOT_FOLLOWED_BY_VAR_OR_VARARG' ) || define( 'T_AMPERSAND_NOT_FOLLOWED_BY_VAR_OR_VARARG', 10014 );

// PHP 8.4.0.
defined( 'T_PROPERTY_C' ) || define( 'T_PROPERTY_C', 10021 );
defined( 'T_PRIVATE_SET' ) || define( 'T_PRIVATE_SET', 10022 );
defined( 'T_PROTECTED_SET' ) || define( 'T_PROTECTED_SET', 10023 );
defined( 'T_PUBLIC_SET' ) || define( 'T_PUBLIC_SET', 10024 );

// PHP 8.5.0.
defined( 'T_PIPE' ) || define( 'T_PIPE', 10031 );
defined( 'T_VOID_CAST' ) || define( 'T_VOID_CAST', 10032 );
2 changes: 2 additions & 0 deletions src/wp-settings.php
Original file line number Diff line number Diff line change
Expand Up @@ -445,8 +445,10 @@
require ABSPATH . WPINC . '/fonts.php';
require ABSPATH . WPINC . '/class-wp-script-modules.php';
require ABSPATH . WPINC . '/script-modules.php';
require ABSPATH . WPINC . '/interactivity-api/interactivity-api-token-shims.php';
require ABSPATH . WPINC . '/interactivity-api/class-wp-interactivity-api.php';
require ABSPATH . WPINC . '/interactivity-api/class-wp-interactivity-api-directives-processor.php';
require ABSPATH . WPINC . '/interactivity-api/class-wp-interactivity-expression-evaluator.php';
require ABSPATH . WPINC . '/interactivity-api/interactivity-api.php';
require ABSPATH . WPINC . '/class-wp-plugin-dependencies.php';
require ABSPATH . WPINC . '/class-wp-url-pattern-prefixer.php';
Expand Down
113 changes: 113 additions & 0 deletions tests/phpunit/development/regex-comparison.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
<?php
/**
* Regex comparison test for the Interactivity API expression splitting.
*
* Compares two regex variations for the `splitStatements` /
* `split_expression_into_statements` helper:
*
* - PHP version: matches escape sequences (\" , \/ , \' , \` ) as atomic
* two-character units. Faster in PCRE, more precise on edge cases.
* - JS-equivalent version: matches bare delimiter characters (/ , " , ' , ` ),
* relying on greedy backtracking. This matches the upstream Datastar
* genRx() regex.
*
* Usage: php tests/phpunit/development/regex-comparison.php
*
* @package WordPress
* @subpackage Interactivity API
*/

$php_re = '/(?:\/(?:\\\\\/|[^\/])*\/|"(?:\\\\"|[^"])*"|\'(?:\\\\\'|[^\'])*\'|`(?:\\\\`|[^`])*`|\(\s*(?:(?:function)\s*\(\s*\)|(?:\(\s*\))\s*=>)\s*(?:\{[\s\S]*?\}|[^;){]*)\s*\)\s*\(\s*\)|[^;])+/';

$js_re = '/(?:\/(?:\/|[^\/])*\/|"(?:\"|[^"])*"|\'(?:\'|[^\'])*\'|`(?:`|[^`])*`|\(\s*(?:(?:function)\s*\(\s*\)|(?:\(\s*\))\s*=>)\s*(?:\{[\s\S]*?\}|[^;){]*)\s*\)\s*\(\s*\)|[^;])+/';

define( 'ITER', 100000 );

function compare( $input ) {
global $php_re, $js_re;
preg_match_all( $php_re, $input, $p );
preg_match_all( $js_re, $input, $j );
$php = $p[0] ?? array();
$js = $j[0] ?? array();
return array( $php, $js, $php === $js );
}

function run_test( $input, $label ) {
[$php, $js, $match] = compare( $input );
echo ( $match ? 'OK' : 'MISMATCH' ) . ' ' . $label . "\n";
if ( ! $match ) {
echo ' PHP: ' . json_encode( $php ) . "\n JS: " . json_encode( $js ) . "\n";
}
return $match;
}

function bench( string $label, string $regex, string $input ): float {
$t = microtime( true );
for ( $i = 0; $i < ITER; $i++ ) {
preg_match_all( $regex, $input, $m );
}
$elapsed = ( microtime( true ) - $t ) * 1000;
printf( "%s: %.1fms\n", $label, $elapsed );
return $elapsed;
}

$perf_input = "state.count > 0 ? 'yes;no' : 'maybe;not'; /foo\\/bar/g; `hello;world`; (() => { return 1; })(); done";

/* ───────────────────────────────────────────────────────────
* Test 1: Common directive expressions
* ─────────────────────────────────────────────────────────── */
$common = array( 'state.count; state.flag', '"hello;world"; foo', "'a;b'; c", '`x;y`; z', '/a;b/; c', 'a/2; b', "state.count === 0 ? 'no' : 'yes'", '(() => { const x = 1; return x; })(); done' );

echo "=== Common ===\n";

foreach ( $common as $e ) {
run_test( $e, substr( $e, 0, 50 ) );
}

/* ───────────────────────────────────────────────────────────
* Test 2: Edge cases with escaped delimiters
* ─────────────────────────────────────────────────────────── */
$edge = array( '/foo\/bar;baz/g', '"hello \"world;foo\""; x', "'it\'s;ok'; y", '`back\`tick;z`; w', '/foo\\\\/bar;baz/', '/a\/b\/c;d/g', '"a\"b;c\"d"; e' );

echo "\n=== Edge ===\n";

foreach ( $edge as $e ) {
run_test( $e, substr( $e, 0, 60 ) );
}

/* ───────────────────────────────────────────────────────────
* Test 3: Randomized fuzz (10 000 inputs)
* ─────────────────────────────────────────────────────────── */
echo "\n=== Fuzz (10000 random expressions) ===\n";

$chars = 'abcdefghijklmnopqrstuvwxyz0123456789;./\\\'"`(){}[]=+-*&|<> ';
$mm = 0;

for ( $i = 0; $i < 10000; $i++ ) {
$len = 5 + random_int( 0, 40 );
$e = '';
for ( $j = 0; $j < $len; $j++ ) {
$e .= $chars[ random_int( 0, strlen( $chars ) - 1 ) ];
}
[$php, $js, $match] = compare( $e );
if ( ! $match ) {
++$mm;
if ( $mm <= 3 ) {
echo 'MISMATCH: ' . json_encode( $e ) . "\n";
echo ' PHP: ' . json_encode( $php ) . "\n JS: " . json_encode( $js ) . "\n";
}
}
}

echo "Mismatches: $mm / 10000\n";

/* ───────────────────────────────────────────────────────────
* Test 4: PHP vs JS-equivalent pattern performance
* ─────────────────────────────────────────────────────────── */
echo "\n=== Performance (" . ITER . " iterations) ===\n";

$tp = bench( 'PHP version', $php_re, $perf_input );
$tj = bench( 'JS version', $js_re, $perf_input );

$d = ( $tj - $tp ) / $tp * 100;
printf( "Delta: %.1f%% (%s)\n", abs( $d ), $d > 0 ? 'PHP faster' : 'JS faster' );
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
<?php
/**
* Unit tests for WP_Interactivity_API::evaluate_expression_safety().
*
* @package WordPress
* @subpackage Interactivity API
* @since 6.9.0
*
* @group interactivity-api
*
* @coversDefaultClass WP_Interactivity_API
*/
class Tests_Interactivity_API_EvaluateExpressionSafety extends WP_UnitTestCase {
/**
* Instance under test.
*
* @var WP_Interactivity_API
*/
protected $interactivity;

/**
* Set up.
*/
public function set_up() {
parent::set_up();
$this->interactivity = new WP_Interactivity_API();
}

/**
* Invokes the private evaluate_expression_safety() helper.
*
* @param string $php_expr Post-regex-transform PHP expression.
* @return int 1 (VALID), 0 (UNSUPPORTED), -1 (INVALID).
*/
private function evaluate_expression_safety( string $php_expr ): int {
$method = new ReflectionMethod( $this->interactivity, 'evaluate_expression_safety' );
if ( PHP_VERSION_ID < 80100 ) {
$method->setAccessible( true );
}
return $method->invoke( $this->interactivity, $php_expr );
}

/**
* @ticket 60356
*/
public function test_valid_read_only_expressions() {
$this->assertSame( 1, $this->evaluate_expression_safety( '$__st[\'count\'] !== $__ctx[\'n\']' ) );
$this->assertSame( 1, $this->evaluate_expression_safety( '$__st[\'count\'] > 0 ? $__st[\'name\'] : \'no\'' ) );
$this->assertSame( 1, $this->evaluate_expression_safety( 'true && false' ) );
$this->assertSame( 1, $this->evaluate_expression_safety( 'null' ) );
}

/**
* @ticket 60356
*/
public function test_unsupported_assignments_and_calls() {
$this->assertSame( 0, $this->evaluate_expression_safety( '$__st[\'x\'] = 5' ) );
$this->assertSame( 0, $this->evaluate_expression_safety( '$__st[\'count\'] += 1' ) );
$this->assertSame( 0, $this->evaluate_expression_safety( '$__st[\'count\']++' ) );
$this->assertSame( 0, $this->evaluate_expression_safety( 'doSomething()' ) );
$this->assertSame( 0, $this->evaluate_expression_safety( '$__st[\'zero\'] ??= 1' ) );
$this->assertSame( 0, $this->evaluate_expression_safety( 'customConstant' ) );
}

/**
* Representative dangerous and PHP-specific constructs should all be
* rejected as INVALID so they never reach eval().
*
* @ticket 60356
*
* @return array<string, array{0:string}>
*/
public function data_invalid_dangerous_or_php_specific_constructs(): array {
return array(
'object operator' => array( '$__st->method()' ),
'nullsafe object operator' => array( '$__st?->method()' ),
'fully qualified static call' => array( '\\Foo\\Bar::baz()' ),
'qualified static call' => array( 'Foo\\Bar::baz()' ),
'relative static call' => array( 'namespace\\Foo::baz()' ),
'nested eval' => array( 'eval("bad")' ),
'exit' => array( 'exit(1)' ),
'include' => array( 'include "file.php"' ),
'include once' => array( 'include_once "file.php"' ),
'require' => array( 'require "file.php"' ),
'require once' => array( 'require_once "file.php"' ),
'new' => array( 'new stdClass()' ),
'clone' => array( 'clone $__st' ),
'function literal' => array( 'function() { return 1; }' ),
'arrow function literal' => array( 'fn() => 1' ),
'echo' => array( 'echo 1' ),
'print' => array( 'print 1' ),
'unset' => array( 'unset($__st["x"])' ),
'throw' => array( 'throw new Exception()' ),
'global' => array( 'global $foo' ),
'static' => array( 'static $foo' ),
'return' => array( 'return $__st["x"]' ),
'yield' => array( 'yield 1' ),
'yield from' => array( 'yield from array(1,2)' ),
'attribute' => array( '#[Attr] 1' ),
'spaceship' => array( '$__st["a"] <=> $__st["b"]' ),
'php array syntax' => array( 'array(1, 2, 3)' ),
'php double arrow' => array( 'array("k" => 1)' ),
'logical and keyword' => array( '$__ctx["x"] and $__ctx["y"]' ),
'logical or keyword' => array( '$__ctx["x"] or $__ctx["y"]' ),
'logical xor keyword' => array( '$__ctx["x"] xor $__ctx["y"]' ),
'array cast' => array( '(array)$__ctx["n"]' ),
'bool cast' => array( '(bool)$__ctx["n"]' ),
'int cast' => array( '(int)$__ctx["n"]' ),
'double cast' => array( '(float)$__ctx["n"]' ),
'object cast' => array( '(object)$__ctx["n"]' ),
'string cast' => array( '(string)$__ctx["n"]' ),
'unset cast' => array( '(unset)$__ctx["n"]' ),
'empty construct' => array( 'empty($__ctx["x"])' ),
'isset construct' => array( 'isset($__ctx["x"])' ),
'concat dot' => array( '$__st["a"] . $__st["b"]' ),
'concat equal' => array( '$__st["a"] .= $__st["b"]' ),
'backticks' => array( '`id`' ),
'magic constant' => array( '__FILE__' ),
'magic dir constant' => array( '__DIR__' ),
'magic line constant' => array( '__LINE__' ),
'match expression' => array( 'match ($__st["count"]) { 1 => 1, default => 0 }' ),
'list destructuring' => array( 'list($__st["a"]) = array(1)' ),
'php close tag' => array( '1 ?>' ),
);
}

/**
* @ticket 60356
*
* @dataProvider data_invalid_dangerous_or_php_specific_constructs
*/
public function test_invalid_dangerous_or_php_specific_constructs( string $php_expr ) {
$this->assertSame( -1, $this->evaluate_expression_safety( $php_expr ) );
}

/**
* @ticket 60356
*
* Regression for the token_get_all() open-tag bug: without prepending
* `<?php ` a valid expression tokenizes as a single T_INLINE_HTML token.
*/
public function test_valid_expression_is_parsed_as_php_code_not_inline_html() {
$this->assertSame( 1, $this->evaluate_expression_safety( '$__st[\'count\'] !== $__ctx[\'n\']' ) );
}
}
Loading
Loading