Skip to content
Merged
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
14 changes: 6 additions & 8 deletions packages/mysql-on-sqlite/src/mysql/class-wp-mysql-lexer.php
Original file line number Diff line number Diff line change
Expand Up @@ -2825,8 +2825,6 @@ private function read_number(): ?int {
* Rules:
* 1. Quotes can be escaped by doubling them ('', "", ``).
* 2. Backslashes escape the next character, unless NO_BACKSLASH_ESCAPES is set.
*
* @param string $quote The quote character - ', ", or `.
*/
private function read_quoted_text(): ?int {
$quote = $this->sql[ $this->bytes_already_read ];
Expand All @@ -2842,6 +2840,11 @@ private function read_quoted_text(): ?int {
while ( true ) {
$at += strcspn( $this->sql, $quote, $at );

// Unclosed string - unexpected EOF.
if ( ( $this->sql[ $at ] ?? null ) !== $quote ) {
return null; // Invalid input.
}

/*
* By default, quotes can be escaped with a "\".
* When NO_BACKSLASH_ESCAPES SQL mode is active, the "\" treated as
Expand All @@ -2852,18 +2855,13 @@ private function read_quoted_text(): ?int {
* "\\\" is an escaped backslash and an escape sequence, and so on.
*/
if ( ! $no_backslash_escapes ) {
for ($i = 0; '\\' === $this->sql[ $at - $i - 1 ]; $i += 1);
for ( $i = 0; ( $at - $i - 1 ) >= 0 && '\\' === $this->sql[ $at - $i - 1 ]; $i += 1 );
if ( 1 === $i % 2 ) {
$at += 1;
continue;
}
}

// Unclosed string - unexpected EOF.
if ( ( $this->sql[ $at ] ?? null ) !== $quote ) {
return null; // Invalid input.
}

// Check if the quote is doubled.
if ( ( $this->sql[ $at + 1 ] ?? null ) === $quote ) {
$at += 2;
Expand Down
109 changes: 109 additions & 0 deletions packages/mysql-on-sqlite/tests/mysql/WP_MySQL_Lexer_Tests.php
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,115 @@ public function data_identifier_or_number(): array {
);
}

/**
* Test that unclosed quoted strings with trailing backslashes do not
* cause out-of-bounds string access in read_quoted_text().
*
* The backslash-counting loop walks backward from the position returned
* by strcspn(). When the closing quote is missing, strcspn() returns
* the remaining string length. If the last byte is a backslash, the
* loop treats the absent quote as escaped and advances past the end
* of the string. On the next iteration, the loop accesses an invalid
* string offset, triggering "Uninitialized string offset" warnings.
*
* @dataProvider data_unclosed_strings_with_backslashes
*/
public function test_unclosed_string_with_trailing_backslash( string $sql ): void {
set_error_handler(
function ( $severity, $message, $file, $line ) {
throw new \ErrorException( $message, 0, $severity, $file, $line );
},
E_WARNING | E_NOTICE
);

try {
$lexer = new WP_MySQL_Lexer( $sql );
while ( $lexer->next_token() ) {
// Consume all tokens.
}
} finally {
restore_error_handler();
}

// If we reach here without an ErrorException, no OOB access occurred.
$this->assertNull( $lexer->get_token() );
}

public function data_unclosed_strings_with_backslashes(): array {
return array(
'single-quoted trailing backslash' => array( "SELECT '\\" ),
'double-quoted trailing backslash' => array( 'SELECT "\\' ),
'even trailing backslashes' => array( "SELECT '\\\\" ),
'odd trailing backslashes' => array( "SELECT '\\\\\\" ),
'backslash-only single-quoted' => array( "'\\" ),
'backslash-only double-quoted' => array( '"\\' ),
);
}

/**
* Regression: valid strings with escapes must still tokenize correctly.
*
* @dataProvider data_valid_escaped_strings
*/
public function test_valid_escaped_string( string $sql, int $expected_token_id ): void {
$lexer = new WP_MySQL_Lexer( $sql );
$this->assertTrue( $lexer->next_token() );
$this->assertSame( $expected_token_id, $lexer->get_token()->id );
}

public function data_valid_escaped_strings(): array {
return array(
'escaped single quote' => array( "'it\\'s'", WP_MySQL_Lexer::SINGLE_QUOTED_TEXT ),
'trailing escaped backslash' => array( "'path\\\\'", WP_MySQL_Lexer::SINGLE_QUOTED_TEXT ),
'doubled single quote' => array( "'it''s'", WP_MySQL_Lexer::SINGLE_QUOTED_TEXT ),
'empty single-quoted string' => array( "''", WP_MySQL_Lexer::SINGLE_QUOTED_TEXT ),
'escaped double quote' => array( '"col\\"name"', WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT ),
'backtick identifier' => array( '`my_column`', WP_MySQL_Lexer::BACK_TICK_QUOTED_ID ),
);
}

/**
* Test that a chunk boundary splitting a quoted string with a trailing
* backslash does not cause an out-of-bounds string access.
*
* This simulates streaming SQL processing where a buffer boundary falls
* inside a string literal right after a backslash escape character.
*/
public function test_chunk_boundary_inside_escaped_string(): void {
set_error_handler(
function ( $severity, $message, $file, $line ) {
throw new \ErrorException( $message, 0, $severity, $file, $line );
},
E_WARNING | E_NOTICE
);

try {
// Build a SQL string where a backslash falls at the chunk boundary.
// The string content before the boundary is padded to place the
// backslash at exactly position $chunk_size - 1.
$chunk_size = 8192;

// "SELECT '" = 8 bytes, so we need chunk_size - 8 - 1 bytes of
// padding before the trailing backslash to place '\' at the last
// byte of the chunk.
$padding = str_repeat( 'A', $chunk_size - 8 - 1 );
$sql = "SELECT '" . $padding . '\\';

// The chunk is exactly $chunk_size bytes. The last byte is '\'.
// The lexer should handle this as an unclosed string without OOB.
$this->assertSame( $chunk_size, strlen( $sql ) );

$lexer = new WP_MySQL_Lexer( $sql );
while ( $lexer->next_token() ) {
// Consume all tokens.
}
} finally {
restore_error_handler();
}

$this->assertNull( $lexer->get_token() );
}

private function get_token_names( array $token_types ): array {
return array_map(
function ( $token_type ) {
Expand Down
Loading