Skip to content

Bound RegexMatches::operator[] by what the match populated - #13517

Merged
moonchen merged 2 commits into
apache:masterfrom
moonchen:regex-matches-bounds
Aug 10, 2026
Merged

Bound RegexMatches::operator[] by what the match populated#13517
moonchen merged 2 commits into
apache:masterfrom
moonchen:regex-matches-bounds

Conversation

@moonchen

@moonchen moonchen commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Problem

RegexMatches allocates 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 an
uninitialized member.

operator[] checked the index against the allocated pair count. So an index past the
pattern's groups passed the check and built a string_view out of that leftover memory:
a bad pointer with a meaningless length.

The PCRE2_UNSET check added in #13441 doesn't cover this. It catches a group the match
reached 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 failed exec() hands back a view over garbage.

Nothing in tree can reach this today: regex_remap rejects a $n above the pattern's
capture count at config load, prefetch and cachekey bound the index by the match count,
and SSLSNIConfig iterates to matches.size(). This fixes the contract, not a live bug.

Fix

Bound the index by the match size — what pcre2_match() actually populated — rather than
the allocated pair count. Keep the PCRE2_UNSET check for a group inside that range that
didn't participate, which is the case #13441 was about.

Also return "" rather than a default-constructed std::string_view for both empty
cases, so the result never has a null data(). Callers pass it straight into functions
that don't accept a null pointer even at zero length:

  • plugins/regex_remap/regex_remap.cc:540memcpy()
  • plugins/cachekey/pattern.cc:271std::string ctor
  • plugins/prefetch/pattern.cc:267std::string::append()
  • plugins/experimental/access_control/pattern.cc:287std::string ctor

get_ovector_pointer() is still available to tell a group that didn't participate from
one 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 sets both offsets of an unused group to PCRE2_UNSET together, so testing the
    start is enough.
  • end < start would need \K inside a lookaround, which PCRE2 has rejected at compile
    time since 10.38 unless PCRE2_EXTRA_ALLOW_LOOKAROUND_BSK is 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:

  • a non-participating group before a participating one — updated, the view is no longer null
  • an index past what the match populated — new
  • indexing after a failed match — new

test_tsutil passes 506 assertions in 32 cases. Full ctest is 127/127.

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.
Copilot AI lite review requested due to automatic review settings August 7, 2026 19:34
@moonchen moonchen added the Bug label Aug 7, 2026
@moonchen moonchen self-assigned this Aug 7, 2026
@moonchen moonchen added the Core label Aug 7, 2026
@moonchen moonchen added this to the 11.0.0 milestone Aug 7, 2026
@moonchen
moonchen requested review from cmcfarlen and ezelkow1 August 7, 2026 19:37

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 last pcre2_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.

Comment thread include/tsutil/Regex.h Outdated
Copilot AI review requested due to automatic review settings August 7, 2026 19:38

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated no new comments.

@moonchen

Copy link
Copy Markdown
Contributor Author

[approve ci autest 2]

@cmcfarlen

Copy link
Copy Markdown
Contributor

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 index >= pcre2_get_ovector_count(...), which is the allocated pair count — DEFAULT_MATCHES is 10 and the storage is a raw char _buffer[24 + 96 + 28 * DEFAULT_MATCHES] member with no initializer. So an index between the highest participating group and 10 passes the check and builds a view from whatever was in that buffer. Bounding by _size is the right fix because _size is what pcre2_match() reported it populated.

The rc == 0 case is why _size <= 0 is safe, and it is worth being explicit about. My first concern was that PCRE2 returns 0 for "match succeeded but the ovector was too small", with group 0 still valid — a naive _size <= 0 guard would then return "" for matches[0] on any pattern with more than 10 groups, which master handles correctly. That does not happen here, because Regex::exec() normalizes it first:

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 _size <= 0 can only mean a genuine error. Correct, but it depends on a normalization several dozen lines away in another function — a sentence in the comment pointing at it would save the next reader the same detour.

A second improvement the description doesn't claim. When RE_FULL_MATCH is requested and the match does not consume the whole subject, exec() sets matches._size = PCRE2_ERROR_NOMATCH. Under the new bound that makes a subsequent matches[0] return ""; on master it still returns a live view over a match the caller was told to reject. Worth mentioning, since it is a behavior change beyond the stale-memory case.

The ""-instead-of-default-constructed part fixes something live, not just theoretical. The description says this is a contract fix with nothing in tree able to reach it. That is true of the out-of-range read, but the null data() is reachable today via #13441. plugins/prefetch/pattern.cc guards up front with if (_tokens[i] >= matchCount), so replIndex < matchCount holds — and #13441 returns a default-constructed std::string_view for a group inside that range that did not participate, whose data() is null (its own unit test asserts matches[1].data() == nullptr). replace() then does:

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 — /(v\d+/)?(.*-)(\d+)$/$1$2{$3+1}/ against a request without the prefix — passes a null pointer to %.*s and to append(const char *, size_t). Benign on our libcs, undefined either way.

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 PCRE2_EXTRA_ALLOW_LOOKAROUND_BSK nor pcre2_set_compile_extra_options appears anywhere in the tree, so the \K-in-lookaround escape hatch is genuinely unreachable here rather than merely unlikely.

One nit: the cachekey citation is off by a line. plugins/cachekey/pattern.cc:271 is String src(_replacement, _tokenOffset[i], 2);, which does not involve matches; the std::string construction from a possibly-null pointer is the next line, String dst(capture.data(), capture.length());. The other three citations land exactly.

Nothing blocking.

@moonchen
moonchen merged commit 985642e into apache:master Aug 10, 2026
15 checks passed
@github-project-automation github-project-automation Bot moved this to For v10.2.0 in ATS v10.2.x Aug 10, 2026
cmcfarlen pushed a commit that referenced this pull request Aug 10, 2026
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)
@cmcfarlen cmcfarlen moved this from For v10.2.0 to Picked v10.2.0 in ATS v10.2.x Aug 10, 2026
@cmcfarlen cmcfarlen modified the milestones: 11.0.0, 10.2.0 Aug 10, 2026
@cmcfarlen

Copy link
Copy Markdown
Contributor

Cherry-picked to the 10.2.x branch as fc507f5 for the 10.2.0 release.

cmcfarlen added a commit to cmcfarlen/trafficserver that referenced this pull request Aug 10, 2026
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.
cmcfarlen added a commit that referenced this pull request Aug 10, 2026
* 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Status: Picked v10.2.0

Development

Successfully merging this pull request may close these issues.

3 participants