Bound RegexMatches::operator[] by what the match populated - #13517
Conversation
The ovector holds a fixed number of pairs regardless of the pattern, but pcre2_match() writes no further than the highest participating group. Indexing past that read the uninitialized remainder of the internal buffer and built a view from it, so checking PCRE2_UNSET was not by itself enough. Bound the index by the match size instead, and return an empty view built from "" rather than a default-constructed one. Callers pass the result straight to memcpy(), std::string::append() and "%.*s", none of which accept a null pointer.
There was a problem hiding this comment.
Pull request overview
This PR tightens the tsutil::RegexMatches contract to prevent RegexMatches::operator[] from constructing std::string_view values from uninitialized/undefined PCRE2 ovector entries, and ensures empty results return a non-null data() pointer to avoid UB in common callers.
Changes:
- Update
RegexMatches::operator[]to bound indexing by what the lastpcre2_match()call actually populated (_size), and return""for empty cases (out-of-range or non-participating group). - Expand unit tests to cover: trailing optional groups beyond the populated match count, indexing past populated groups but within allocated ovector capacity, and indexing after a failed match.
- Update public header documentation to describe the new empty-result behavior (non-null
data()).
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
src/tsutil/Regex.cc |
Bounds operator[] by populated match size and returns non-null empty views for out-of-range / unset captures. |
include/tsutil/Regex.h |
Documents the updated operator[] contract and non-null empty return value. |
src/tsutil/unit_tests/test_Regex.cc |
Adds/updates regression coverage for non-participating groups, indices past populated groups, and post-failure indexing. |
|
[approve ci autest 2] |
|
The diagnosis is right and the fix is in the correct place. A few things I verified that seem worth recording, including one that makes this more than a contract cleanup. The bound really was wrong. Master checks The if (rc == 0) {
matches._size = pcre2_get_ovector_count(RegexMatches::_MatchData::get(matches._match_data));
}and in that case PCRE2 has filled every pair, so the allocated count is the populated count. After normalization A second improvement the description doesn't claim. When The PrefetchDebug("replacing '%s' with '%.*s'", src.c_str(), static_cast<int>(dst.length()), dst.data());
result.append(dst.data(), dst.length());so a pattern with an optional group preceding a participating one — I raised exactly this on #13352 and suggested that PR normalize the view locally. Fixing it here instead is clearly the better place, since it covers all four call sites at once rather than one plugin. Worth a note on #13352 so the local guard isn't added redundantly — I'll leave that there. The no-end-offset-check rationale holds. I checked that neither One nit: the cachekey citation is off by a line. Nothing blocking. |
The ovector holds a fixed number of pairs regardless of the pattern, but pcre2_match() writes no further than the highest participating group. Indexing past that read the uninitialized remainder of the internal buffer and built a view from it, so checking PCRE2_UNSET was not by itself enough. Bound the index by the match size instead, and return an empty view built from "" rather than a default-constructed one. Callers pass the result straight to memcpy(), std::string::append() and "%.*s", none of which accept a null pointer. (cherry picked from commit 985642e)
|
Cherry-picked to the 10.2.x branch as fc507f5 for the 10.2.0 release. |
Three late bug fixes on 10.2.x. All are fixes with no new configuration, metrics or API surface, so only the changelog and the commit/PR counts change.
* Add 10.2.0 changelog and release notes Generate CHANGELOG-10.2.0 from the 10.2.0 milestone and document the release in whats-new and upgrading. The connect retry change (#13102) is called out as a necessary incompatible change, since the retry limits were not previously applied according to origin state. * Address review: fix PR count and token_key markup The PR count was 655 before five stale milestone entries were dropped; the changelog has 650. Use :ts:cv: for proxy.config.quic.server.token_key.filename, which is documented on 10.2.x even though it is absent from master, where it was first checked. * Add late 10.2.x additions to changelog and release notes Picks up #13328 (shared-memory cache directory for fast restart) and #13418 (traffic_ctl cache clear). The shm directory gets its own section since it is a new opt-in feature with four new records and a traffic_ctl subcommand. * Add July 2026 security fixes to changelog and release notes The Release 2 security bundle (#13452) landed directly on 10.2.x without public PRs, so those commits never appear in a milestone. Source them from the commit range with the changelog tool's git-range mode and append them as bare subjects, matching how CHANGELOG-10.1.4 lists them. Link the advisory from whats-new for the CVE mapping. * Add #13352, #13517 and #13523 to the changelog Three late bug fixes on 10.2.x. All are fixes with no new configuration, metrics or API surface, so only the changelog and the commit/PR counts change.
Problem
RegexMatchesallocates its ovector with a fixed 10 pairs regardless of the pattern.pcre2_match()fills it in only as far as the highest capture group that participated,and leaves the rest untouched — holding whatever was already in
_buffer, which is anuninitialized member.
operator[]checked the index against the allocated pair count. So an index past thepattern's groups passed the check and built a
string_viewout of that leftover memory:a bad pointer with a meaningless length.
The
PCRE2_UNSETcheck added in #13441 doesn't cover this. It catches a group the matchreached but that didn't participate. Entries the match never reached hold stale bytes,
not
PCRE2_UNSET.The same is true after a failed match, where PCRE2 leaves the ovector undefined. On
master,
matches[0]after a failedexec()hands back a view over garbage.Nothing in tree can reach this today:
regex_remaprejects a$nabove the pattern'scapture count at config load, prefetch and cachekey bound the index by the match count,
and
SSLSNIConfigiterates tomatches.size(). This fixes the contract, not a live bug.Fix
Bound the index by the match size — what
pcre2_match()actually populated — rather thanthe allocated pair count. Keep the
PCRE2_UNSETcheck for a group inside that range thatdidn't participate, which is the case #13441 was about.
Also return
""rather than a default-constructedstd::string_viewfor both emptycases, so the result never has a null
data(). Callers pass it straight into functionsthat don't accept a null pointer even at zero length:
plugins/regex_remap/regex_remap.cc:540—memcpy()plugins/cachekey/pattern.cc:271—std::stringctorplugins/prefetch/pattern.cc:267—std::string::append()plugins/experimental/access_control/pattern.cc:287—std::stringctorget_ovector_pointer()is still available to tell a group that didn't participate fromone that matched an empty string, which is what the PCRE2 docs suggest for that.
Why there's no check on the end offset
Raised in review on #13441, so worth answering here:
PCRE2_UNSETtogether, so testing thestart is enough.
end < startwould need\Kinside a lookaround, which PCRE2 has rejected at compiletime since 10.38 unless
PCRE2_EXTRA_ALLOW_LOOKAROUND_BSKis set. ATS never sets it,and with it forced on PCRE2 clamps rather than inverting.
Tests
Three sections in
test_Regex.cc, all of which fail without the change:test_tsutilpasses 506 assertions in 32 cases. Fullctestis 127/127.