Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 21 additions & 3 deletions mrbgems/mruby-regexp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,9 +172,11 @@ ReDoS attacks.
**Backtracking engine**: Used when patterns contain `\1`-`\9`
backreferences, non-greedy quantifiers (`*?`, `+?`, `??`),
lookaround assertions (`(?=...)`, `(?!...)`, `(?<=...)`, `(?<!...)`)
or atomic groups (`(?>...)`). Protected by a
configurable step limit (`MRB_REGEXP_STEP_LIMIT`, default 1M) to
prevent excessive backtracking.
or atomic groups (`(?>...)`). Bounded by a configurable step limit
(`MRB_REGEXP_STEP_LIMIT`, default 1M) against excessive backtracking and
by a recursion limit (`MRB_REGEXP_RECURSION_LIMIT`, default 1000) on
the C stack. A search that reaches either raises `RegexpError` naming
the limit, since what it had found by then is not the answer.

The engine is selected automatically at compile time based on
pattern analysis.
Expand Down Expand Up @@ -255,8 +257,24 @@ there.
#ifndef MRB_REGEXP_STEP_LIMIT
#define MRB_REGEXP_STEP_LIMIT 1000000
#endif

/* Maximum recursion depth of the backtracking engine (C stack) */
#ifndef MRB_REGEXP_RECURSION_LIMIT
#define MRB_REGEXP_RECURSION_LIMIT 1000
#endif
```

A search that reaches either limit raises `RegexpError`, `step limit over
(MRB_REGEXP_STEP_LIMIT)` or `recursion limit over
(MRB_REGEXP_RECURSION_LIMIT)`, rather than answer with what it had found

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Answering the phrasing point of the review above, which had no line here to sit on.

This wording stays. "rather than answer with what it had found by then" is the sentence the whole change is about, and it is worded that way in the commit message and the pull request body too: what the search had when it gave up is not a partial answer to be returned or not, it is a shorter match, a later one or none, with nothing to tell it from the real one. "the partial result" names it as a result, which is what the change denies.

The other phrasing the review names, at lines 19 to 24, is not this PR's text.

by then. The recursion limit is spent per fork and per capture, so a
repetition of an atomic group or a lookaround spends a few frames per
iteration, and a long enough run of one reaches it on a pattern that is
not pathological; a build with the stack for it can set it higher. The
values a build chose are `Regexp::RECURSION_LIMIT` and `Regexp::STEP_LIMIT`,
for a program that has to size a subject or a pattern to the build it runs
on; CRuby has no counterpart, its guard being `Regexp.timeout`.

Case folding beyond ASCII is not this gem's to configure. The table is
core's, carried by any build that defines `MRB_UTF8_STRING` without
`MRB_USE_ASCII_CTYPE`, and is what `String#downcase` and the four case methods
Expand Down
11 changes: 10 additions & 1 deletion mrbgems/mruby-regexp/include/re_internal.h
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,14 @@ typedef struct mrb_regexp_pattern {
#define MRB_REGEXP_RECURSION_LIMIT 1000
#endif

/* What a search answers when the backtracking engine gave up at one of the
two limits before it had an answer (see mrb_re_exec()). The caller raises
on it: what the search had found by then is not a shorter or a later
match, and reading it as one was the defect. Which of the two it was
names the knob to turn. */
#define RE_OVER_RECURSION_LIMIT (-1)
#define RE_OVER_STEP_LIMIT (-2)

/* Maximum captures */
#define RE_MAX_CAPTURES 32

Expand Down Expand Up @@ -332,7 +340,8 @@ mrb_re_char_interior_p(const char *str, const char *s, const char *end)
}

