From d7e8bc9cfd333286d5095ea466acb02ab13a7abf Mon Sep 17 00:00:00 2001 From: Martin Dietze Date: Thu, 21 May 2026 18:55:01 +0200 Subject: [PATCH] Issue #1883: accept non-public TLDs in redirect URLs. * Previously a simple instance of the Apache UrlValidator was used. * This lead to TLDs being validated, so that custom TLDs, e.g. from internal environments, would be marked invalid. * The solution is to validate the authority part (host + if present port) using a regular expression. * Use this validator also in IdentityProviderUtil for consistency. Signed-off-by: Martin Dietze --- .../core/util/IdentityProviderUtil.java | 11 +- .../core/validator/RedirectURLValidator.java | 56 ++++++- .../core/IdentityProviderUtilTest.java | 6 + .../validator/RedirectURLValidatorTest.java | 142 ++++++++++++++++++ 4 files changed, 200 insertions(+), 15 deletions(-) create mode 100644 esignet-core/src/test/java/io/mosip/esignet/core/validator/RedirectURLValidatorTest.java diff --git a/esignet-core/src/main/java/io/mosip/esignet/core/util/IdentityProviderUtil.java b/esignet-core/src/main/java/io/mosip/esignet/core/util/IdentityProviderUtil.java index 5dc9098a3..75988d384 100644 --- a/esignet-core/src/main/java/io/mosip/esignet/core/util/IdentityProviderUtil.java +++ b/esignet-core/src/main/java/io/mosip/esignet/core/util/IdentityProviderUtil.java @@ -31,6 +31,7 @@ import io.mosip.esignet.core.constants.Constants; import io.mosip.esignet.core.constants.ErrorConstants; import io.mosip.esignet.core.exception.EsignetException; +import io.mosip.esignet.core.validator.RedirectURLValidator; import org.apache.commons.codec.binary.Hex; import org.bouncycastle.asn1.x500.X500Name; import org.bouncycastle.cert.X509CertificateHolder; @@ -41,7 +42,6 @@ import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; import org.bouncycastle.util.io.pem.PemObject; import org.bouncycastle.util.io.pem.PemReader; -import org.apache.commons.validator.routines.UrlValidator; import org.jose4j.jwk.PublicJsonWebKey; import org.jose4j.jwk.RsaJsonWebKey; import org.jose4j.jwk.EllipticCurveJsonWebKey; @@ -60,9 +60,6 @@ import jakarta.xml.bind.DatatypeConverter; -import static org.apache.commons.validator.routines.UrlValidator.ALLOW_ALL_SCHEMES; -import static org.apache.commons.validator.routines.UrlValidator.ALLOW_LOCAL_URLS; - @Slf4j @Component public class IdentityProviderUtil { @@ -80,13 +77,13 @@ public class IdentityProviderUtil { private static Base64.Encoder urlSafeEncoder; private static Base64.Decoder urlSafeDecoder; private static PathMatcher pathMatcher; - private static UrlValidator urlValidator; + private static RedirectURLValidator urlValidator; static { urlSafeEncoder = Base64.getUrlEncoder().withoutPadding(); urlSafeDecoder = Base64.getUrlDecoder(); pathMatcher = new AntPathMatcher(); - urlValidator = new UrlValidator(ALLOW_ALL_SCHEMES+ALLOW_LOCAL_URLS); + urlValidator = new RedirectURLValidator(); } /** @@ -219,7 +216,7 @@ public static String generateRandomAlphaNumeric(int length) { } private static boolean matchUri(String registeredUri, String requestedUri) { - return (urlValidator.isValid(registeredUri) && urlValidator.isValid(requestedUri)) + return (urlValidator.isValid(registeredUri, null) && urlValidator.isValid(requestedUri, null)) && pathMatcher.match(registeredUri, requestedUri); } diff --git a/esignet-core/src/main/java/io/mosip/esignet/core/validator/RedirectURLValidator.java b/esignet-core/src/main/java/io/mosip/esignet/core/validator/RedirectURLValidator.java index 07d58997f..fe1a61da6 100644 --- a/esignet-core/src/main/java/io/mosip/esignet/core/validator/RedirectURLValidator.java +++ b/esignet-core/src/main/java/io/mosip/esignet/core/validator/RedirectURLValidator.java @@ -1,24 +1,64 @@ package io.mosip.esignet.core.validator; +import org.apache.commons.validator.routines.RegexValidator; import org.apache.commons.validator.routines.UrlValidator; -import org.hibernate.validator.constraints.URL; +import static org.apache.commons.validator.routines.UrlValidator.ALLOW_ALL_SCHEMES; +import static org.apache.commons.validator.routines.UrlValidator.ALLOW_LOCAL_URLS; import org.springframework.stereotype.Component; import jakarta.validation.ConstraintValidator; import jakarta.validation.ConstraintValidatorContext; -import static org.apache.commons.validator.routines.UrlValidator.ALLOW_ALL_SCHEMES; -import static org.apache.commons.validator.routines.UrlValidator.ALLOW_LOCAL_URLS; - +/** + * @class RedirectURLValidator uses a customised Apache {@link UrlValidator} + * to check syntactical validity of redirect URLs, allowing any scheme and local URLs, + * but restricting the authority part to valid IPv4/IPv6 addresses, localhost, or domain + * names with any TLD. + */ @Component public class RedirectURLValidator implements ConstraintValidator { - private final UrlValidator urlValidator = new UrlValidator(ALLOW_ALL_SCHEMES+ALLOW_LOCAL_URLS); + // IPv6 address in brackets – strict RFC 4291 alternation covering all + // compressed (::) and full (8-group) forms; rejects bare garbage like [::::] + private static final String IPV6_REGEX = + "\\[(?:" + + "(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}" + // full 8-group, no :: + "|(?:[0-9a-fA-F]{1,4}:){1,7}:" + // trailing :: + "|(?:[0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}" + // 6+1 around :: + "|(?:[0-9a-fA-F]{1,4}:){1,5}(?::[0-9a-fA-F]{1,4}){1,2}" + // 5+1-2 + "|(?:[0-9a-fA-F]{1,4}:){1,4}(?::[0-9a-fA-F]{1,4}){1,3}" + // 4+1-3 + "|(?:[0-9a-fA-F]{1,4}:){1,3}(?::[0-9a-fA-F]{1,4}){1,4}" + // 3+1-4 + "|(?:[0-9a-fA-F]{1,4}:){1,2}(?::[0-9a-fA-F]{1,4}){1,5}" + // 2+1-5 + "|[0-9a-fA-F]{1,4}:(?::[0-9a-fA-F]{1,4}){1,6}" + // 1+1-6 + "|:(?::[0-9a-fA-F]{1,4}){1,7}" + // leading :: + "|::" + // all-zeros + ")\\]"; + // IPv4 address + private static final String IPV4_REGEX = "((25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\\.){3}(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])"; + // localhost + private static final String LOCALHOST_REGEX = "localhost"; + // Domain name with any TLD (at least two letters). + // Each label must start and end with an alphanumeric character (RFC 1123); + // hyphens are only permitted in the interior of a label. + private static final String DOMAIN_REGEX = "([a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?\\.)+[a-zA-Z]{2,}"; + // Optional port — restricted to the valid TCP/UDP range 0–65535 + private static final String PORT_REGEX = "(:(6553[0-5]|655[0-2]\\d|65[0-4]\\d{2}|6[0-4]\\d{3}|[1-5]\\d{4}|\\d{1,4}))?"; + + // The resulting regular expression validates the authority part of the URL (host and optional port) while allowing any TLD in the domain name. + private static final String AUTHORITY_PART_RX = "^(" + IPV6_REGEX + "|" + IPV4_REGEX + "|" + LOCALHOST_REGEX + "|" + DOMAIN_REGEX + ")" + PORT_REGEX + "$"; + private final UrlValidator urlValidator = new UrlValidator(new RegexValidator(AUTHORITY_PART_RX), ALLOW_ALL_SCHEMES+ALLOW_LOCAL_URLS); + + /** + * Validates redirect URLs while allowing private/non-IANA TLDs. + * + * @param redirectUrl redirect URL to validate + * @param constraintValidatorContext validation context, unused + * @return true if the redirect URL is valid + */ @Override - public boolean isValid(String redirectUrl, ConstraintValidatorContext constraintValidatorContext) { - return urlValidator.isValid(redirectUrl); + public boolean isValid(final String redirectUrl, final ConstraintValidatorContext constraintValidatorContext) { + return this.urlValidator.isValid(redirectUrl); } - } diff --git a/esignet-core/src/test/java/io/mosip/esignet/core/IdentityProviderUtilTest.java b/esignet-core/src/test/java/io/mosip/esignet/core/IdentityProviderUtilTest.java index e99d0eeb9..7c235116c 100644 --- a/esignet-core/src/test/java/io/mosip/esignet/core/IdentityProviderUtilTest.java +++ b/esignet-core/src/test/java/io/mosip/esignet/core/IdentityProviderUtilTest.java @@ -48,6 +48,12 @@ public void validateRedirectURIPositiveTest() throws EsignetException { "https://api.dev.mosip.net/home/testament?rr=rrr"); IdentityProviderUtil.validateRedirectURI(Arrays.asList("io.mosip.residentapp://oauth"), "io.mosip.residentapp://oauth"); + // Regression for issue #1883: non-public / private TLDs (.corp, .internal) + // must be accepted by the matchUri path inside validateRedirectURI. + IdentityProviderUtil.validateRedirectURI(Arrays.asList("https://sso.idp.corp/callback"), + "https://sso.idp.corp/callback"); + IdentityProviderUtil.validateRedirectURI(Arrays.asList("https://portal.company.internal/**"), + "https://portal.company.internal/auth/callback"); } @Test diff --git a/esignet-core/src/test/java/io/mosip/esignet/core/validator/RedirectURLValidatorTest.java b/esignet-core/src/test/java/io/mosip/esignet/core/validator/RedirectURLValidatorTest.java new file mode 100644 index 000000000..dc0e9758c --- /dev/null +++ b/esignet-core/src/test/java/io/mosip/esignet/core/validator/RedirectURLValidatorTest.java @@ -0,0 +1,142 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + */ +package io.mosip.esignet.core.validator; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +/** + * Verifies that redirect URLs with standard public TLDs are accepted. + */ +public class RedirectURLValidatorTest { + + private static final RedirectURLValidator REDIRECT_URL_VALIDATOR = new RedirectURLValidator(); + + /** + * Tests that valid URLs with standard IANA-registered TLDs are accepted. + */ + @Test + public void standardIanaRegisteredTldsTest() { + // .com + Assertions.assertTrue(REDIRECT_URL_VALIDATOR.isValid("https://example.com/callback", null)); + // .org + Assertions.assertTrue(REDIRECT_URL_VALIDATOR.isValid("https://example.org/callback", null)); + // twoletter TLDs like .de + Assertions.assertTrue(REDIRECT_URL_VALIDATOR.isValid("https://example.de/callback", null)); + // longer TLDs like .technology + Assertions.assertTrue(REDIRECT_URL_VALIDATOR.isValid("https://example.technology/callback", null)); + } + + /** + * Tests that valid URLs with non-IANA or custom TLDs are accepted. + */ + @Test + public void nonIanaOrCustomTldTest() { + // .xx is not a real IANA TLD but must be accepted + Assertions.assertTrue(REDIRECT_URL_VALIDATOR.isValid("https://api.dev.mosip.xx/home/test", null)); + // .internal is commonly used for private networks + Assertions.assertTrue(REDIRECT_URL_VALIDATOR.isValid("https://myservice.internal/callback", null)); + // .local is used in mDNS / private environments + Assertions.assertTrue(REDIRECT_URL_VALIDATOR.isValid("https://myapp.local/callback", null)); + // .test is an RFC 2606 reserved name for testing + Assertions.assertTrue(REDIRECT_URL_VALIDATOR.isValid("https://app.test/callback", null)); + // private TLD used inside the MOSIP ecosystem + Assertions.assertTrue(REDIRECT_URL_VALIDATOR.isValid("https://api.service.mosip/callback", null)); + // non IANA TLD .corp + Assertions.assertTrue(REDIRECT_URL_VALIDATOR.isValid("https://auth.sso.corp/callback", null)); + } + + /** + * Tests that valid URLs with IPv4 and IPv6 addresses, as well as localhost, are accepted. + */ + @Test + public void ipAddressV4V6Test() { + Assertions.assertTrue(REDIRECT_URL_VALIDATOR.isValid("http://192.168.1.1/callback", null)); + Assertions.assertTrue(REDIRECT_URL_VALIDATOR.isValid("http://10.0.0.1:8080/callback", null)); + Assertions.assertTrue(REDIRECT_URL_VALIDATOR.isValid("http://localhost/callback", null)); + Assertions.assertTrue(REDIRECT_URL_VALIDATOR.isValid("http://localhost:8080/callback", null)); + Assertions.assertTrue(REDIRECT_URL_VALIDATOR.isValid("http://[::1]/callback", null)); + Assertions.assertTrue(REDIRECT_URL_VALIDATOR.isValid("http://[2001:db8::1]/callback", null)); + // invalid: only colons, no hex digits + Assertions.assertFalse(REDIRECT_URL_VALIDATOR.isValid("http://[::::]/callback", null)); + // invalid: group exceeds four hex digits + Assertions.assertFalse(REDIRECT_URL_VALIDATOR.isValid("http://[12345::1]/callback", null)); + // invalid: nine groups (too many) + Assertions.assertFalse(REDIRECT_URL_VALIDATOR.isValid("http://[1:2:3:4:5:6:7:8:9]/callback", null)); + // invalid: non-hex character + Assertions.assertFalse(REDIRECT_URL_VALIDATOR.isValid("http://[::gggg]/callback", null)); + } + + /** + * Tests that valid URLs with ports are accepted and invalid ports are rejected. + */ + @Test + public void portsTest() { + Assertions.assertTrue(REDIRECT_URL_VALIDATOR.isValid("https://example.com:443/callback", null)); + Assertions.assertTrue(REDIRECT_URL_VALIDATOR.isValid("https://example.com:8443/callback", null)); + Assertions.assertTrue(REDIRECT_URL_VALIDATOR.isValid("https://example.com:65535/callback", null)); + // first port value above the valid range must be rejected + Assertions.assertFalse(REDIRECT_URL_VALIDATOR.isValid("https://example.com:65536/callback", null)); + // port 0 is accepted (enforcement is left to upper layers) + Assertions.assertTrue(REDIRECT_URL_VALIDATOR.isValid("https://example.com:0/callback", null)); + // clearly out-of-range port must be rejected + Assertions.assertFalse(REDIRECT_URL_VALIDATOR.isValid("https://example.com:99999/callback", null)); + } + + /** + * Tests that valid URLs with various schemes are accepted. + */ + @Test + public void schemesTest() { + // http + Assertions.assertTrue(REDIRECT_URL_VALIDATOR.isValid("http://example.com/callback", null)); + // ftp + Assertions.assertTrue(REDIRECT_URL_VALIDATOR.isValid("ftp://example.com/callback", null)); + // Mobile deep-link used by MOSIP resident app + Assertions.assertTrue(REDIRECT_URL_VALIDATOR.isValid("io.mosip.residentapp://oauth", null)); + Assertions.assertTrue(REDIRECT_URL_VALIDATOR.isValid("myapp://auth.internal/callback", null)); + } + + /** + * Tests that valid URLs with various path variations, query strings, and fragments are accepted. + */ + @Test + public void pathVariationsAndQueryStringsTest() { + Assertions.assertTrue(REDIRECT_URL_VALIDATOR.isValid("https://api.dev.mosip.net/home/testament?rr=rrr", null)); + Assertions.assertTrue(REDIRECT_URL_VALIDATOR.isValid( + "https://api.dev.mosip.net/home/werrrwqfdsfg5fgs34sdffggdfgsdfg?state=reefdf", null)); + Assertions.assertTrue(REDIRECT_URL_VALIDATOR.isValid("https://example.com/page#section", null)); + Assertions.assertTrue(REDIRECT_URL_VALIDATOR.isValid("https://a.b.c.example.com/callback", null)); + Assertions.assertTrue(REDIRECT_URL_VALIDATOR.isValid("https://api.dev.mosip.net/home/test", null)); + } + + /** + * Tests that invalid URLs are rejected. + */ + @Test + public void invalidUrlTest() { + // null + Assertions.assertFalse(REDIRECT_URL_VALIDATOR.isValid(null, null)); + // empty + Assertions.assertFalse(REDIRECT_URL_VALIDATOR.isValid("", null)); + // A URL without a scheme is not valid + Assertions.assertFalse(REDIRECT_URL_VALIDATOR.isValid("example.com/callback", null)); + // TLD must be at least two letters; single-char TLD must be rejected + Assertions.assertFalse(REDIRECT_URL_VALIDATOR.isValid("https://example.c/callback", null)); + // TLD must consist of letters only, not digits + Assertions.assertFalse(REDIRECT_URL_VALIDATOR.isValid("https://example.123/callback", null)); + // 256 is not a valid octet + Assertions.assertFalse(REDIRECT_URL_VALIDATOR.isValid("http://256.0.0.1/callback", null)); + // space in host + Assertions.assertFalse(REDIRECT_URL_VALIDATOR.isValid("https://exam ple.com/callback", null)); + // label must not start with a hyphen (RFC 1123) + Assertions.assertFalse(REDIRECT_URL_VALIDATOR.isValid("https://-bad.example.com/callback", null)); + // label must not end with a hyphen (RFC 1123) + Assertions.assertFalse(REDIRECT_URL_VALIDATOR.isValid("https://bad-.example.com/callback", null)); + // only scheme + Assertions.assertFalse(REDIRECT_URL_VALIDATOR.isValid("https://", null)); + } +} \ No newline at end of file