Skip to content

Commit f784237

Browse files
Fix ReDoS vulnerability in ISO 8601 duration validation regex (#422)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: thomasturrell <1552612+thomasturrell@users.noreply.github.com>
1 parent b7f93f0 commit f784237

File tree

4 files changed

+328
-9
lines changed

4 files changed

+328
-9
lines changed

xapi-model/src/main/java/dev/learning/xapi/model/Result.java

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@
88
import com.fasterxml.jackson.annotation.JsonInclude.Include;
99
import dev.learning.xapi.model.validation.constraints.HasScheme;
1010
import dev.learning.xapi.model.validation.constraints.VaildScore;
11+
import dev.learning.xapi.model.validation.constraints.ValidDuration;
1112
import jakarta.validation.Valid;
12-
import jakarta.validation.constraints.Pattern;
1313
import java.net.URI;
1414
import java.util.LinkedHashMap;
1515
import java.util.function.Consumer;
@@ -41,14 +41,7 @@ public class Result {
4141
private String response;
4242

4343
/** Period of time over which the Statement occurred. */
44-
// Java Duration does not store ISO 8601:2004 durations.
45-
@Pattern(
46-
regexp =
47-
"^(P\\d+W)?$|^P(?!$)(\\d+Y)?(\\d+M)?" // NOSONAR
48-
+ "(\\d+D)?(T(?=\\d)(\\d+H)?(\\d+M)?(\\d*\\.?\\d+S)?)?$", // NOSONAR
49-
flags = Pattern.Flag.CASE_INSENSITIVE,
50-
message = "Must be a valid ISO 8601:2004 duration format.")
51-
private String duration;
44+
@ValidDuration private String duration;
5245

5346
private LinkedHashMap<@HasScheme URI, Object> extensions;
5447

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/*
2+
* Copyright 2016-2025 Berry Cloud Ltd. All rights reserved.
3+
*/
4+
5+
package dev.learning.xapi.model.validation.constraints;
6+
7+
import static java.lang.annotation.ElementType.ANNOTATION_TYPE;
8+
import static java.lang.annotation.ElementType.CONSTRUCTOR;
9+
import static java.lang.annotation.ElementType.FIELD;
10+
import static java.lang.annotation.ElementType.METHOD;
11+
import static java.lang.annotation.ElementType.PARAMETER;
12+
import static java.lang.annotation.ElementType.TYPE_USE;
13+
import static java.lang.annotation.RetentionPolicy.RUNTIME;
14+
15+
import dev.learning.xapi.model.validation.internal.validators.DurationValidator;
16+
import jakarta.validation.Constraint;
17+
import jakarta.validation.Payload;
18+
import java.lang.annotation.Documented;
19+
import java.lang.annotation.Retention;
20+
import java.lang.annotation.Target;
21+
22+
/**
23+
* The annotated element must be a valid ISO 8601:2004 duration format.
24+
*
25+
* <p>Accepts formats like:
26+
*
27+
* <ul>
28+
* <li>Week format: P1W, P52W
29+
* <li>Day format: P1D, P365D
30+
* <li>Time format: PT1H, PT30M, PT45S, PT1.5S
31+
* <li>Combined format: P1Y2M3D, P1DT1H30M45S
32+
* </ul>
33+
*
34+
* @author Berry Cloud
35+
* @see <a href="https://github.com/adlnet/xAPI-Spec/blob/master/xAPI-Data.md#result">xAPI
36+
* Result</a>
37+
*/
38+
@Documented
39+
@Constraint(validatedBy = {DurationValidator.class})
40+
@Target({METHOD, FIELD, ANNOTATION_TYPE, CONSTRUCTOR, PARAMETER, TYPE_USE})
41+
@Retention(RUNTIME)
42+
public @interface ValidDuration {
43+
44+
/**
45+
* Error Message.
46+
*
47+
* @return the error message
48+
*/
49+
String message() default "Must be a valid ISO 8601:2004 duration format.";
50+
51+
/**
52+
* Groups.
53+
*
54+
* @return the validation groups
55+
*/
56+
Class<?>[] groups() default {};
57+
58+
/**
59+
* Payload.
60+
*
61+
* @return the payload
62+
*/
63+
Class<? extends Payload>[] payload() default {};
64+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/*
2+
* Copyright 2016-2025 Berry Cloud Ltd. All rights reserved.
3+
*/
4+
5+
package dev.learning.xapi.model.validation.internal.validators;
6+
7+
import dev.learning.xapi.model.validation.constraints.ValidDuration;
8+
import jakarta.validation.ConstraintValidator;
9+
import jakarta.validation.ConstraintValidatorContext;
10+
import java.util.regex.Matcher;
11+
import java.util.regex.Pattern;
12+
13+
/**
14+
* Validates ISO 8601:2004 duration format strings.
15+
*
16+
* <p>Supports formats: P[n]W, P[n]Y[n]M[n]DT[n]H[n]M[n]S and variations.
17+
*
18+
* @author Berry Cloud
19+
*/
20+
public class DurationValidator implements ConstraintValidator<ValidDuration, String> {
21+
22+
// Simple patterns - each validates a single component type
23+
private static final Pattern WEEK = Pattern.compile("^\\d+W$", Pattern.CASE_INSENSITIVE);
24+
private static final Pattern DATE =
25+
Pattern.compile("^(\\d+Y)?(\\d+M)?(\\d+D)?$", Pattern.CASE_INSENSITIVE);
26+
private static final Pattern TIME =
27+
Pattern.compile("^(\\d+H)?(\\d+M)?((\\d+\\.\\d+|\\d+)S)?$", Pattern.CASE_INSENSITIVE);
28+
29+
@Override
30+
public boolean isValid(String value, ConstraintValidatorContext context) {
31+
if (value == null) {
32+
return true;
33+
}
34+
35+
if (!value.toUpperCase().startsWith("P") || value.length() < 2) {
36+
return false;
37+
}
38+
39+
String rest = value.substring(1);
40+
41+
if (WEEK.matcher(rest).matches()) {
42+
return true;
43+
}
44+
45+
int tpos = rest.toUpperCase().indexOf('T');
46+
String datePart = tpos >= 0 ? rest.substring(0, tpos) : rest;
47+
String timePart = tpos >= 0 ? rest.substring(tpos + 1) : "";
48+
49+
if (datePart.isEmpty() && timePart.isEmpty()) {
50+
return false;
51+
}
52+
53+
return isValidDatePart(datePart) && isValidTimePart(timePart);
54+
}
55+
56+
private boolean isValidDatePart(String datePart) {
57+
if (datePart.isEmpty()) {
58+
return true;
59+
}
60+
Matcher m = DATE.matcher(datePart);
61+
return m.matches() && (m.group(1) != null || m.group(2) != null || m.group(3) != null);
62+
}
63+
64+
private boolean isValidTimePart(String timePart) {
65+
if (timePart.isEmpty()) {
66+
return true;
67+
}
68+
Matcher m = TIME.matcher(timePart);
69+
return m.matches() && (m.group(1) != null || m.group(2) != null || m.group(3) != null);
70+
}
71+
}

xapi-model/src/test/java/dev/learning/xapi/model/ResultTests.java

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,22 @@
55
package dev.learning.xapi.model;
66

77
import static org.hamcrest.MatcherAssert.assertThat;
8+
import static org.hamcrest.Matchers.empty;
9+
import static org.hamcrest.Matchers.hasSize;
810
import static org.hamcrest.Matchers.instanceOf;
911
import static org.hamcrest.Matchers.is;
12+
import static org.hamcrest.Matchers.not;
1013

1114
import com.fasterxml.jackson.databind.ObjectMapper;
15+
import jakarta.validation.ConstraintViolation;
16+
import jakarta.validation.Validation;
17+
import jakarta.validation.Validator;
1218
import java.io.IOException;
19+
import java.util.Set;
1320
import org.junit.jupiter.api.DisplayName;
1421
import org.junit.jupiter.api.Test;
22+
import org.junit.jupiter.params.ParameterizedTest;
23+
import org.junit.jupiter.params.provider.ValueSource;
1524
import org.springframework.util.ResourceUtils;
1625

1726
/**
@@ -25,6 +34,8 @@ class ResultTests {
2534

2635
private final ObjectMapper objectMapper = new ObjectMapper().findAndRegisterModules();
2736

37+
private final Validator validator = Validation.buildDefaultValidatorFactory().getValidator();
38+
2839
@Test
2940
void whenDeserializingResultThenResultIsInstanceOfResult() throws Exception {
3041

@@ -139,4 +150,184 @@ void whenCallingToStringThenResultIsExpected() {
139150
"Result(score=Score(scaled=1.0, raw=1.0, min=0.0, max=5.0), success=true, completion=true, "
140151
+ "response=test, duration=P1D, extensions=null)"));
141152
}
153+
154+
// Duration Validation Tests
155+
156+
@ParameterizedTest
157+
@ValueSource(
158+
strings = {
159+
// Week format
160+
"P1W",
161+
"P52W",
162+
"P104W",
163+
// Day format
164+
"P1D",
165+
"P365D",
166+
// Time format
167+
"PT1H",
168+
"PT30M",
169+
"PT45S",
170+
"PT1.5S",
171+
"PT0.5S",
172+
// Combined date format
173+
"P1Y",
174+
"P1M",
175+
"P1Y2M",
176+
"P1Y2M3D",
177+
// Combined date and time format
178+
"P1DT1H",
179+
"P1DT1H30M",
180+
"P1DT1H30M45S",
181+
"P1Y2M3DT4H5M6S",
182+
"P1Y2M3DT4H5M6.7S",
183+
// Minimal valid formats
184+
"PT0S",
185+
"PT1S",
186+
"P0D",
187+
// Real-world examples
188+
"PT8H",
189+
"P90D",
190+
"P2Y",
191+
"PT15M30S"
192+
})
193+
@DisplayName("When Duration Is Valid Then Validation Passes")
194+
void whenDurationIsValidThenValidationPasses(String duration) {
195+
196+
// Given Result With Valid Duration
197+
final var result = Result.builder().duration(duration).build();
198+
199+
// When Validating Result
200+
final Set<ConstraintViolation<Result>> violations = validator.validate(result);
201+
202+
// Then Validation Passes
203+
assertThat(violations, empty());
204+
}
205+
206+
@ParameterizedTest
207+
@ValueSource(
208+
strings = {
209+
// Invalid formats
210+
"",
211+
"T1H",
212+
"1D",
213+
"PD",
214+
"PT",
215+
"P1",
216+
"1Y2M",
217+
// Invalid time without T
218+
"P1H",
219+
"P1M30S",
220+
// Invalid mixing weeks with other units
221+
"P1W1D",
222+
"P1W1Y",
223+
"P1WT1H",
224+
// Invalid decimal placement
225+
"P1.5D",
226+
"P1.5Y",
227+
"PT1.5H",
228+
"PT1.5M",
229+
// Missing P prefix
230+
"1Y2M3D",
231+
"T1H30M",
232+
// Invalid order
233+
"P1D1Y",
234+
"PT1S1M",
235+
"PT1M1H",
236+
// Double separators
237+
"P1Y2M3DTT1H",
238+
"PP1D",
239+
// Negative values
240+
"P-1D",
241+
"PT-1H"
242+
})
243+
@DisplayName("When Duration Is Invalid Then Validation Fails")
244+
void whenDurationIsInvalidThenValidationFails(String duration) {
245+
246+
// Given Result With Invalid Duration
247+
final var result = Result.builder().duration(duration).build();
248+
249+
// When Validating Result
250+
final Set<ConstraintViolation<Result>> violations = validator.validate(result);
251+
252+
// Then Validation Fails
253+
assertThat(violations, not(empty()));
254+
assertThat(violations, hasSize(1));
255+
}
256+
257+
@Test
258+
@DisplayName("When Duration Is Null Then Validation Passes")
259+
void whenDurationIsNullThenValidationPasses() {
260+
261+
// Given Result With Null Duration
262+
final var result = Result.builder().duration(null).build();
263+
264+
// When Validating Result
265+
final Set<ConstraintViolation<Result>> violations = validator.validate(result);
266+
267+
// Then Validation Passes
268+
assertThat(violations, empty());
269+
}
270+
271+
@Test
272+
@DisplayName("When Duration Has Many Digits Then Validation Completes Quickly")
273+
void whenDurationHasManyDigitsThenValidationCompletesQuickly() {
274+
275+
// Given Result With Long But Valid Duration
276+
final var result = Result.builder().duration("P99999Y99999M99999DT99999H99999M99999S").build();
277+
278+
// When Validating Result
279+
final long startTime = System.nanoTime();
280+
final Set<ConstraintViolation<Result>> violations = validator.validate(result);
281+
final long endTime = System.nanoTime();
282+
final long durationMs = (endTime - startTime) / 1_000_000;
283+
284+
// Then Validation Passes And Completes In Reasonable Time
285+
assertThat(violations, empty());
286+
// Validation should complete quickly - 500ms is generous for a regex match
287+
assertThat("Validation should complete in less than 500ms", durationMs < 500);
288+
}
289+
290+
@Test
291+
@DisplayName("When Duration Is Adversarial Input Then Validation Completes Quickly Without ReDoS")
292+
void whenDurationIsAdversarialInputThenValidationCompletesQuicklyWithoutReDoS() {
293+
294+
// Given Result With Adversarial Input That Could Cause ReDoS
295+
// This input is designed to trigger exponential backtracking in vulnerable regex patterns
296+
final var adversarialInput = "P" + "9".repeat(50) + "!";
297+
final var result = Result.builder().duration(adversarialInput).build();
298+
299+
// When Validating Result
300+
final long startTime = System.nanoTime();
301+
final Set<ConstraintViolation<Result>> violations = validator.validate(result);
302+
final long endTime = System.nanoTime();
303+
final long durationMs = (endTime - startTime) / 1_000_000;
304+
305+
// Then Validation Fails Quickly (not vulnerable to ReDoS)
306+
assertThat(violations, not(empty()));
307+
// Validation should complete quickly even with adversarial input - 500ms is generous
308+
assertThat(
309+
"Validation should complete in less than 500ms even with adversarial input",
310+
durationMs < 500);
311+
}
312+
313+
@Test
314+
@DisplayName("When Duration Has Multiple Optional Groups Then Validation Completes Quickly")
315+
void whenDurationHasMultipleOptionalGroupsThenValidationCompletesQuickly() {
316+
317+
// Given Result With Input That Tests Multiple Optional Groups
318+
// This tests the pattern with input that doesn't match but exercises optional groups
319+
final var testInput = "PYMDTHMS";
320+
final var result = Result.builder().duration(testInput).build();
321+
322+
// When Validating Result
323+
final long startTime = System.nanoTime();
324+
final Set<ConstraintViolation<Result>> violations = validator.validate(result);
325+
final long endTime = System.nanoTime();
326+
final long durationMs = (endTime - startTime) / 1_000_000;
327+
328+
// Then Validation Fails Quickly Without Excessive Backtracking
329+
assertThat(violations, not(empty()));
330+
// Validation should complete quickly - 500ms is generous for a regex match
331+
assertThat("Validation should complete in less than 500ms", durationMs < 500);
332+
}
142333
}

0 commit comments

Comments
 (0)