/* Execute a match.
Returns number of captures filled (0 = no match).
Returns number of captures filled (0 = no match), or RE_OVER_*_LIMIT with
nothing in `captures` to read.
captures[2*n] = start, captures[2*n+1] = end for group n. */
int mrb_re_exec(mrb_state *mrb, const mrb_regexp_pattern *pat,
const char *str, mrb_int len, mrb_int start,
Expand Down
45 changes: 31 additions & 14 deletions mrbgems/mruby-regexp/src/re_exec.c
Original file line number Diff line number Diff line change
Expand Up @@ -641,13 +641,13 @@ lookbehind_start(const mrb_regexp_pattern *pat, const char *str,
the RE_LOOK_END (see there), so a cut can pass through one; the opener
absorbs its own number like an RE_ATOMIC and hands any other up.
The fourth answer, BT_LIMIT, is a frame giving up at the recursion or step
limit. A frame that gets it hands it up; a SPLIT takes it as that branch
failing and answers with its other branch, as it would with a failure.
What no frame does is turn it into a cut or into a lookaround's answer,
since a limit says nothing about the text: the frame giving up may be
inside an atomic group's body, where a cut would keep the group's exit
from being taken, or inside a negative lookaround, where reading the limit
as "no match" would make the assertion hold. */
limit, and it is the search's answer rather than the frame's: every frame
hands it up unchanged, as it does a cut, and backtrack_exec() stops at it
(see there). A limit says nothing about the text, so no frame may read it
as its branch having failed and answer with its other branch, which would
be answering a smaller question with whatever the alternatives inside the
limit produce: a shorter, a later or no match, told from the real answer
by nothing. */
#define BT_FAIL 0
#define BT_MATCH 1
#define BT_LIMIT 2
Expand Down Expand Up @@ -849,18 +849,18 @@ bt_match(bt_state *m, const char *sp, uint32_t pc, int depth)
if (inst.a) {
if (inst.offset > pc) {
int r = bt_iter(m, sp, pc + 1, pc, depth + 1);
if (r != BT_FAIL && r != BT_LIMIT) return r;
if (r != BT_FAIL) return r;
pc = inst.offset;
break;
}
if (ITER_EMPTY(m, pc, sp)) { pc++; break; }
int r = bt_match(m, sp, pc + 1, depth + 1);
if (r != BT_FAIL && r != BT_LIMIT) return r;
if (r != BT_FAIL) return r;
return bt_iter(m, sp, inst.offset, pc, depth + 1);
}
{
int r = bt_match(m, sp, pc + 1, depth + 1);
if (r != BT_FAIL && r != BT_LIMIT) return r;
if (r != BT_FAIL) return r;
}
pc = inst.offset;
break;
Expand All @@ -873,18 +873,18 @@ bt_match(bt_state *m, const char *sp, uint32_t pc, int depth)
if (inst.a) {
if (inst.offset > pc) {
int r = bt_match(m, sp, inst.offset, depth + 1);
if (r != BT_FAIL && r != BT_LIMIT) return r;
if (r != BT_FAIL) return r;
return bt_iter(m, sp, pc + 1, pc, depth + 1);
}
if (ITER_EMPTY(m, pc, sp)) { pc++; break; }
int r = bt_iter(m, sp, inst.offset, pc, depth + 1);
if (r != BT_FAIL && r != BT_LIMIT) return r;
if (r != BT_FAIL) return r;
pc++;
break;
}
{
int r = bt_match(m, sp, inst.offset, depth + 1);
if (r != BT_FAIL && r != BT_LIMIT) return r;
if (r != BT_FAIL) return r;
}
pc++;
break;
Expand Down Expand Up @@ -1116,14 +1116,25 @@ backtrack_exec(mrb_state *mrb, const mrb_regexp_pattern *pat,
memset(caps, -1, sizeof(int) * ncap);
m.steps = 0;

if (bt_match(&m, sp, 0, 0) == BT_MATCH) {
int r = bt_match(&m, sp, 0, 0);
if (r == BT_MATCH) {
if (captures) {
int copy = ncap < captures_size ? ncap : captures_size;
memcpy(captures, caps, sizeof(int) * copy);
}
mrb_free(mrb, caps);
return ncap > 0 ? ncap : 1;
}
if (r == BT_LIMIT) {
/* The search ends here, not this start position's attempt: the
positions after it answer where the first match is only once this
one has none, which is what the limit left unanswered. Which limit
is read off the step count: the depth check runs before a frame
counts its step, so the count is over the step limit exactly when
the step check gave up. */
mrb_free(mrb, caps);
return m.steps > MRB_REGEXP_STEP_LIMIT ? RE_OVER_STEP_LIMIT : RE_OVER_RECURSION_LIMIT;
}
}
mrb_free(mrb, caps);
return 0;
Expand Down Expand Up @@ -1239,6 +1250,10 @@ mrb_re_rexec(mrb_state *mrb, const mrb_regexp_pattern *pat,
if (len - lo > RE_RSEARCH_PROBE_SPAN) break;
memset(captures, -1, sizeof(int) * captures_size);
last_n = exec_range(mrb, pat, str, len, lo, limit, captures, captures_size, binary);
/* A match or a limit ends the probe. A limit is the answer to the whole
question and not to this window's: a window that gave up is not one
with no match in it, and widening would only ask the same search
again a size up. */
if (last_n) break;
/* The window had grown to the whole range, so there is no match to find
and the search below would only ask again. */
Expand All @@ -1250,6 +1265,7 @@ mrb_re_rexec(mrb_state *mrb, const mrb_regexp_pattern *pat,
last_n = exec_range(mrb, pat, str, len, 0, limit, captures, captures_size, binary);
if (last_n == 0) return 0;
}
if (last_n < 0) return last_n;

/* Whichever range answered gave its leftmost match; the last one is found
by walking forward from it. Each step resumes one byte past the match
Expand All @@ -1264,6 +1280,7 @@ mrb_re_rexec(mrb_state *mrb, const mrb_regexp_pattern *pat,
memset(captures, -1, sizeof(int) * captures_size);
int n = exec_range(mrb, pat, str, len, pos, limit, captures, captures_size, binary);
if (n == 0) break;
if (n < 0) return n;
last_n = n;
memcpy(last, captures, sizeof(int) * captures_size);
pos = captures[0] + 1;
Expand Down
28 changes: 27 additions & 1 deletion mrbgems/mruby-regexp/src/regexp.c
Original file line number Diff line number Diff line change
Expand Up @@ -427,6 +427,19 @@ match_operand(mrb_state *mrb, mrb_value obj)
return mrb_ensure_string_type(mrb, obj);
}

/* Raise if the search answered RE_OVER_*_LIMIT (see mrb_re_exec()): it gave
up at a limit, and what it had found by then is not a shorter or a later
match, so the caller raises rather than read it as one. Every caller holds
its capture buffer on the stack, so the raise strands nothing. */
static void
re_check_over_limit(mrb_state *mrb, int n)
{
if (n >= 0) return;
mrb_raise(mrb, E_REGEXP_ERROR, n == RE_OVER_STEP_LIMIT
? "step limit over (MRB_REGEXP_STEP_LIMIT)"
: "recursion limit over (MRB_REGEXP_RECURSION_LIMIT)");
}

/* Internal: execute match and create MatchData.
Returns MatchData on match, nil on no match.
Sets $~ and $1-$9 globals, and clears them on a miss. */
Expand All @@ -441,6 +454,7 @@ exec_match(mrb_state *mrb, mrb_value self, mrb_value str, mrb_int pos)
memset(captures, -1, sizeof(int) * cap_size);
int ncap = mrb_re_exec(mrb, pat, RSTRING_PTR(str), RSTRING_LEN(str), pos,
captures, cap_size, re_binary_string_p(str));
re_check_over_limit(mrb, ncap);

if (ncap == 0) {
clear_match_globals(mrb);
Expand Down Expand Up @@ -623,6 +637,7 @@ regexp_s_byte_rsearch(mrb_state *mrb, mrb_value klass)
int captures[RE_MAX_CAPTURES * 2];
int ncap = mrb_re_rexec(mrb, pat, RSTRING_PTR(str), RSTRING_LEN(str), limit,
captures, cap_size, re_binary_string_p(str));
re_check_over_limit(mrb, ncap);
if (ncap == 0) {
clear_match_globals(mrb);
return mrb_nil_value();
Expand All @@ -647,6 +662,7 @@ exec_match_p(mrb_state *mrb, mrb_value re, mrb_value str, mrb_int pos)

int ncap = mrb_re_exec(mrb, pat, RSTRING_PTR(str), RSTRING_LEN(str), pos, NULL, 0,
re_binary_string_p(str));
re_check_over_limit(mrb, ncap);
return mrb_bool_value(ncap > 0);
}

Expand Down Expand Up @@ -1475,6 +1491,7 @@ regexp_s_gsub_str(mrb_state *mrb, mrb_value klass)
while (pos <= slen) {
memset(captures, -1, sizeof(int) * cap_size);
int n = mrb_re_exec(mrb, pat, s, slen, pos, captures, cap_size, binary);
re_check_over_limit(mrb, n);
if (n == 0) break;

/* save last match for $~ */
Expand Down Expand Up @@ -1557,6 +1574,7 @@ regexp_s_sub_str(mrb_state *mrb, mrb_value klass)
memset(captures, -1, sizeof(int) * cap_size);

int n = mrb_re_exec(mrb, pat, s, slen, 0, captures, cap_size, re_binary_string_p(str));
re_check_over_limit(mrb, n);
if (n == 0) {
clear_match_globals(mrb);
return mrb_str_dup(mrb, str);
Expand Down Expand Up @@ -1829,7 +1847,9 @@ regexp_s_gsub_block(mrb_state *mrb, mrb_value klass)

while (pos <= slen) {
memset(captures, -1, sizeof(int) * cap_size);
if (mrb_re_exec(mrb, pat, s, slen, pos, captures, cap_size, binary) == 0) break;
int n = mrb_re_exec(mrb, pat, s, slen, pos, captures, cap_size, binary);
re_check_over_limit(mrb, n);
if (n == 0) break;
mrb_int beg = captures[0], end = captures[1];

mrb_value matched = re_byte_substr(mrb, str, beg, end - beg);
Expand Down Expand Up @@ -1929,6 +1949,7 @@ regexp_s_scan(mrb_state *mrb, mrb_value klass)
while (pos <= slen) {
memset(captures, -1, sizeof(int) * cap_size);
int n = mrb_re_exec(mrb, pat, s, slen, pos, captures, cap_size, binary);
re_check_over_limit(mrb, n);
if (n == 0) break;

last_ncap = cap_size;
Expand Down Expand Up @@ -2150,6 +2171,11 @@ mrb_mruby_regexp_gem_init(mrb_state *mrb)
mrb_define_const(mrb, re, "IGNORECASE", mrb_fixnum_value(1));
mrb_define_const(mrb, re, "EXTENDED", mrb_fixnum_value(2));
mrb_define_const(mrb, re, "MULTILINE", mrb_fixnum_value(4));
/* The two limits of the backtracking engine, which a build sets (see
re_internal.h) and a `RegexpError` names: the value behind the name, for
whoever has to size a subject or a pattern to the build it runs on. */
mrb_define_const(mrb, re, "RECURSION_LIMIT", mrb_int_value(mrb, MRB_REGEXP_RECURSION_LIMIT));
mrb_define_const(mrb, re, "STEP_LIMIT", mrb_int_value(mrb, MRB_REGEXP_STEP_LIMIT));

/* Class methods */
mrb_define_method(mrb, re, "initialize", regexp_init, MRB_ARGS_ARG(1, 2));
Expand Down
53 changes: 53 additions & 0 deletions mrbgems/mruby-regexp/test/regexp_syntax.rb
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,59 @@
assert_equal "abbc", /(?:(a)b|a(b)|\2)+c/.match("abbc")[0]
end

assert("Regexp - the backtracking engine raises at a limit rather than answer short") do
# A frame gives up at MRB_REGEXP_RECURSION_LIMIT or MRB_REGEXP_STEP_LIMIT,
# and the frames above it used to read that as the branch having failed and
# go on with their other branches: the search answered with a shorter match,
# a later one or none, told from the real answer by nothing. A limit is the
# search's answer now, and the caller raises on it; the same patterns match
# whole on a subject inside the limits, as in CRuby.
#
# The subjects are sized from the limits, which the build sets and the two
# constants read back. A repetition of an atomic group or a lookaround
# spends two or three frames per iteration, so a run of `a` as long as the
# recursion limit is past it for every pattern here and a quarter of that
# run is inside it; a chain of `(?=a)` or `(?>a)` spends two frames per
# link; `(a+)+\1b` spends about 2^n steps on a run of n. The n that reaches
# the step limit is counted by shifting the limit down rather than 1 up to
# it, so that a build setting the limit near the width of `mrb_int` has no
# shift of its own to overflow.
limit = Regexp::RECURSION_LIMIT
over = "a" * limit
fits = "a" * (limit / 4)
n = 0
n += 1 while (Regexp::STEP_LIMIT - 1) >> n > 0
steps = "a" * (n + 10)
assert_raise(RegexpError) { over.match(/(?:(?>a))*/) } # answered a third of the run
assert_raise(RegexpError) { over.match(/(?:(?=a)a)*/) } # a third of the run
assert_raise(RegexpError) { (over + "b").match(/(a)*?b/) } # began past the middle
assert_raise(RegexpError) { over.match(/(?:(?>a))*\z/) } # began past the middle
assert_raise(RegexpError) { "a".match(Regexp.new("(?=a)" * (limit / 2 + 1) + "a")) } # was nil
assert_raise(RegexpError) { over.match(Regexp.new("(?>a)" * (limit / 2 + 1) + "a")) } # was nil
assert_equal fits, fits.match(/(?:(?>a))*/)[0]
assert_equal fits, fits.match(/(?:(?=a)a)*/)[0]
assert_equal 0, (fits + "b").match(/(a)*?b/).begin(0)
assert_equal 0, fits.match(/(?:(?>a))*\z/).begin(0)
assert_equal "a", "a".match(Regexp.new("(?=a)" * (limit / 4) + "a"))[0]
assert_equal fits, fits.match(Regexp.new("(?>a)" * (limit / 4 - 1) + "a"))[0]
Comment thread
coderabbitai[bot] marked this conversation as resolved.
# The message names the limit, so that whoever hits one on a legitimate
# subject knows which knob to turn, and the constant says where it stands.
assert_raise_with_message(RegexpError, "recursion limit over (MRB_REGEXP_RECURSION_LIMIT)") do
over.match(/(?:(?>a))*/)
end
assert_raise_with_message(RegexpError, "step limit over (MRB_REGEXP_STEP_LIMIT)") do
steps.match(/(a+)+\1b/)
end
assert_kind_of Integer, Regexp::RECURSION_LIMIT
assert_kind_of Integer, Regexp::STEP_LIMIT
# A limit ends the search at the start position it was hit at: the
# positions after it would say where the first match is only once this one
# has none. The search that reads no captures raises the same.
assert_raise(RegexpError) { ("b" + over + "c").match(/b(?:(?>a))*c|a/) } # began at 1
assert_raise(RegexpError) { over.match?(/(?:(?>a))*\z/) }
assert_raise(RegexpError) { /(?:(?>a))*\z/ === over }
end

assert("Regexp - quantified first alternative does not leak into the next") do
# A quantifier loops back to its own atom. When the atom starts the first
# alternative, the alternation SPLIT is inserted in front of it; the
Expand Down
38 changes: 38 additions & 0 deletions mrbgems/mruby-regexp/test/string_index.rb
Original file line number Diff line number Diff line change
Expand Up @@ -469,6 +469,44 @@ def re.is_a?(klass); false; end
assert_nil mb.byterindex(/うえ/, 3)
end

assert("a backward search raises where one of its searches hits a limit") do
# The search makes up to three searches: a window at the end that widens,
# the whole range when no window answers, and a walk forward from the match
# it found. A limit in any of them is the answer to the whole question, so
# each raises rather than read it as a window with no match in it, which
# would widen, or as the walk having run out of matches, which would answer
# the one before.
#
# The subjects are sized from the recursion limit, which the build sets and
# `Regexp::RECURSION_LIMIT` reads back. An atomic group nested d deep spends
# 2d frames per copy and one more per iteration of a repetition, and d is
# chosen so that a run inside the 256 bytes the window reads spends the
# limit; a build with a limit the window cannot reach sends the first and
# third cases through the whole-range search instead, where they raise the
# same.
limit = Regexp::RECURSION_LIMIT
d = limit / 600 + 1
atom = "a"
d.times { atom = "(?>#{atom})" }
# the window, grown to the whole subject: the lookbehind lets only position
# 0 try, and that one gives up
n = limit / (2 * d + 1) + 1
assert_raise(RegexpError) { ("a" * n + "b").rindex(/(?<!a)(?:#{atom})*b/) } # was nil, CRuby 0
# the whole range, once no window within 256 bytes of the end has answered
assert_raise(RegexpError) { ("a" * limit + "b" + "c" * 300).rindex(/\A(?:(?>a))*b/) } # was nil, CRuby 0
# the walk from the window's match at position 1 to position 2, which has
# the run the second alternative asks for and gives up on; the windows
# before the one that reaches position 1 have less than that run
n = limit / (2 * d) + 1
assert_raise(RegexpError) { ("x" + "a" * (n + 1) + "c").rindex(/(?<=x)a|(?:#{atom}){#{n}}c/) } # was 1, CRuby 2
# Inside the limits the same three searches answer, as in CRuby.
n = limit / (2 * (2 * d + 1))
assert_equal 0, ("a" * n + "b").rindex(/(?<!a)(?:#{atom})*b/)
assert_equal 0, ("a" * (limit / 4) + "b" + "c" * 300).rindex(/\A(?:(?>a))*b/)
n = limit / (4 * d)
assert_equal 2, ("x" + "a" * (n + 1) + "c").rindex(/(?<=x)a|(?:#{atom}){#{n}}c/)
end

assert("String#index and String#rindex with regexp set the match globals") do
assert_equal 1, "abc".index(/(b)/)
assert_equal "b", $1
Expand Down
Loading
Loading