From ad486d3f6e977755eaae8e75bc645779b82e2a03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Csahvx655-wq=E2=80=9D?= <“sahvx655@gmail.com”> Date: Wed, 3 Jun 2026 13:09:34 +0530 Subject: [PATCH 1/6] Prevent URL path traversal bypass via percent encoding in UrlValidator --- .../validator/routines/UrlValidator.java | 28 ++++++++++++++++++- .../validator/routines/UrlValidatorTest.java | 13 +++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/apache/commons/validator/routines/UrlValidator.java b/src/main/java/org/apache/commons/validator/routines/UrlValidator.java index 44862bcc9..3ae6e82b9 100644 --- a/src/main/java/org/apache/commons/validator/routines/UrlValidator.java +++ b/src/main/java/org/apache/commons/validator/routines/UrlValidator.java @@ -474,6 +474,32 @@ protected boolean isValidFragment(final String fragment) { return isOff(NO_FRAGMENTS); } + private String decodePath(final String path) { + final StringBuilder sb = new StringBuilder(); + final int len = path.length(); + for (int i = 0; i < len; i++) { + final char c = path.charAt(i); + if (c == '%' && i + 2 < len) { + final char h1 = path.charAt(i + 1); + final char h2 = path.charAt(i + 2); + if (h1 == '2') { + if (h2 == 'e' || h2 == 'E') { + sb.append('.'); + i += 2; + continue; + } + if (h2 == 'f' || h2 == 'F') { + sb.append('/'); + i += 2; + continue; + } + } + } + sb.append(c); + } + return sb.toString(); + } + /** * Returns true if the path is valid. A {@code null} value is considered invalid. * @@ -487,7 +513,7 @@ protected boolean isValidPath(final String path) { try { // Don't omit host otherwise leading path may be taken as host if it starts with // - final URI uri = new URI(null, "localhost", path, null); + final URI uri = new URI(null, "localhost", decodePath(path), null); final String norm = uri.normalize().getPath(); if (norm.startsWith("/../") // Trying to go via the parent dir || norm.equals("/..")) { // Trying to go to the parent dir diff --git a/src/test/java/org/apache/commons/validator/routines/UrlValidatorTest.java b/src/test/java/org/apache/commons/validator/routines/UrlValidatorTest.java index 25e0b5dc5..7708f8842 100644 --- a/src/test/java/org/apache/commons/validator/routines/UrlValidatorTest.java +++ b/src/test/java/org/apache/commons/validator/routines/UrlValidatorTest.java @@ -524,6 +524,19 @@ void testValidator363() { assertTrue(urlValidator.isValid("http://www.example.org/.../..")); } + @Test + void testPathTraversalPercentEncoding() { + final UrlValidator urlValidator = new UrlValidator(); + assertFalse(urlValidator.isValid("http://www.example.org/..%2fworld")); + assertFalse(urlValidator.isValid("http://www.example.org/..%2Fworld")); + assertFalse(urlValidator.isValid("http://www.example.org/%2e%2e/world")); + assertFalse(urlValidator.isValid("http://www.example.org/%2e%2e%2fworld")); + assertFalse(urlValidator.isValid("http://www.example.org/%2E%2E%2Fworld")); + assertFalse(urlValidator.isValid("http://www.example.org/..%2f")); + assertFalse(urlValidator.isValid("http://www.example.org/%2e%2e")); + assertFalse(urlValidator.isValid("http://www.example.org/%2e%2e/")); + } + @Test void testValidator375() { final UrlValidator validator = new UrlValidator(); From 7c1e99d649ffa0cbeea1a23deb795538b92a315d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Csahvx655-wq=E2=80=9D?= <“sahvx655@gmail.com”> Date: Tue, 9 Jun 2026 13:21:28 +0530 Subject: [PATCH 2/6] Refactor percent decoding to use standard URLDecoder --- .../validator/routines/UrlValidator.java | 32 ++++++------------- .../validator/routines/UrlValidatorTest.java | 4 +++ 2 files changed, 14 insertions(+), 22 deletions(-) diff --git a/src/main/java/org/apache/commons/validator/routines/UrlValidator.java b/src/main/java/org/apache/commons/validator/routines/UrlValidator.java index 3ae6e82b9..dc3244514 100644 --- a/src/main/java/org/apache/commons/validator/routines/UrlValidator.java +++ b/src/main/java/org/apache/commons/validator/routines/UrlValidator.java @@ -17,8 +17,11 @@ package org.apache.commons.validator.routines; import java.io.Serializable; +import java.io.UnsupportedEncodingException; import java.net.URI; import java.net.URISyntaxException; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; import java.util.Collections; import java.util.HashSet; import java.util.Locale; @@ -475,29 +478,14 @@ protected boolean isValidFragment(final String fragment) { } private String decodePath(final String path) { - final StringBuilder sb = new StringBuilder(); - final int len = path.length(); - for (int i = 0; i < len; i++) { - final char c = path.charAt(i); - if (c == '%' && i + 2 < len) { - final char h1 = path.charAt(i + 1); - final char h2 = path.charAt(i + 2); - if (h1 == '2') { - if (h2 == 'e' || h2 == 'E') { - sb.append('.'); - i += 2; - continue; - } - if (h2 == 'f' || h2 == 'F') { - sb.append('/'); - i += 2; - continue; - } - } - } - sb.append(c); + try { + // URLDecoder.decode converts '+' to a space character. + // Since '+' is a valid literal character in a URI path, + // we escape it to '%2B' before decoding to preserve it. + return URLDecoder.decode(path.replace("+", "%2B"), StandardCharsets.UTF_8.name()); + } catch (final UnsupportedEncodingException | IllegalArgumentException e) { + return path; } - return sb.toString(); } /** diff --git a/src/test/java/org/apache/commons/validator/routines/UrlValidatorTest.java b/src/test/java/org/apache/commons/validator/routines/UrlValidatorTest.java index 7708f8842..a0a7b1143 100644 --- a/src/test/java/org/apache/commons/validator/routines/UrlValidatorTest.java +++ b/src/test/java/org/apache/commons/validator/routines/UrlValidatorTest.java @@ -535,6 +535,10 @@ void testPathTraversalPercentEncoding() { assertFalse(urlValidator.isValid("http://www.example.org/..%2f")); assertFalse(urlValidator.isValid("http://www.example.org/%2e%2e")); assertFalse(urlValidator.isValid("http://www.example.org/%2e%2e/")); + + // Test literal '+' and '%2B' in path + assertTrue(urlValidator.isValid("http://www.example.org/foo+bar")); + assertTrue(urlValidator.isValid("http://www.example.org/foo%2Bbar")); } @Test From c60f75d177a308e8fa4d1bd8884f534695ac01a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Csahvx655-wq=E2=80=9D?= <“sahvx655@gmail.com”> Date: Tue, 9 Jun 2026 16:01:37 +0530 Subject: [PATCH 3/6] Simplify path decoding and treat decoding exceptions as invalid paths --- .../commons/validator/routines/UrlValidator.java | 11 ++++------- .../commons/validator/routines/UrlValidatorTest.java | 5 +++++ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/main/java/org/apache/commons/validator/routines/UrlValidator.java b/src/main/java/org/apache/commons/validator/routines/UrlValidator.java index dc3244514..f490b21b4 100644 --- a/src/main/java/org/apache/commons/validator/routines/UrlValidator.java +++ b/src/main/java/org/apache/commons/validator/routines/UrlValidator.java @@ -479,12 +479,9 @@ protected boolean isValidFragment(final String fragment) { private String decodePath(final String path) { try { - // URLDecoder.decode converts '+' to a space character. - // Since '+' is a valid literal character in a URI path, - // we escape it to '%2B' before decoding to preserve it. - return URLDecoder.decode(path.replace("+", "%2B"), StandardCharsets.UTF_8.name()); - } catch (final UnsupportedEncodingException | IllegalArgumentException e) { - return path; + return URLDecoder.decode(path, StandardCharsets.UTF_8.name()); + } catch (final UnsupportedEncodingException e) { + throw new RuntimeException(e); // UTF-8 is always supported } } @@ -507,7 +504,7 @@ protected boolean isValidPath(final String path) { || norm.equals("/..")) { // Trying to go to the parent dir return false; } - } catch (final URISyntaxException e) { + } catch (final URISyntaxException | IllegalArgumentException e) { return false; } diff --git a/src/test/java/org/apache/commons/validator/routines/UrlValidatorTest.java b/src/test/java/org/apache/commons/validator/routines/UrlValidatorTest.java index a0a7b1143..25de2b9c9 100644 --- a/src/test/java/org/apache/commons/validator/routines/UrlValidatorTest.java +++ b/src/test/java/org/apache/commons/validator/routines/UrlValidatorTest.java @@ -539,6 +539,11 @@ void testPathTraversalPercentEncoding() { // Test literal '+' and '%2B' in path assertTrue(urlValidator.isValid("http://www.example.org/foo+bar")); assertTrue(urlValidator.isValid("http://www.example.org/foo%2Bbar")); + + // Test direct call to isValidPath with invalid percent encoding + assertFalse(urlValidator.isValidPath("/foo%frbar")); + assertTrue(urlValidator.isValidPath("/foo+bar")); + assertTrue(urlValidator.isValidPath("/foo%2Bbar")); } @Test From 669a83c6cc8510a1d82000cb909d8e5d212c3b06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Csahvx655-wq=E2=80=9D?= <“sahvx655@gmail.com”> Date: Fri, 19 Jun 2026 14:30:58 +0530 Subject: [PATCH 4/6] Simplify path decoding, preserve plus character, and count consecutive slashes on decoded path --- .../validator/routines/UrlValidator.java | 31 ++++------ .../validator/routines/UrlValidatorTest.java | 59 ++++++++++++------- 2 files changed, 49 insertions(+), 41 deletions(-) diff --git a/src/main/java/org/apache/commons/validator/routines/UrlValidator.java b/src/main/java/org/apache/commons/validator/routines/UrlValidator.java index 687d1925d..c220830fd 100644 --- a/src/main/java/org/apache/commons/validator/routines/UrlValidator.java +++ b/src/main/java/org/apache/commons/validator/routines/UrlValidator.java @@ -32,10 +32,8 @@ import org.apache.commons.validator.GenericValidator; /** - * URL Validation routines. - *
+ *
URL Validation routines.
* Behavior of validation is modified by passing in options: - * *Checks if a field has a valid URL address.
* * Note that the method calls #isValidAuthority() * which checks that the domain is valid. @@ -479,14 +477,6 @@ protected boolean isValidFragment(final String fragment) { return isOff(NO_FRAGMENTS); } - private String decodePath(final String path) { - try { - return URLDecoder.decode(path, StandardCharsets.UTF_8.name()); - } catch (final UnsupportedEncodingException e) { - throw new RuntimeException(e); // UTF-8 is always supported - } - } - /** * Returns true if the path is valid. A {@code null} value is considered invalid. * @@ -499,19 +489,22 @@ protected boolean isValidPath(final String path) { } try { + // URLDecoder.decode converts '+' to a space character. + // Since '+' is a valid literal character in a URI path, + // we escape it to '%2B' before decoding to preserve it. + final String decodedPath = URLDecoder.decode(path.replace("+", "%2B"), StandardCharsets.UTF_8.name()); // Don't omit host otherwise leading path may be taken as host if it starts with // - final URI uri = new URI(null, "localhost", decodePath(path), null); + final URI uri = new URI(null, "localhost", decodedPath, null); final String norm = uri.normalize().getPath(); if (norm.startsWith("/../") // Trying to go via the parent dir || norm.equals("/..")) { // Trying to go to the parent dir return false; } - } catch (final URISyntaxException | IllegalArgumentException e) { - return false; - } - - final int slash2Count = countToken("//", path); - if (isOff(ALLOW_2_SLASHES) && slash2Count > 0) { + final int slash2Count = countToken("//", decodedPath); + if (isOff(ALLOW_2_SLASHES) && slash2Count > 0) { + return false; + } + } catch (final UnsupportedEncodingException | IllegalArgumentException | URISyntaxException e) { return false; } diff --git a/src/test/java/org/apache/commons/validator/routines/UrlValidatorTest.java b/src/test/java/org/apache/commons/validator/routines/UrlValidatorTest.java index 25de2b9c9..90d81b9d2 100644 --- a/src/test/java/org/apache/commons/validator/routines/UrlValidatorTest.java +++ b/src/test/java/org/apache/commons/validator/routines/UrlValidatorTest.java @@ -524,28 +524,6 @@ void testValidator363() { assertTrue(urlValidator.isValid("http://www.example.org/.../..")); } - @Test - void testPathTraversalPercentEncoding() { - final UrlValidator urlValidator = new UrlValidator(); - assertFalse(urlValidator.isValid("http://www.example.org/..%2fworld")); - assertFalse(urlValidator.isValid("http://www.example.org/..%2Fworld")); - assertFalse(urlValidator.isValid("http://www.example.org/%2e%2e/world")); - assertFalse(urlValidator.isValid("http://www.example.org/%2e%2e%2fworld")); - assertFalse(urlValidator.isValid("http://www.example.org/%2E%2E%2Fworld")); - assertFalse(urlValidator.isValid("http://www.example.org/..%2f")); - assertFalse(urlValidator.isValid("http://www.example.org/%2e%2e")); - assertFalse(urlValidator.isValid("http://www.example.org/%2e%2e/")); - - // Test literal '+' and '%2B' in path - assertTrue(urlValidator.isValid("http://www.example.org/foo+bar")); - assertTrue(urlValidator.isValid("http://www.example.org/foo%2Bbar")); - - // Test direct call to isValidPath with invalid percent encoding - assertFalse(urlValidator.isValidPath("/foo%frbar")); - assertTrue(urlValidator.isValidPath("/foo+bar")); - assertTrue(urlValidator.isValidPath("/foo%2Bbar")); - } - @Test void testValidator375() { final UrlValidator validator = new UrlValidator(); @@ -648,4 +626,41 @@ void testValidator473Part3() { // reject null DomainValidator with mismatched al assertEquals("DomainValidator disagrees with ALLOW_LOCAL_URLS setting", thrown.getMessage()); } + @Test + void testValidator383() { + final UrlValidator validator = new UrlValidator(); + + // Literal traversal checks (already rejected) + assertFalse(validator.isValid("http://example.com/../etc/passwd")); + assertFalse(validator.isValid("http://example.com/..")); + assertFalse(validator.isValid("http://example.com/../")); + + // Percent-encoded traversal checks + assertFalse(validator.isValid("http://example.com/..%2fetc/passwd")); + assertFalse(validator.isValid("http://example.com/..%2Fetc/passwd")); + assertFalse(validator.isValid("http://example.com/%2e%2e/world")); + assertFalse(validator.isValid("http://example.com/%2e%2e%2fworld")); + assertFalse(validator.isValid("http://example.com/%2E%2e%2Fworld")); + + // Consecutive slashes via percent encoding + final UrlValidator noDoubleSlashes = new UrlValidator(); + assertFalse(noDoubleSlashes.isValid("http://example.com/foo%2F%2Fbar")); + assertFalse(noDoubleSlashes.isValid("http://example.com/foo%2f%2fbar")); + assertFalse(noDoubleSlashes.isValid("http://example.com/%2F%2Fbar")); + + final UrlValidator allowDoubleSlashes = new UrlValidator(UrlValidator.ALLOW_2_SLASHES); + assertTrue(allowDoubleSlashes.isValid("http://example.com/foo%2F%2Fbar")); + assertTrue(allowDoubleSlashes.isValid("http://example.com/foo%2f%2fbar")); + assertTrue(allowDoubleSlashes.isValid("http://example.com/%2F%2Fbar")); + + // Invalid percent-encoding handling + assertFalse(validator.isValid("http://example.com/foo%2")); + assertFalse(validator.isValid("http://example.com/foo%2G")); + assertFalse(validator.isValid("http://example.com/foo%")); + + // Plus character preservation + assertTrue(validator.isValid("http://example.com/foo+bar")); + assertTrue(validator.isValid("http://example.com/foo+bar/baz+qux")); + } + } From fa8b4e41639c4098ddd30588bb632192bcb46d3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Csahvx655-wq=E2=80=9D?= <“sahvx655@gmail.com”> Date: Fri, 19 Jun 2026 14:39:13 +0530 Subject: [PATCH 5/6] Remove plus character replacement from path decoding --- .../org/apache/commons/validator/routines/UrlValidator.java | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/main/java/org/apache/commons/validator/routines/UrlValidator.java b/src/main/java/org/apache/commons/validator/routines/UrlValidator.java index c220830fd..2a2e9fb71 100644 --- a/src/main/java/org/apache/commons/validator/routines/UrlValidator.java +++ b/src/main/java/org/apache/commons/validator/routines/UrlValidator.java @@ -489,10 +489,7 @@ protected boolean isValidPath(final String path) { } try { - // URLDecoder.decode converts '+' to a space character. - // Since '+' is a valid literal character in a URI path, - // we escape it to '%2B' before decoding to preserve it. - final String decodedPath = URLDecoder.decode(path.replace("+", "%2B"), StandardCharsets.UTF_8.name()); + final String decodedPath = URLDecoder.decode(path, StandardCharsets.UTF_8.name()); // Don't omit host otherwise leading path may be taken as host if it starts with // final URI uri = new URI(null, "localhost", decodedPath, null); final String norm = uri.normalize().getPath(); From 5bf633b0f58fa7a5caed19cc5ea0466740d97622 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Csahvx655-wq=E2=80=9D?= <“sahvx655@gmail.com”> Date: Fri, 19 Jun 2026 14:57:17 +0530 Subject: [PATCH 6/6] Fix trailing spaces checkstyle violations in UrlValidatorTest.java --- .../apache/commons/validator/routines/UrlValidatorTest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test/java/org/apache/commons/validator/routines/UrlValidatorTest.java b/src/test/java/org/apache/commons/validator/routines/UrlValidatorTest.java index 90d81b9d2..0cb5d9ad7 100644 --- a/src/test/java/org/apache/commons/validator/routines/UrlValidatorTest.java +++ b/src/test/java/org/apache/commons/validator/routines/UrlValidatorTest.java @@ -629,7 +629,7 @@ void testValidator473Part3() { // reject null DomainValidator with mismatched al @Test void testValidator383() { final UrlValidator validator = new UrlValidator(); - + // Literal traversal checks (already rejected) assertFalse(validator.isValid("http://example.com/../etc/passwd")); assertFalse(validator.isValid("http://example.com/..")); @@ -647,7 +647,7 @@ void testValidator383() { assertFalse(noDoubleSlashes.isValid("http://example.com/foo%2F%2Fbar")); assertFalse(noDoubleSlashes.isValid("http://example.com/foo%2f%2fbar")); assertFalse(noDoubleSlashes.isValid("http://example.com/%2F%2Fbar")); - + final UrlValidator allowDoubleSlashes = new UrlValidator(UrlValidator.ALLOW_2_SLASHES); assertTrue(allowDoubleSlashes.isValid("http://example.com/foo%2F%2Fbar")); assertTrue(allowDoubleSlashes.isValid("http://example.com/foo%2f%2fbar"));