Skip to content

Commit 541b614

Browse files
committed
feat: Add ExtrasPathValidator for traversal protection
1 parent 4c2181e commit 541b614

2 files changed

Lines changed: 145 additions & 0 deletions

File tree

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package com.retailsvc.http.internal;
2+
3+
import com.retailsvc.http.BadRequestException;
4+
import java.net.URI;
5+
import java.net.URLDecoder;
6+
import java.nio.charset.StandardCharsets;
7+
import java.util.regex.Pattern;
8+
9+
public final class ExtrasPathValidator {
10+
11+
private static final Pattern ENCODED_BLOCKLIST =
12+
Pattern.compile("(?i)%(?:25|2e|2f|5c|00|0[1-9a-f]|1[0-9a-f]|7f)");
13+
14+
private ExtrasPathValidator() {}
15+
16+
public static String validateAndDecode(URI uri) {
17+
String raw = uri.getRawPath();
18+
if (raw == null) {
19+
throw new BadRequestException("missing path");
20+
}
21+
if (ENCODED_BLOCKLIST.matcher(raw).find()) {
22+
throw new BadRequestException("path contains disallowed percent-encoded sequence");
23+
}
24+
if (raw.indexOf('\\') >= 0) {
25+
throw new BadRequestException("path contains backslash");
26+
}
27+
for (int i = 0; i < raw.length(); i++) {
28+
char c = raw.charAt(i);
29+
if (c < 0x20 || c == 0x7f) {
30+
throw new BadRequestException("path contains control character");
31+
}
32+
}
33+
34+
String decoded;
35+
try {
36+
decoded = URLDecoder.decode(raw, StandardCharsets.UTF_8);
37+
} catch (IllegalArgumentException e) {
38+
throw new BadRequestException("malformed percent-encoding");
39+
}
40+
41+
for (int i = 0; i < decoded.length(); i++) {
42+
char c = decoded.charAt(i);
43+
if (c < 0x20 || c == 0x7f) {
44+
throw new BadRequestException("decoded path contains control character");
45+
}
46+
}
47+
48+
String[] segments = decoded.substring(decoded.startsWith("/") ? 1 : 0).split("/", -1);
49+
for (int i = 0; i < segments.length; i++) {
50+
String s = segments[i];
51+
if (s.isEmpty() && i != segments.length - 1) {
52+
throw new BadRequestException("empty path segment");
53+
}
54+
if (".".equals(s) || "..".equals(s)) {
55+
throw new BadRequestException("path traversal segment");
56+
}
57+
}
58+
59+
return decoded;
60+
}
61+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package com.retailsvc.http.internal;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
5+
6+
import com.retailsvc.http.BadRequestException;
7+
import java.net.URI;
8+
import org.junit.jupiter.api.Test;
9+
10+
class ExtrasPathValidatorTest {
11+
12+
@Test
13+
void plainPathPasses() {
14+
URI uri = URI.create("/files/a/b.txt");
15+
assertThat(ExtrasPathValidator.validateAndDecode(uri)).isEqualTo("/files/a/b.txt");
16+
}
17+
18+
@Test
19+
void dotDotSegmentRejected() {
20+
assertReject("/files/../etc/passwd");
21+
}
22+
23+
@Test
24+
void singleDotSegmentRejected() {
25+
assertReject("/files/./x");
26+
}
27+
28+
@Test
29+
void emptySegmentRejected() {
30+
assertReject("/files//x");
31+
}
32+
33+
@Test
34+
void encodedDotRejected() {
35+
assertReject("/files/%2e%2e/etc/passwd");
36+
assertReject("/files/%2E/x");
37+
}
38+
39+
@Test
40+
void doubleEncodedDotRejected() {
41+
assertReject("/files/%252e%252e/etc/passwd");
42+
}
43+
44+
@Test
45+
void encodedSlashRejected() {
46+
assertReject("/files/%2fetc/passwd");
47+
assertReject("/files/%2Fetc/passwd");
48+
}
49+
50+
@Test
51+
void backslashRejected() {
52+
assertReject("/files/x%5cy");
53+
assertReject("/files/x%5Cy");
54+
}
55+
56+
@Test
57+
void literalBackslashRejected() throws Exception {
58+
URI uri = new URI("/files/x%5cy");
59+
assertThatThrownBy(() -> ExtrasPathValidator.validateAndDecode(uri))
60+
.isInstanceOf(BadRequestException.class);
61+
}
62+
63+
@Test
64+
void nulByteRejected() {
65+
assertReject("/files/x%00.txt");
66+
}
67+
68+
@Test
69+
void controlCharRejected() {
70+
assertReject("/files/x%0ay");
71+
}
72+
73+
@Test
74+
void doubleEncodedPercentRejected() {
75+
assertReject("/files/%25xx");
76+
assertReject("/files/%2500");
77+
}
78+
79+
private void assertReject(String raw) {
80+
URI uri = URI.create(raw);
81+
assertThatThrownBy(() -> ExtrasPathValidator.validateAndDecode(uri))
82+
.isInstanceOf(BadRequestException.class);
83+
}
84+
}

0 commit comments

Comments
 (0)