Copilot commented on code in PR #2668: URL: https://github.com/apache/groovy/pull/2668#discussion_r3524401202
########## src/main/java/groovy/util/regex/RegexGuard.java: ########## @@ -0,0 +1,214 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package groovy.util.regex; + +import org.apache.groovy.lang.annotation.Incubating; +import org.codehaus.groovy.runtime.FormatHelper; +import org.codehaus.groovy.runtime.RegexSupport; + +import java.time.Duration; +import java.util.concurrent.TimeUnit; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Deadline-guarded regular expression evaluation, protecting against + * Regular Expression Denial of Service (ReDoS) when patterns or inputs + * come from untrusted sources. Java's regex engine has no native timeout, + * so catastrophic backtracking can hang a thread indefinitely. + * <p> + * The guard works by wrapping the input {@code CharSequence} so that + * {@code charAt} periodically checks a deadline and throws + * {@link RegexTimeoutException} once it has passed. Backtracking repeatedly + * re-reads input characters, so a runaway match is cut off shortly after the + * deadline without needing watchdog threads or thread interruption. + * <pre class="groovyTestCase"> + * import groovy.util.regex.RegexGuard + * import groovy.util.regex.RegexTimeoutException + * import java.time.Duration + * + * assert RegexGuard.matches(/gro+vy/, 'groovy', 200) + * def m = RegexGuard.matcher(/(\d+)/, 'abc 123', Duration.ofMillis(200)) + * assert m.find() && m.group(1) == '123' + * + * try { + * RegexGuard.matches(/(.*,){16}X/, '1,' * 40, 200) // catastrophic backtracking + * assert false, 'should have timed out' + * } catch (RegexTimeoutException expected) { + * } + * </pre> + * Notes and limitations: + * <ul> + * <li>The deadline starts when the guarded matcher is created and covers all + * subsequent use of it, e.g. repeated {@code find()} calls.</li> + * <li>The clock is only consulted while the engine reads input characters, + * every 512 reads; evaluations finishing in fewer reads + * never pay for a clock call. Pathological patterns perform millions of reads + * per second, so overshoot past the deadline is negligible in practice.</li> + * <li>{@code Pattern.compile} itself is not guarded; pattern compilation is + * not subject to backtracking.</li> + * </ul> + * + * @see groovy.transform.SafeRegex + * @since 6.0.0 + */ +@Incubating +public final class RegexGuard { + + private RegexGuard() { + } + + /** + * Matches the whole input against the pattern like the {@code ==~} operator, + * giving up once the timeout has elapsed. Follows the operator's conventions: + * a {@code null} pattern or input yields {@code false}, non-{@code Pattern} + * patterns and non-{@code String} inputs are converted via their default + * string representation, and the matcher is stored for + * {@code Matcher.lastMatcher}. + * + * @param pattern the pattern, a {@link Pattern} or an object whose string representation is the regex + * @param input the text to match + * @param millis the timeout in milliseconds (must be positive) + * @return {@code true} if the whole input matches the pattern + * @throws RegexTimeoutException if evaluation exceeds the timeout + */ + public static boolean matches(Object pattern, Object input, long millis) { + return doMatches(pattern, input, toDeadlineNanos(millis), millis + " ms"); + } + + /** + * Variant of {@link #matches(Object, Object, long)} taking the timeout as a {@link Duration}. + */ + public static boolean matches(Object pattern, Object input, Duration timeout) { + return doMatches(pattern, input, toDeadlineNanos(timeout), timeout.toString()); + } + + /** + * Creates a matcher of the pattern over deadline-guarded input, like the + * {@code =~} operator. Matcher operations that read the input, e.g. + * {@code find()} or {@code matches()}, throw {@link RegexTimeoutException} + * once the deadline, measured from this call, has passed. + * + * @param pattern the pattern, a {@link Pattern} or an object whose string representation is the regex + * @param input the text to match + * @param millis the timeout in milliseconds (must be positive) + * @return a matcher over the guarded input + */ + public static Matcher matcher(Object pattern, Object input, long millis) { + return doMatcher(pattern, input, toDeadlineNanos(millis), millis + " ms"); + } + + /** + * Variant of {@link #matcher(Object, Object, long)} taking the timeout as a {@link Duration}. + */ + public static Matcher matcher(Object pattern, Object input, Duration timeout) { + return doMatcher(pattern, input, toDeadlineNanos(timeout), timeout.toString()); + } + + /** + * Wraps a character sequence so that reads beyond the deadline throw + * {@link RegexTimeoutException}. Useful for guarding regex APIs not covered + * by the other helpers, e.g. {@code Pattern.split} or manual matcher creation. + * + * @param input the sequence to guard + * @param millis the timeout in milliseconds (must be positive) + * @return a guarded view of the input + */ + public static CharSequence guard(CharSequence input, long millis) { + return new GuardedCharSequence(input, toDeadlineNanos(millis), millis + " ms"); + } + + /** + * Variant of {@link #guard(CharSequence, long)} taking the timeout as a {@link Duration}. + */ + public static CharSequence guard(CharSequence input, Duration timeout) { + return new GuardedCharSequence(input, toDeadlineNanos(timeout), timeout.toString()); + } + + private static boolean doMatches(Object pattern, Object input, long deadline, String timeoutText) { + if (pattern == null || input == null) return false; + Matcher matcher = toPattern(pattern).matcher(new GuardedCharSequence(toCharSequence(input), deadline, timeoutText)); + RegexSupport.setLastMatcher(matcher); + return matcher.matches(); + } + + private static Matcher doMatcher(Object pattern, Object input, long deadline, String timeoutText) { + return toPattern(pattern).matcher(new GuardedCharSequence(toCharSequence(input), deadline, timeoutText)); + } + + private static long toDeadlineNanos(long millis) { + if (millis <= 0) throw new IllegalArgumentException("timeout must be positive but was " + millis); + return System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(millis); + } + + private static long toDeadlineNanos(Duration timeout) { + if (timeout == null || timeout.isZero() || timeout.isNegative()) + throw new IllegalArgumentException("timeout must be positive but was " + timeout); + return System.nanoTime() + timeout.toNanos(); + } Review Comment: `toDeadlineNanos(Duration)` has the same potential overflow issue as the `long` overload: adding a very large duration to `System.nanoTime()` can wrap negative and make the guard time out immediately. Saturating to `Long.MAX_VALUE` when overflow is detected keeps large timeouts from misbehaving. ########## src/main/java/groovy/util/regex/RegexGuard.java: ########## @@ -0,0 +1,214 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package groovy.util.regex; + +import org.apache.groovy.lang.annotation.Incubating; +import org.codehaus.groovy.runtime.FormatHelper; +import org.codehaus.groovy.runtime.RegexSupport; + +import java.time.Duration; +import java.util.concurrent.TimeUnit; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Deadline-guarded regular expression evaluation, protecting against + * Regular Expression Denial of Service (ReDoS) when patterns or inputs + * come from untrusted sources. Java's regex engine has no native timeout, + * so catastrophic backtracking can hang a thread indefinitely. + * <p> + * The guard works by wrapping the input {@code CharSequence} so that + * {@code charAt} periodically checks a deadline and throws + * {@link RegexTimeoutException} once it has passed. Backtracking repeatedly + * re-reads input characters, so a runaway match is cut off shortly after the + * deadline without needing watchdog threads or thread interruption. + * <pre class="groovyTestCase"> + * import groovy.util.regex.RegexGuard + * import groovy.util.regex.RegexTimeoutException + * import java.time.Duration + * + * assert RegexGuard.matches(/gro+vy/, 'groovy', 200) + * def m = RegexGuard.matcher(/(\d+)/, 'abc 123', Duration.ofMillis(200)) + * assert m.find() && m.group(1) == '123' + * + * try { + * RegexGuard.matches(/(.*,){16}X/, '1,' * 40, 200) // catastrophic backtracking + * assert false, 'should have timed out' + * } catch (RegexTimeoutException expected) { + * } + * </pre> + * Notes and limitations: + * <ul> + * <li>The deadline starts when the guarded matcher is created and covers all + * subsequent use of it, e.g. repeated {@code find()} calls.</li> + * <li>The clock is only consulted while the engine reads input characters, + * every 512 reads; evaluations finishing in fewer reads + * never pay for a clock call. Pathological patterns perform millions of reads + * per second, so overshoot past the deadline is negligible in practice.</li> + * <li>{@code Pattern.compile} itself is not guarded; pattern compilation is + * not subject to backtracking.</li> + * </ul> + * + * @see groovy.transform.SafeRegex + * @since 6.0.0 + */ +@Incubating +public final class RegexGuard { + + private RegexGuard() { + } + + /** + * Matches the whole input against the pattern like the {@code ==~} operator, + * giving up once the timeout has elapsed. Follows the operator's conventions: + * a {@code null} pattern or input yields {@code false}, non-{@code Pattern} + * patterns and non-{@code String} inputs are converted via their default + * string representation, and the matcher is stored for + * {@code Matcher.lastMatcher}. + * + * @param pattern the pattern, a {@link Pattern} or an object whose string representation is the regex + * @param input the text to match + * @param millis the timeout in milliseconds (must be positive) + * @return {@code true} if the whole input matches the pattern + * @throws RegexTimeoutException if evaluation exceeds the timeout + */ + public static boolean matches(Object pattern, Object input, long millis) { + return doMatches(pattern, input, toDeadlineNanos(millis), millis + " ms"); + } + + /** + * Variant of {@link #matches(Object, Object, long)} taking the timeout as a {@link Duration}. + */ + public static boolean matches(Object pattern, Object input, Duration timeout) { + return doMatches(pattern, input, toDeadlineNanos(timeout), timeout.toString()); + } + + /** + * Creates a matcher of the pattern over deadline-guarded input, like the + * {@code =~} operator. Matcher operations that read the input, e.g. + * {@code find()} or {@code matches()}, throw {@link RegexTimeoutException} + * once the deadline, measured from this call, has passed. + * + * @param pattern the pattern, a {@link Pattern} or an object whose string representation is the regex + * @param input the text to match + * @param millis the timeout in milliseconds (must be positive) + * @return a matcher over the guarded input + */ + public static Matcher matcher(Object pattern, Object input, long millis) { + return doMatcher(pattern, input, toDeadlineNanos(millis), millis + " ms"); + } + + /** + * Variant of {@link #matcher(Object, Object, long)} taking the timeout as a {@link Duration}. + */ + public static Matcher matcher(Object pattern, Object input, Duration timeout) { + return doMatcher(pattern, input, toDeadlineNanos(timeout), timeout.toString()); + } + + /** + * Wraps a character sequence so that reads beyond the deadline throw + * {@link RegexTimeoutException}. Useful for guarding regex APIs not covered + * by the other helpers, e.g. {@code Pattern.split} or manual matcher creation. + * + * @param input the sequence to guard + * @param millis the timeout in milliseconds (must be positive) + * @return a guarded view of the input + */ + public static CharSequence guard(CharSequence input, long millis) { + return new GuardedCharSequence(input, toDeadlineNanos(millis), millis + " ms"); + } + + /** + * Variant of {@link #guard(CharSequence, long)} taking the timeout as a {@link Duration}. + */ + public static CharSequence guard(CharSequence input, Duration timeout) { + return new GuardedCharSequence(input, toDeadlineNanos(timeout), timeout.toString()); + } + + private static boolean doMatches(Object pattern, Object input, long deadline, String timeoutText) { + if (pattern == null || input == null) return false; + Matcher matcher = toPattern(pattern).matcher(new GuardedCharSequence(toCharSequence(input), deadline, timeoutText)); + RegexSupport.setLastMatcher(matcher); + return matcher.matches(); + } + + private static Matcher doMatcher(Object pattern, Object input, long deadline, String timeoutText) { + return toPattern(pattern).matcher(new GuardedCharSequence(toCharSequence(input), deadline, timeoutText)); + } + + private static long toDeadlineNanos(long millis) { + if (millis <= 0) throw new IllegalArgumentException("timeout must be positive but was " + millis); + return System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(millis); + } Review Comment: `toDeadlineNanos(long)` can overflow when adding a very large timeout to `System.nanoTime()`, producing a negative deadline and causing an immediate `RegexTimeoutException` (or other incorrect timing). Consider saturating the computed deadline to `Long.MAX_VALUE` when the addition overflows so large timeouts behave as “effectively no timeout”. ########## src/main/java/groovy/util/regex/RegexGuard.java: ########## @@ -0,0 +1,214 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package groovy.util.regex; + +import org.apache.groovy.lang.annotation.Incubating; +import org.codehaus.groovy.runtime.FormatHelper; +import org.codehaus.groovy.runtime.RegexSupport; + +import java.time.Duration; +import java.util.concurrent.TimeUnit; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Deadline-guarded regular expression evaluation, protecting against + * Regular Expression Denial of Service (ReDoS) when patterns or inputs + * come from untrusted sources. Java's regex engine has no native timeout, + * so catastrophic backtracking can hang a thread indefinitely. + * <p> + * The guard works by wrapping the input {@code CharSequence} so that + * {@code charAt} periodically checks a deadline and throws + * {@link RegexTimeoutException} once it has passed. Backtracking repeatedly + * re-reads input characters, so a runaway match is cut off shortly after the + * deadline without needing watchdog threads or thread interruption. + * <pre class="groovyTestCase"> + * import groovy.util.regex.RegexGuard + * import groovy.util.regex.RegexTimeoutException + * import java.time.Duration + * + * assert RegexGuard.matches(/gro+vy/, 'groovy', 200) + * def m = RegexGuard.matcher(/(\d+)/, 'abc 123', Duration.ofMillis(200)) + * assert m.find() && m.group(1) == '123' + * + * try { + * RegexGuard.matches(/(.*,){16}X/, '1,' * 40, 200) // catastrophic backtracking + * assert false, 'should have timed out' + * } catch (RegexTimeoutException expected) { + * } + * </pre> + * Notes and limitations: + * <ul> + * <li>The deadline starts when the guarded matcher is created and covers all + * subsequent use of it, e.g. repeated {@code find()} calls.</li> + * <li>The clock is only consulted while the engine reads input characters, + * every 512 reads; evaluations finishing in fewer reads + * never pay for a clock call. Pathological patterns perform millions of reads + * per second, so overshoot past the deadline is negligible in practice.</li> + * <li>{@code Pattern.compile} itself is not guarded; pattern compilation is + * not subject to backtracking.</li> + * </ul> + * + * @see groovy.transform.SafeRegex + * @since 6.0.0 + */ +@Incubating +public final class RegexGuard { + + private RegexGuard() { + } + + /** + * Matches the whole input against the pattern like the {@code ==~} operator, + * giving up once the timeout has elapsed. Follows the operator's conventions: + * a {@code null} pattern or input yields {@code false}, non-{@code Pattern} + * patterns and non-{@code String} inputs are converted via their default + * string representation, and the matcher is stored for + * {@code Matcher.lastMatcher}. + * + * @param pattern the pattern, a {@link Pattern} or an object whose string representation is the regex + * @param input the text to match + * @param millis the timeout in milliseconds (must be positive) + * @return {@code true} if the whole input matches the pattern + * @throws RegexTimeoutException if evaluation exceeds the timeout + */ + public static boolean matches(Object pattern, Object input, long millis) { + return doMatches(pattern, input, toDeadlineNanos(millis), millis + " ms"); + } + + /** + * Variant of {@link #matches(Object, Object, long)} taking the timeout as a {@link Duration}. + */ + public static boolean matches(Object pattern, Object input, Duration timeout) { + return doMatches(pattern, input, toDeadlineNanos(timeout), timeout.toString()); + } + + /** + * Creates a matcher of the pattern over deadline-guarded input, like the + * {@code =~} operator. Matcher operations that read the input, e.g. + * {@code find()} or {@code matches()}, throw {@link RegexTimeoutException} + * once the deadline, measured from this call, has passed. + * + * @param pattern the pattern, a {@link Pattern} or an object whose string representation is the regex + * @param input the text to match + * @param millis the timeout in milliseconds (must be positive) + * @return a matcher over the guarded input + */ + public static Matcher matcher(Object pattern, Object input, long millis) { + return doMatcher(pattern, input, toDeadlineNanos(millis), millis + " ms"); + } + + /** + * Variant of {@link #matcher(Object, Object, long)} taking the timeout as a {@link Duration}. + */ + public static Matcher matcher(Object pattern, Object input, Duration timeout) { + return doMatcher(pattern, input, toDeadlineNanos(timeout), timeout.toString()); + } + + /** + * Wraps a character sequence so that reads beyond the deadline throw + * {@link RegexTimeoutException}. Useful for guarding regex APIs not covered + * by the other helpers, e.g. {@code Pattern.split} or manual matcher creation. + * + * @param input the sequence to guard + * @param millis the timeout in milliseconds (must be positive) + * @return a guarded view of the input + */ + public static CharSequence guard(CharSequence input, long millis) { + return new GuardedCharSequence(input, toDeadlineNanos(millis), millis + " ms"); + } + + /** + * Variant of {@link #guard(CharSequence, long)} taking the timeout as a {@link Duration}. + */ + public static CharSequence guard(CharSequence input, Duration timeout) { + return new GuardedCharSequence(input, toDeadlineNanos(timeout), timeout.toString()); + } + + private static boolean doMatches(Object pattern, Object input, long deadline, String timeoutText) { + if (pattern == null || input == null) return false; + Matcher matcher = toPattern(pattern).matcher(new GuardedCharSequence(toCharSequence(input), deadline, timeoutText)); + RegexSupport.setLastMatcher(matcher); + return matcher.matches(); + } + + private static Matcher doMatcher(Object pattern, Object input, long deadline, String timeoutText) { + return toPattern(pattern).matcher(new GuardedCharSequence(toCharSequence(input), deadline, timeoutText)); + } + + private static long toDeadlineNanos(long millis) { + if (millis <= 0) throw new IllegalArgumentException("timeout must be positive but was " + millis); + return System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(millis); + } + + private static long toDeadlineNanos(Duration timeout) { + if (timeout == null || timeout.isZero() || timeout.isNegative()) + throw new IllegalArgumentException("timeout must be positive but was " + timeout); + return System.nanoTime() + timeout.toNanos(); + } + + private static Pattern toPattern(Object pattern) { + if (pattern instanceof Pattern p) return p; + return Pattern.compile(pattern instanceof String s ? s : FormatHelper.toString(pattern)); + } + + private static CharSequence toCharSequence(Object input) { + // convert like the regex operators do; also avoids guarding sequences + // with costly charAt implementations (e.g. GString) + return input instanceof String s ? s : FormatHelper.toString(input); + } + + private static final int CHECK_INTERVAL = 512; + + private static final class GuardedCharSequence implements CharSequence { + private final CharSequence delegate; + private final long deadline; + private final String timeoutText; + private int reads; // single matching thread; racy updates only affect check cadence + + private GuardedCharSequence(CharSequence delegate, long deadline, String timeoutText) { + this.delegate = delegate; + this.deadline = deadline; + this.timeoutText = timeoutText; + } + + @Override + public char charAt(int index) { + if (++reads % CHECK_INTERVAL == 0 && System.nanoTime() - deadline > 0) { + throw new RegexTimeoutException("regex evaluation exceeded timeout of " + timeoutText); + } + return delegate.charAt(index); Review Comment: `GuardedCharSequence.charAt` is on the hot path of regex evaluation; using `% CHECK_INTERVAL` introduces a division/modulo on every character read. Since `CHECK_INTERVAL` is a power of two (512), this can be made cheaper with a bitmask check. -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected]
