Skip to content

Fix unbounded strcspn/strspn scans in decode_string_or_url and decode_escape_at#232

Draft
Copilot wants to merge 2 commits intotrunkfrom
copilot/fix-cssprocessor-decode-string-bug
Draft

Fix unbounded strcspn/strspn scans in decode_string_or_url and decode_escape_at#232
Copilot wants to merge 2 commits intotrunkfrom
copilot/fix-cssprocessor-decode-string-bug

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented Apr 9, 2026

Both decode_string_or_url() and decode_escape_at() called PHP's strcspn/strspn without a length argument, causing each to scan the entire remaining CSS string rather than stopping at the relevant boundary. A min() clamp corrected the result after the fact but left the unnecessary scan in place.

Fixes

  • decode_string_or_url() — pass $end - $at as the fourth argument to strcspn(), eliminating the scan past the token boundary and removing the now-redundant min( $normal_len, $end - $at ) clamp.
  • decode_escape_at() — pass 6 as the fourth argument to strspn() (the CSS spec maximum for hex escape digits), removing the redundant min( $hex_len, 6 ) clamp.
// Before
$normal_len = strcspn( $this->css, $special_chars, $at );
$normal_len = min( $normal_len, $end - $at );   // clamp after over-scanning

$hex_len = strspn( $this->css, '0123456789ABCDEFabcdef', $at );
$hex_len = min( $hex_len, 6 );                  // clamp after over-scanning

// After
$normal_len = strcspn( $this->css, $special_chars, $at, $end - $at );

$hex_len = strspn( $this->css, '0123456789ABCDEFabcdef', $at, 6 );

Tests

  • test_decode_string_or_url_respects_length_boundary — string token containing \41 (triggers the slow path) followed by ; color: red;; asserts the decoded value is exactly helloAworld with no content past the closing quote.
  • test_decode_escape_at_hex_limit_is_six_digits — string token "\0000411rest" where 000041 (6 digits) decodes to A and the trailing 1rest must remain as literal characters, yielding A1rest.

Warning

Firewall rules blocked me from connecting to one or more addresses (expand for details)

