Skip to content

mruby-regexp: reject overlong and out-of-range UTF-8 sequences - #7068

Merged
matz merged 1 commit into
mruby:masterfrom
takumin:regexp-utf8-overlong-reject
Aug 10, 2026
Merged

mruby-regexp: reject overlong and out-of-range UTF-8 sequences#7068
matz merged 1 commit into
mruby:masterfrom
takumin:regexp-utf8-overlong-reject

Conversation

@takumin

@takumin takumin commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

mrb_re_utf8_charlen() checks that a sequence has the right number of continuation bytes and that they are shaped 10xxxxxx, but not that the value they encode needed that many bytes. mrb_re_utf8_decode() then assembles the codepoint from whatever it was handed. An overlong encoding therefore decodes to the character it only pretends to be, which leaves the two paths through the engine disagreeing about the same subject: a character class matches it, the identical literal does not.

"\xC0\xBC" =~ /[<]/            # before: 0,        after: nil
"\xC0\xBC" =~ /</              # before: nil,      after: nil
"\xC0\xBC".gsub(/[<]/, "&lt;") # before: "&lt;",   after: "\xC0\xBC"
"\xE0\x80\xBC" =~ /[<]/        # before: 0,        after: nil

Regexp.new("[Ā]").match?("\xE0\x84\x80")  # before: true,  after: false
/Ā/.match?("\xE0\x84\x80")                # before: false, after: false

C0 BC is the two-byte overlong spelling of <, and E0 84 80 the three-byte spelling of Ā. CRuby raises ArgumentError (invalid byte sequence in UTF-8) on every one of these, since it refuses to match against a string that is not valid in its encoding at all.

Escaping code is where this shows. s.gsub(/[<>&]/, ...) and s.gsub(/</, ...) are interchangeable in every reading of the source, and here they are not: the class form rewrites the overlong bytes, the literal form leaves them. [^<] sides with the class, reading the same bytes as the < it excludes and passing them through, so it parts from the literal too. Whichever direction a filter needs, it cannot get it consistently from the same subject.

Cause

mrb_re_utf8_charlen() derives a length from the lead byte and validates the continuation bytes. It has no minimum-value rule, so C0 and C1, which can only ever start an overlong two-byte sequence, are accepted as leads, and E0 8x, F0 8x and the surrogate range ED Ax are accepted too. mrb_re_utf8_decode() then shifts the payload bits together and returns 0x3C for C0 BC.

The two paths part company after that:

  • A class compares the decoded codepoint, class_match(), so the overlong form hits the same bitmap bit as the real character.
  • A literal compares bytes, RE_CHAR, so it never matches a spelling other than the one in the pattern.

Neither is wrong on its own terms. What is wrong is that the decoder promises a codepoint and delivers one for input that encodes none.

read_class_atom() decodes the pattern side through the same helper, so an overlong sequence written into a pattern currently joins the class as the character it imitates: Regexp.new("[\xC0\xBC]").match?("<") is true.

Fix

Reject the overlong, surrogate and out-of-range forms in mrb_re_utf8_charlen(), returning 1 the way it already does for a truncated sequence and a bad continuation byte. Three rules cover it:

  • a lead of C0 or C1 is never valid;
  • a three-byte sequence needs the codepoint at or above 0x800, which rules out E0 80 through E0 9F, and outside the surrogate range D800 to DFFF, which rules out ED A0 through ED BF;
  • a four-byte sequence needs the codepoint at or above 0x10000 and at most 0x10FFFF, which rules out F0 80 through F0 8F and anything above F4 8F.

All three are decidable from the lead byte together with the first continuation byte, so the check costs one range comparison ahead of the existing loop and needs no second pass. The function's own comment already described the shape of the contract; this extends it from well-formedness to validity, and the invalid bytes fall back to the byte-at-a-time treatment every other malformed input gets. Everything else decodes through this one function, mrb_re_utf8_decode() for the length and both engines through mrb_re_charlen() and mrb_re_decode_char(), so the pattern side is covered by the same change, with the pattern byte then landing in the class as its raw value.

Deliberately not proposed: raising on an invalid subject the way CRuby does. That would mean validating the whole string on every match, which is not what a byte-oriented mruby String can afford, and the fallback already gives a defined answer.

Tests

mrbgems/mruby-regexp/test/regexp.rb gains Regexp - overlong UTF-8 is not the character it spells, next to the two blocks that already pin what malformed UTF-8 does here. The class and the literal are asserted in the same block, since the point is that they now agree. The block also pins the surrogate and above-U+10FFFF forms as byte-at-a-time, and the shortest valid spelling on each side of every new bound (C2 80, E0 A0 80, ED 9F BF, EE 80 80, F0 90 80 80, F4 8F BF BF) as one character, so the tightened ranges cannot drift.

