Skip to content

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

Open
moonchen wants to merge 2 commits into
apache:masterfrom
moonchen:regex-matches-bounds
Open

Bound RegexMatches::operator[] by what the match populated#13517
moonchen wants to merge 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
Comment on lines +71 to +74
* An index the match did not populate, and a group that did not participate in the match, both
* yield an empty view. The result never has a null data(), so it can be handed straight to
* memcpy(), std::string::append() and "%.*s". Use get_ovector_pointer() to tell a group that did
* not participate from one that matched an empty string.
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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

2 participants