Avoid regex backtracking in duration parsing

Consume the duration text token by token instead of validating the
whole string with a nested-quantifier regex, which could backtrack
exponentially on crafted input.
This commit is contained in:
SiteRelEnby 2026-09-04 19:14:24 -04:00
parent 8534a14602
commit 0c08d120c7
No known key found for this signature in database
GPG Key ID: 75DEE578292BB4CC
2 changed files with 16 additions and 5 deletions

View File

@ -31,7 +31,6 @@ import java.util.regex.Pattern;
public final class TimeParser {
private static final Pattern DURATION_TOKEN = Pattern.compile("(\\d+(?:[.,]\\d+)?|\\ban?\\b)\\s*([a-zA-Z]+)");
private static final Pattern DURATION_ONLY = Pattern.compile("^(?:(?:\\d+(?:[.,]\\d+)?|an?)\\s*[a-zA-Z]+\\s*)+$");
private static final Pattern DURATION_COLON = Pattern.compile("^(\\d+):(\\d{1,2})(?::(\\d{1,2}))?$");
private static final Pattern UNIX_TIMESTAMP = Pattern.compile("^\\d{9,}$");
private static final Pattern TIME_OF_DAY = Pattern.compile(
@ -113,12 +112,19 @@ public final class TimeParser {
// Separators between the parts: "2 hours, 30 minutes" / "2 hours and 30 minutes"
text = text.replaceAll("\\band\\b|(?<!\\d),|,(?!\\d)", " ").replaceAll("\\s+", " ").trim();
if (!DURATION_ONLY.matcher(text).matches()) {
throw new TimeParseException("Invalid duration: " + input);
}
// Consume the text token by token, so that everything has to be part of an amount/unit pair
final var matcher = DURATION_TOKEN.matcher(text);
var result = Duration.ZERO;
while (matcher.find()) {
var position = 0;
while (position < text.length()) {
matcher.region(position, text.length());
if (!matcher.lookingAt()) {
throw new TimeParseException("Invalid duration: " + input);
}
position = matcher.end();
while (position < text.length() && text.charAt(position) == ' ') {
position++;
}
final var amountText = matcher.group(1);
final var unitText = matcher.group(2).toLowerCase(Locale.ROOT);
final var unit = UNITS.get(unitText);

View File

@ -77,6 +77,11 @@ class TimeParserTest {
assertThrows(TimeParser.TimeParseException.class, () -> duration("18:00pm"));
assertThrows(TimeParser.TimeParseException.class, () -> duration("PT"));
assertThrows(TimeParser.TimeParseException.class, () -> duration("soon"));
assertThrows(TimeParser.TimeParseException.class, () -> duration("2h xyz"));
assertThrows(TimeParser.TimeParseException.class, () -> duration("2h 30"));
assertThrows(TimeParser.TimeParseException.class, () -> duration("hours 2"));
// Must fail fast, without regex backtracking blowing up
assertThrows(TimeParser.TimeParseException.class, () -> duration("a" + "Aa".repeat(5000)));
}
@Test