mruby-regexp: reject overlong and out-of-range UTF-8 sequences - #7068
Conversation
`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(/[<]/, "<") # was "<", 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.
📝 WalkthroughWalkthroughThe 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. ChangesUTF-8 validation
Estimated code review effort: 2 (Simple) | ~10 minutes Suggested labels: Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
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
📒 Files selected for processing (2)
mrbgems/mruby-regexp/src/re_utf8.cmrbgems/mruby-regexp/test/regexp.rb
mrb_re_utf8_charlen()checks that a sequence has the right number of continuation bytes and that they are shaped10xxxxxx, 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.C0 BCis the two-byte overlong spelling of<, andE0 84 80the three-byte spelling ofĀ. CRuby raisesArgumentError(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(/[<>&]/, ...)ands.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, soC0andC1, which can only ever start an overlong two-byte sequence, are accepted as leads, andE0 8x,F0 8xand the surrogate rangeED Axare accepted too.mrb_re_utf8_decode()then shifts the payload bits together and returns0x3CforC0 BC.The two paths part company after that:
class_match(), so the overlong form hits the same bitmap bit as the real character.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?("<")istrue.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:C0orC1is never valid;0x800, which rules outE0 80throughE0 9F, and outside the surrogate rangeD800toDFFF, which rules outED A0throughED BF;0x10000and at most0x10FFFF, which rules outF0 80throughF0 8Fand anything aboveF4 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 throughmrb_re_charlen()andmrb_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
Stringcan afford, and the fallback already gives a defined answer.Tests
mrbgems/mruby-regexp/test/regexp.rbgainsRegexp - 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+10FFFFforms 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 testpasses: 2009 assertions, 0 failures.Summary by CodeRabbit
Bug Fixes
Tests