I tried to connect to the following addresses, but was blocked by firewall rules:

  • https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/8da41e9a9fe4cc14d23cf7fcef3b80476f54e511
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/DD4Sgt /usr/bin/composer install --quiet (http block)
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/vWOdJf /usr/bin/composer install --global k/php-toolkit/php-toolkit/.git/copilot-hooks .git/ (http block)
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/yRZUE7 /usr/bin/composer install -j DROP committer.email (http block)
  • https://api.github.com/repos/PHPCSStandards/composer-installer/zipball/e9cf5e4bbf7eeaf9ef5db34938942602838fc2b1
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/DD4Sgt /usr/bin/composer install --quiet (http block)
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/vWOdJf /usr/bin/composer install --global k/php-toolkit/php-toolkit/.git/copilot-hooks .git/ (http block)
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/yRZUE7 /usr/bin/composer install -j DROP committer.email (http block)
  • https://api.github.com/repos/PHPCompatibility/PHPCompatibility/zipball/9fb324479acf6f39452e0655d2429cc0d3914243
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/DD4Sgt /usr/bin/composer install --quiet (http block)
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/vWOdJf /usr/bin/composer install --global k/php-toolkit/php-toolkit/.git/copilot-hooks .git/ (http block)
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/yRZUE7 /usr/bin/composer install -j DROP committer.email (http block)
  • https://api.github.com/repos/doctrine/instantiator/zipball/0daa7cfba7b009aa3c95ae585322fb596a4ca2ae
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/DD4Sgt /usr/bin/composer install --quiet (http block)
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/vWOdJf /usr/bin/composer install --global k/php-toolkit/php-toolkit/.git/copilot-hooks .git/ (http block)
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/yRZUE7 /usr/bin/composer install -j DROP committer.email (http block)
  • https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/DD4Sgt /usr/bin/composer install --quiet (http block)
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/vWOdJf /usr/bin/composer install --global k/php-toolkit/php-toolkit/.git/copilot-hooks .git/ (http block)
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/yRZUE7 /usr/bin/composer install -j DROP committer.email (http block)
  • https://api.github.com/repos/nikic/PHP-Parser/zipball/f7c23a43eee861070ab4e88819a4e76a611c7e4f
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/DD4Sgt /usr/bin/composer install --quiet (http block)
  • https://api.github.com/repos/phar-io/manifest/zipball/65f90285728eae4eae313b8b6ba11b2f5436038e
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/DD4Sgt /usr/bin/composer install --quiet (http block)
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/vWOdJf /usr/bin/composer install --global k/php-toolkit/php-toolkit/.git/copilot-hooks .git/ (http block)
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/yRZUE7 /usr/bin/composer install -j DROP committer.email (http block)
  • https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/DD4Sgt /usr/bin/composer install --quiet (http block)
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/vWOdJf /usr/bin/composer install --global k/php-toolkit/php-toolkit/.git/copilot-hooks .git/ (http block)
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/yRZUE7 /usr/bin/composer install -j DROP committer.email (http block)
  • https://api.github.com/repos/phpstan/phpstan/zipball/fcf8b71aeab4e1a1131d1783cef97b23a51b87a9
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/DD4Sgt /usr/bin/composer install --quiet (http block)
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/vWOdJf /usr/bin/composer install --global k/php-toolkit/php-toolkit/.git/copilot-hooks .git/ (http block)
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/yRZUE7 /usr/bin/composer install -j DROP committer.email (http block)
  • https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/ac91f01ccec49fb77bdc6fd1e548bc70f7faa3e5
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/DD4Sgt /usr/bin/composer install --quiet (http block)
  • https://api.github.com/repos/sebastianbergmann/comparator/zipball/67a2df3a62639eab2cc5906065e9805d4fd5dfc5
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/DD4Sgt /usr/bin/composer install --quiet (http block)
  • https://api.github.com/repos/sebastianbergmann/complexity/zipball/25f207c40d62b8b7aa32f5ab026c53561964053a
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/DD4Sgt /usr/bin/composer install --quiet (http block)
  • https://api.github.com/repos/sebastianbergmann/diff/zipball/ba01945089c3a293b01ba9badc29ad55b106b0bc
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/DD4Sgt /usr/bin/composer install --quiet (http block)
  • https://api.github.com/repos/sebastianbergmann/environment/zipball/830c43a844f1f8d5b7a1f6d6076b784454d8b7ed
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/DD4Sgt /usr/bin/composer install --quiet (http block)
  • https://api.github.com/repos/sebastianbergmann/exporter/zipball/78c00df8f170e02473b682df15bfcdacc3d32d72
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/DD4Sgt /usr/bin/composer install --quiet (http block)
  • https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/b4f479ebdbf63ac605d183ece17d8d7fe49c15c7
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/DD4Sgt /usr/bin/composer install --quiet (http block)
  • https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/0448d60087a382392a1b2a1abe434466e03dcc87
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/DD4Sgt /usr/bin/composer install --quiet (http block)
  • https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/38b24367e1b340aa78b96d7cab042942d917bb84
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/DD4Sgt /usr/bin/composer install --quiet (http block)
  • https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/5a10147d0aaf65b58940a0b72f71c9ac0423cc67
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/DD4Sgt /usr/bin/composer install --quiet (http block)
  • https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/5da5f67fc95621df9ff4c4e5a84d6a8a2acf7c28
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/DD4Sgt /usr/bin/composer install --quiet (http block)
  • https://api.github.com/repos/sebastianbergmann/php-timer/zipball/5a63ce20ed1b5bf577850e2c4e87f4aa902afbd2
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/DD4Sgt /usr/bin/composer install --quiet (http block)
  • https://api.github.com/repos/sebastianbergmann/version/zipball/c6c1022351a901512170118436c764e473f6de8c
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/DD4Sgt /usr/bin/composer install --quiet (http block)
  • https://api.github.com/repos/theseer/tokenizer/zipball/737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2
    • Triggering command: /usr/bin/php8.3 /usr/bin/php8.3 -n -c /tmp/DD4Sgt /usr/bin/composer install --quiet (http block)

If you need me to access, download, or install something from one of these locations, you can either:

Original prompt

Bug

In components/DataLiberation/CSS/class-cssprocessor.php, the decode_string_or_url() method has a bug on lines 1569-1573 (at commit 70fb724):

// Slow path: build decoded string (one allocation).
$decoded = '';
$at      = $start;
$end     = $start + $length;

