Skip to content

Commit 87853cd

Browse files
Fix ReDoS vulnerability in ISO 8601 duration regex validation
Co-authored-by: thomasturrell <1552612+thomasturrell@users.noreply.github.com>
1 parent ab511cf commit 87853cd

2 files changed

Lines changed: 212 additions & 2 deletions

File tree

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,11 @@ public class Result {
4242

4343
/** Period of time over which the Statement occurred. */
4444
// Java Duration does not store ISO 8601:2004 durations.
45+
// Using possessive quantifiers on digits to prevent ReDoS (Regular Expression Denial of Service)
4546
@Pattern(
4647
regexp =
47-
"^(P\\d+W)?$|^P(?!$)(\\d+Y)?(\\d+M)?" // NOSONAR
48-
+ "(\\d+D)?(T(?=\\d)(\\d+H)?(\\d+M)?(\\d*\\.?\\d+S)?)?$", // NOSONAR
48+
"^P\\d++W$|^P(?!$)(\\d++Y)?(\\d++M)?"
49+
+ "(\\d++D)?(T(?=\\d)(\\d++H)?(\\d++M)?((?:\\d++\\.\\d++|\\d++)S)?)?$",
4950
flags = Pattern.Flag.CASE_INSENSITIVE,
5051
message = "Must be a valid ISO 8601:2004 duration format.")
5152
private String duration;
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
/*
2+
* Copyright 2016-2025 Berry Cloud Ltd. All rights reserved.
3+
*/
4+
5+
package dev.learning.xapi.model;
6+
7+
import static org.hamcrest.MatcherAssert.assertThat;
8+
import static org.hamcrest.Matchers.empty;
9+
import static org.hamcrest.Matchers.hasSize;
10+
import static org.hamcrest.Matchers.not;
11+
12+
import jakarta.validation.ConstraintViolation;
13+
import jakarta.validation.Validation;
14+
import jakarta.validation.Validator;
15+
import java.util.Set;
16+
import org.junit.jupiter.api.DisplayName;
17+
import org.junit.jupiter.api.Test;
18+
import org.junit.jupiter.params.ParameterizedTest;
19+
import org.junit.jupiter.params.provider.ValueSource;
20+
21+
/**
22+
* Result Duration Validation Tests.
23+
*
24+
* <p>Tests for ISO 8601:2004 duration format validation to ensure the regex pattern correctly
25+
* validates durations and is not vulnerable to ReDoS (Regular Expression Denial of Service)
26+
* attacks.
27+
*
28+
* @author GitHub Copilot
29+
*/
30+
@DisplayName("Result Duration Validation Tests")
31+
class ResultDurationValidationTests {
32+
33+
private final Validator validator = Validation.buildDefaultValidatorFactory().getValidator();
34+
35+
@ParameterizedTest
36+
@ValueSource(
37+
strings = {
38+
// Week format
39+
"P1W",
40+
"P52W",
41+
"P104W",
42+
// Day format
43+
"P1D",
44+
"P365D",
45+
// Time format
46+
"PT1H",
47+
"PT30M",
48+
"PT45S",
49+
"PT1.5S",
50+
"PT0.5S",
51+
// Combined date format
52+
"P1Y",
53+
"P1M",
54+
"P1Y2M",
55+
"P1Y2M3D",
56+
// Combined date and time format
57+
"P1DT1H",
58+
"P1DT1H30M",
59+
"P1DT1H30M45S",
60+
"P1Y2M3DT4H5M6S",
61+
"P1Y2M3DT4H5M6.7S",
62+
// Minimal valid formats
63+
"PT0S",
64+
"PT1S",
65+
"P0D",
66+
// Real-world examples
67+
"PT8H",
68+
"P90D",
69+
"P2Y",
70+
"PT15M30S"
71+
})
72+
@DisplayName("When Duration Is Valid Then Validation Passes")
73+
void whenDurationIsValidThenValidationPasses(String duration) {
74+
75+
// Given Result With Valid Duration
76+
final var result = Result.builder().duration(duration).build();
77+
78+
// When Validating Result
79+
final Set<ConstraintViolation<Result>> violations = validator.validate(result);
80+
81+
// Then Validation Passes
82+
assertThat(violations, empty());
83+
}
84+
85+
@ParameterizedTest
86+
@ValueSource(
87+
strings = {
88+
// Invalid formats
89+
"",
90+
"T1H",
91+
"1D",
92+
"PD",
93+
"PT",
94+
"P1",
95+
"1Y2M",
96+
// Invalid time without T
97+
"P1H",
98+
"P1M30S",
99+
// Invalid mixing weeks with other units
100+
"P1W1D",
101+
"P1W1Y",
102+
"P1WT1H",
103+
// Invalid decimal placement
104+
"P1.5D",
105+
"P1.5Y",
106+
"PT1.5H",
107+
"PT1.5M",
108+
// Missing P prefix
109+
"1Y2M3D",
110+
"T1H30M",
111+
// Invalid order
112+
"P1D1Y",
113+
"PT1S1M",
114+
"PT1M1H",
115+
// Double separators
116+
"P1Y2M3DTT1H",
117+
"PP1D",
118+
// Negative values
119+
"P-1D",
120+
"PT-1H"
121+
})
122+
@DisplayName("When Duration Is Invalid Then Validation Fails")
123+
void whenDurationIsInvalidThenValidationFails(String duration) {
124+
125+
// Given Result With Invalid Duration
126+
final var result = Result.builder().duration(duration).build();
127+
128+
// When Validating Result
129+
final Set<ConstraintViolation<Result>> violations = validator.validate(result);
130+
131+
// Then Validation Fails
132+
assertThat(violations, not(empty()));
133+
assertThat(violations, hasSize(1));
134+
}
135+
136+
@Test
137+
@DisplayName("When Duration Is Null Then Validation Passes")
138+
void whenDurationIsNullThenValidationPasses() {
139+
140+
// Given Result With Null Duration
141+
final var result = Result.builder().duration(null).build();
142+
143+
// When Validating Result
144+
final Set<ConstraintViolation<Result>> violations = validator.validate(result);
145+
146+
// Then Validation Passes
147+
assertThat(violations, empty());
148+
}
149+
150+
@Test
151+
@DisplayName("When Duration Has Many Digits Then Validation Completes Quickly")
152+
void whenDurationHasManyDigitsThenValidationCompletesQuickly() {
153+
154+
// Given Result With Long But Valid Duration
155+
final var result = Result.builder().duration("P99999Y99999M99999DT99999H99999M99999S").build();
156+
157+
// When Validating Result
158+
final long startTime = System.nanoTime();
159+
final Set<ConstraintViolation<Result>> violations = validator.validate(result);
160+
final long endTime = System.nanoTime();
161+
final long durationMs = (endTime - startTime) / 1_000_000;
162+
163+
// Then Validation Passes And Completes In Reasonable Time
164+
assertThat(violations, empty());
165+
assertThat("Validation should complete in less than 100ms", durationMs < 100);
166+
}
167+
168+
@Test
169+
@DisplayName(
170+
"When Duration Is Adversarial Input Then Validation Completes Quickly Without ReDoS")
171+
void whenDurationIsAdversarialInputThenValidationCompletesQuicklyWithoutReDoS() {
172+
173+
// Given Result With Adversarial Input That Could Cause ReDoS
174+
// This input is designed to trigger exponential backtracking in vulnerable regex patterns
175+
final var adversarialInput = "P" + "9".repeat(50) + "!";
176+
final var result = Result.builder().duration(adversarialInput).build();
177+
178+
// When Validating Result
179+
final long startTime = System.nanoTime();
180+
final Set<ConstraintViolation<Result>> violations = validator.validate(result);
181+
final long endTime = System.nanoTime();
182+
final long durationMs = (endTime - startTime) / 1_000_000;
183+
184+
// Then Validation Fails Quickly (not vulnerable to ReDoS)
185+
assertThat(violations, not(empty()));
186+
assertThat("Validation should complete in less than 100ms even with adversarial input",
187+
durationMs < 100);
188+
}
189+
190+
@Test
191+
@DisplayName("When Duration Has Multiple Optional Groups Then Validation Completes Quickly")
192+
void whenDurationHasMultipleOptionalGroupsThenValidationCompletesQuickly() {
193+
194+
// Given Result With Input That Tests Multiple Optional Groups
195+
// This tests the pattern with input that doesn't match but exercises optional groups
196+
final var testInput = "PYMDTHMS";
197+
final var result = Result.builder().duration(testInput).build();
198+
199+
// When Validating Result
200+
final long startTime = System.nanoTime();
201+
final Set<ConstraintViolation<Result>> violations = validator.validate(result);
202+
final long endTime = System.nanoTime();
203+
final long durationMs = (endTime - startTime) / 1_000_000;
204+
205+
// Then Validation Fails Quickly Without Excessive Backtracking
206+
assertThat(violations, not(empty()));
207+
assertThat("Validation should complete in less than 100ms", durationMs < 100);
208+
}
209+
}

0 commit comments

Comments
 (0)