Skip to content

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

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

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
@sirreal sirreal requested a review from Copilot April 23, 2026 17:29
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Improves the performance characteristics of DataLiberation’s CSS decoding by bounding strcspn() / strspn() scans to the relevant token/escape limits, and adds PHPUnit coverage to prevent regressions.

Changes:

  • Limit decode_string_or_url()’s strcspn() scan to the token’s remaining length ($end - $at) and remove the redundant post-scan clamp.
  • Limit decode_escape_at()’s strspn() scan to 6 hex digits (CSS max) and remove the redundant clamp.
  • Add tests validating (1) decoding does not read past token boundaries and (2) hex escapes consume at most 6 digits.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
components/DataLiberation/CSS/class-cssprocessor.php Bounds strcspn/strspn to eliminate unbounded scans during string/url and escape decoding.
components/DataLiberation/Tests/CSSProcessorTest.php Adds regression tests for token boundary correctness and 6-hex-digit escape handling.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread components/DataLiberation/Tests/CSSProcessorTest.php
Comment on lines +1590 to +1592
// \000041 is 6 hex digits → U+0041 = 'A'; the trailing '1' is literal.
// Without the length limit, strspn() would scan 7 hex digits (0000411),
// giving U+0411 = 'Б' (Cyrillic), which is incorrect.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sirreal sirreal marked this pull request as ready for review April 23, 2026 17:45
Copy link
Copy Markdown
Member

@sirreal sirreal left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks like a nice improvement.

@sirreal sirreal requested a review from adamziel April 23, 2026 17:47
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.

3 participants