while ( $at < $end ) {
    // Find next special character.
    $normal_len = strcspn( $this->css, $special_chars, $at );
    if ( $normal_len > 0 ) {
        // Clamp to not exceed the end boundary.
        $normal_len = min( $normal_len, $end - $at );
        $decoded   .= wp_scrub_utf8( substr( $this->css, $at, $normal_len ) );
        $at        += $normal_len;
    }

Problem

The strcspn() call on line 1570 does not pass a $length parameter, so it scans from $at all the way to the end of $this->css — potentially far past the $end boundary of the substring being decoded. The min() clamp on line 1573 catches this for the append operation, but:

  1. The clamp is redundant if strcspn() is called correctly with its $length parameter
  2. Without the length parameter, strcspn() does unnecessary work scanning potentially the entire remaining CSS string when only a small substring is being decoded
  3. If strcspn() finds a special character beyond $end but within $this->css, it returns a value less than $end - $at, which is correct by accident — but if the rest of the string has NO special characters, it returns strlen($this->css) - $at which could be far too large without the clamp

Fix

Replace lines 1570-1573 with:

$normal_len = strcspn( $this->css, $special_chars, $at, $end - $at );
if ( $normal_len > 0 ) {
    $decoded .= wp_scrub_utf8( substr( $this->css, $at, $normal_len ) );
    $at      += $normal_len;
}

PHP's strcspn() supports a 4th $length parameter (see https://www.php.net/manual/en/function.strcspn.php) that limits the search to the specified number of bytes. This eliminates both the redundant clamp and the unnecessary scanning.

Test coverage needed

There's no targeted test that verifies decode_string_or_url() correctly respects its $length boundary when the CSS string continues beyond the token. Add a test that specifically exercises this: a CSS string where a token (e.g., a string token containing an escape that triggers the slow path) is followed by more CSS content, and verify the decoded value only includes content from within the token boundaries, not from beyond.

A good test case would be something like: a string token with an escape (to trigger the slow path) followed by other tokens, verifying the value of the string token doesn't include content from beyond its boundaries. For example:

// CSS with a string containing an escape followed by more CSS.
// The escape (\41 = 'A') triggers the slow path in decode_string_or_url().
// The key check: the string value must be exactly "helloAworld" and NOT
// include any of the "; color: red" that follows.
$css = '"hello\\41 world"; color: red;';

Also verify with a get_normalized_token() call (which also uses decode_string_or_url) to confirm the normalized output respects the boundary.

The test file is at components/DataLiberation/Tests/CSSProcessorTest.php.

The following is the prior conversation context from the user's chat exploration (may be truncated):

User: @copilot This clamp logic is redundant, isn't it? Does this have test coverage? This should just use the length param on strcspn()

// Find next special character.
$normal_len = strcspn( $this->css, $special_chars, $at );
if ( $normal_len > 0 ) {
// Clamp to not exceed the end boundary.
$normal_len = min( $normal_len, $end - $at );

This seems likely to process much more or the css string than is desired. Work on a PR with test coverage (if there's no coverage) and a fix.

Assistant:

Let me start by examining the code in question and understanding the context.

Assistant: Now let me look at the specific lines mentioned (1569-1573) and search for existing test coverage:

Assistant: Now I have a clear understanding of the bug. Let me analyze the specific issue:

The bug is on lines 1569-1573 of class-cssprocessor.php:

$normal_len = strcspn( $this->css, $special_chars, $at );
if ( $normal_len > 0 ) {
    // Clamp to not exceed the end boundary.
    $normal_len = min( $normal_len, $end - $at );
    $decoded   .= wp_scrub_utf8( substr( $this->css, $at, $normal_len ) );

You're right on both counts:

  1. The clamp is redundantstrcspn() accepts a $length parameter that would limit the search directly: strcspn($string, $characters, $offset, $length). Using it would be cleaner and more correct than scanning potentially far past $end and then clamping afterward.

  2. **This can process ...

This pull request was created from Copilot chat.

…escape_at; add targeted tests

Agent-Logs-Url: https://github.com/WordPress/php-toolkit/sessions/c4d1289b-697f-44ee-9750-fac7d627e1b5

Co-authored-by: sirreal <841763+sirreal@users.noreply.github.com>
Copilot AI changed the title [WIP] Fix bug in decode_string_or_url method of class-cssprocessor.php Fix unbounded strcspn/strspn scans in decode_string_or_url and decode_escape_at Apr 9, 2026
Copilot AI requested a review from sirreal April 9, 2026 10:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants