From 464919490ff6fb7054d1451a23752eda42891255 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Apr 2026 12:27:19 +0000 Subject: [PATCH 1/4] Initial plan From df6c042e041c0195b02a9f40fcc1859fe7a7dc30 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Apr 2026 12:36:23 +0000 Subject: [PATCH 2/4] Replace eval() in extract_subdir_path() with safe recursive path parser Agent-Logs-Url: https://github.com/wp-cli/wp-cli/sessions/8fd3eb62-47e5-402c-83f5-905362601e8d Co-authored-by: swissspidy <841956+swissspidy@users.noreply.github.com> --- features/config.feature | 7 ++ php/WP_CLI/Runner.php | 208 +++++++++++++++++++++++++++++++++++++++- tests/RunnerTest.php | 64 +++++++++++++ 3 files changed, 278 insertions(+), 1 deletion(-) create mode 100644 tests/RunnerTest.php diff --git a/features/config.feature b/features/config.feature index 07fec14ced..c2a927d176 100644 --- a/features/config.feature +++ b/features/config.feature @@ -79,6 +79,13 @@ Feature: Have a config file When I run `wp core is-installed` Then STDOUT should be empty + Given an index.php file: + """ + = $len || '.' !== $expr[ $pos ] ) { + break; + } + ++$pos; // Consume '.'. + + // Skip whitespace. + while ( $pos < $len && ctype_space( $expr[ $pos ] ) ) { + ++$pos; + } + + $next = self::parse_path_term( $expr, $pos ); + if ( false === $next ) { + return false; + } + $result .= $next; + } + + return $result; + } + + /** + * Parse a single path term: dirname( expr ) or a quoted string literal. + * + * @param string $expr The full expression string. + * @param int &$pos Current offset, advanced as tokens are consumed. + * @return string|false + */ + private static function parse_path_term( $expr, &$pos ) { + $len = strlen( $expr ); + + // Skip leading whitespace. + while ( $pos < $len && ctype_space( $expr[ $pos ] ) ) { + ++$pos; + } + + if ( $pos >= $len ) { + return false; + } + + // Match dirname(...) — case-insensitive to mirror PHP semantics. + if ( 0 === strncasecmp( substr( $expr, $pos ), 'dirname', 7 ) ) { + $save_pos = $pos; + $pos += 7; + + while ( $pos < $len && ctype_space( $expr[ $pos ] ) ) { + ++$pos; + } + if ( $pos >= $len || '(' !== $expr[ $pos ] ) { + $pos = $save_pos; + return false; + } + ++$pos; // Consume '('. + + $inner = self::parse_concat_expr( $expr, $pos ); + if ( false === $inner ) { + $pos = $save_pos; + return false; + } + + while ( $pos < $len && ctype_space( $expr[ $pos ] ) ) { + ++$pos; + } + if ( $pos >= $len || ')' !== $expr[ $pos ] ) { + $pos = $save_pos; + return false; + } + ++$pos; // Consume ')'. + + return dirname( $inner ); + } + + // Match a quoted string literal. + return self::parse_path_string( $expr, $pos ); + } + + /** + * Parse a single- or double-quoted PHP string literal. + * + * Handles the escape sequences recognised by PHP for each quote style. + * + * @param string $expr The full expression string. + * @param int &$pos Current offset, advanced past the closing quote. + * @return string|false String value, or false if not a valid literal. + */ + private static function parse_path_string( $expr, &$pos ) { + $len = strlen( $expr ); + if ( $pos >= $len ) { + return false; + } + + $quote = $expr[ $pos ]; + if ( "'" !== $quote && '"' !== $quote ) { + return false; + } + + $save_pos = $pos; + ++$pos; // Consume opening quote. + $result = ''; + + while ( $pos < $len ) { + $ch = $expr[ $pos ]; + + if ( $ch === $quote ) { + ++$pos; // Consume closing quote. + return $result; + } + + if ( '\\' === $ch && ( $pos + 1 ) < $len ) { + $next = $expr[ $pos + 1 ]; + $pos += 2; + + if ( "'" === $quote ) { + // Single-quoted strings: only \\ and \' are escape sequences. + if ( '\\' === $next || "'" === $next ) { + $result .= $next; + } else { + $result .= '\\' . $next; + } + } else { + // Double-quoted strings: handle the common escape sequences. + switch ( $next ) { + case 'n': + $result .= "\n"; + break; + case 'r': + $result .= "\r"; + break; + case 't': + $result .= "\t"; + break; + case '"': + case '\\': + case '$': + $result .= $next; + break; + default: + $result .= '\\' . $next; + break; + } + } + continue; + } + + $result .= $ch; + ++$pos; + } + + // Unterminated string literal. + $pos = $save_pos; + return false; + } + /** * Find the directory that contains the WordPress files. * Defaults to the current working dir. diff --git a/tests/RunnerTest.php b/tests/RunnerTest.php new file mode 100644 index 0000000000..6e1ea132ff --- /dev/null +++ b/tests/RunnerTest.php @@ -0,0 +1,64 @@ +getMethod( 'safe_parse_path' ); + + $this->assertSame( $expected, $method->invoke( null, $expression ) ); + } + + public static function dataSafeParsePath(): array { + return [ + // Simple single-quoted relative paths. + [ "'./foo'", './foo' ], + [ " './foo' ", './foo' ], + + // Simple double-quoted paths. + [ '"./foo"', './foo' ], + [ '"/absolute/path"', '/absolute/path' ], + + // String concatenation. + [ "'/base' . '/sub'", '/base/sub' ], + [ "'/base' . '/sub' . '/deep'", '/base/sub/deep' ], + + // dirname() with a single-quoted string. + [ "dirname('/path/to/index.php') . '/wp'", '/path/to/wp' ], + [ "dirname( '/path/to/index.php' ) . '/wp'", '/path/to/wp' ], + + // dirname() with a double-quoted string. + [ 'dirname("/path/to/index.php") . "/wp"', '/path/to/wp' ], + + // Nested dirname(). + [ "dirname(dirname('/path/to/index.php')) . '/wp'", '/path/wp' ], + + // Single-quoted escape sequences. + [ "'foo\\'s'", "foo's" ], + [ "'back\\\\slash'", 'back\\slash' ], + + // Double-quoted escape sequences. + [ '"tab\\there"', "tab\there" ], + + // Malicious / unsupported expressions must return false. + [ 'system("id")', false ], + [ 'SOME_CONSTANT', false ], + [ "'path' + '/foo'", false ], + [ '', false ], + ]; + } +} From 8c195352cf51162044baf5514e7aaabb52fde5d0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Apr 2026 12:38:02 +0000 Subject: [PATCH 3/4] Address code review: use consistent pos --- php/WP_CLI/Runner.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/php/WP_CLI/Runner.php b/php/WP_CLI/Runner.php index d5c70a41bf..cd813d45c1 100644 --- a/php/WP_CLI/Runner.php +++ b/php/WP_CLI/Runner.php @@ -412,7 +412,7 @@ private static function safe_parse_path( $expression ) { ++$pos; } - if ( $len !== $pos ) { + if ( $pos < $len ) { return false; } @@ -478,7 +478,7 @@ private static function parse_path_term( $expr, &$pos ) { } // Match dirname(...) — case-insensitive to mirror PHP semantics. - if ( 0 === strncasecmp( substr( $expr, $pos ), 'dirname', 7 ) ) { + if ( 0 === strncasecmp( substr( $expr, $pos, 7 ), 'dirname', 7 ) ) { $save_pos = $pos; $pos += 7; From a14f97c5feadafe7ecb23a042bcefa376d803b78 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 27 Apr 2026 20:28:03 +0000 Subject: [PATCH 4/4] Fix failing Behat test: remove --- features/config.feature | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/features/config.feature b/features/config.feature index c2a927d176..2828a86b1d 100644 --- a/features/config.feature +++ b/features/config.feature @@ -81,7 +81,7 @@ Feature: Have a config file Given an index.php file: """ -