rake test passes: 2009 assertions, 0 failures.

Summary by CodeRabbit

  • Bug Fixes

    • Improved UTF-8 validation in regular expressions.
    • Invalid sequences, including overlong encodings, surrogate values, and out-of-range code points, are now handled correctly.
    • Valid boundary code points continue to match as single characters.
  • Tests

    • Added regression coverage for invalid and valid UTF-8 sequences across literals, character classes, and wildcard matching.

`mrb_re_utf8_charlen()` derived a length from the lead byte and checked that
the continuation bytes were shaped `10xxxxxx`, but never that the value they
encode needed that many bytes. `mrb_re_utf8_decode()` then assembled a
codepoint out of whatever it was handed, so an overlong encoding decoded to
the character it only pretends to be.

That left the two paths through the engine disagreeing about the same subject.
A character class compares the decoded codepoint, `class_match()`, so the
overlong form hit the same bitmap bit as the real character. A literal compares
bytes, `RE_CHAR`, so it never matched a spelling other than the one written in
the pattern.

```ruby
"\xC0\xBC" =~ /[<]/            # was 0, now nil
"\xC0\xBC" =~ /</              # nil
"\xC0\xBC".gsub(/[<]/, "&lt;") # was "&lt;", now "\xC0\xBC"

Regexp.new("[Ā]").match?("\xE0\x84\x80")  # was true, now false
/Ā/.match?("\xE0\x84\x80")                # false
```

`C0 BC` is the two-byte overlong spelling of `<`, and `E0 84 80` the
three-byte spelling of `Ā`. Escaping code is where this shows.
`s.gsub(/[<>&]/, ...)` and `s.gsub(/</, ...)` are interchangeable in every
reading of the source, and here they were not: the class form rewrote the
overlong bytes, the literal form left them.

`read_class_atom()` decodes the pattern side through the same helper, so an
overlong sequence written into a pattern used to join the class as the
character it imitates: `Regexp.new("[\xC0\xBC]").match?("<")` was `true`.

Add the missing minimum-value rule to `mrb_re_utf8_charlen()`. A lead of `C0`
or `C1` can only ever start an overlong two-byte sequence; a three-byte
sequence needs a codepoint at or above `0x800` and outside the surrogate range;
a four-byte sequence needs one from `0x10000` to `0x10FFFF`. All three are
decidable from the lead byte together with the first continuation byte, so the
check costs one range comparison ahead of the existing loop and needs no second
pass. Sequences that fail it return 1 the way a truncated sequence and a bad
continuation byte already do, and fall back to the byte-at-a-time treatment
every other malformed input gets.

Raising on an invalid subject the way CRuby does is deliberately not part of
this: it would mean validating the whole string on every match, which is not
what a byte-oriented mruby `String` can afford, and the fallback already gives
a defined answer.
@takumin
takumin requested a review from matz as a code owner August 10, 2026 08:32
@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The regexp UTF-8 decoder now rejects overlong sequences, surrogate encodings, out-of-range code points, invalid lead bytes, and invalid continuation ranges. Regression tests cover invalid matching behavior and valid encoding boundaries.

Changes

UTF-8 validation

Layer / File(s) Summary
Strict decoder validation and regression tests
mrbgems/mruby-regexp/src/re_utf8.c, mrbgems/mruby-regexp/test/regexp.rb
The decoder enforces canonical UTF-8 lengths, surrogate restrictions, the U+10FFFF limit, and continuation-byte constraints. Tests cover invalid literals, character classes, byte-wise dot matching, and valid boundary code points.

Estimated code review effort: 2 (Simple) | ~10 minutes

Suggested labels: mrbgems

Suggested reviewers: matz

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: rejecting overlong and out-of-range UTF-8 sequences in mruby-regexp.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@mrbgems/mruby-regexp/test/regexp.rb`:
- Around line 2187-2190: Add a regression assertion alongside the existing UTF-8
scan cases to verify that scanning "\xF4\x90\x80\x80" with /./ returns four
bytes, covering the c == 0xf4 upper continuation boundary while preserving the
existing invalid-lead-byte test.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: af34d06e-8064-47cb-bf22-e6596ffb7602

📥 Commits

Reviewing files that changed from the base of the PR and between 8ad6907 and 7fecf84.

📒 Files selected for processing (2)
  • mrbgems/mruby-regexp/src/re_utf8.c
  • mrbgems/mruby-regexp/test/regexp.rb

Comment thread mrbgems/mruby-regexp/test/regexp.rb
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants