VYPR
Medium severity4.3NVD Advisory· Published Jun 8, 2026

CVE-2026-11477

CVE-2026-11477

Description

hsweb-framework's OAuth2 client component is vulnerable to open redirect due to improper validation of redirect URIs, potentially leading to authorization code leakage.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

hsweb-framework's OAuth2 client component is vulnerable to open redirect due to improper validation of redirect URIs, potentially leading to authorization code leakage.

Vulnerability

A vulnerability exists in the OAuth2Client function within the hsweb-authorization-oauth2 component of hs-web hsweb-framework up to version 5.0.1. The issue lies in the validateRedirectUri method, which incorrectly uses a prefix-based string comparison for validating redirect URIs, allowing for bypass via crafted URLs containing userinfo components [4].

Exploitation

An attacker can exploit this vulnerability by providing a crafted redirect_uri parameter during the OAuth2 authorization request. This crafted URI can appear to start with the legitimate registered redirect URL but actually point to an attacker-controlled domain. For example, a registered URL like https://trusted.example.com could be bypassed with https://trusted.example.com:password@evil.com/callback [4]. The attack can be executed remotely without authentication.

Impact

Successful exploitation of this vulnerability can lead to an open redirect, potentially resulting in the leakage of authorization codes. When the redirect URI is processed, the authorization code is sent to the attacker-controlled domain, compromising the user's session and potentially allowing further unauthorized access [4].

Mitigation

The vulnerability was addressed in commit c2882679a9125cea52678151af5ae213cbd52579 [1], which replaced prefix-based validation with URI component checks and introduced a redirect-uri-validation-mode configuration. Applying this patch or updating to a fixed version is advised. The exact fixed version is not explicitly stated, but the commit was merged on May 19, 2026 [2].

AI Insight generated on Jun 8, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected products

2

Patches

1
c2882679a912

fix(oauth2): 修复 redirect_uri 校验绕过 (#355)

https://github.com/hs-web/hsweb-framework老周(AI)May 19, 2026via nvd-ref
9 files changed · +426 14
  • hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/code/AuthorizationCodeCache.java+3 1 modified
    @@ -23,4 +23,6 @@ public class AuthorizationCodeCache implements Serializable {
     
         private String scope;
     
    -}
    \ No newline at end of file
    +    private String redirectUri;
    +
    +}
    
  • hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/code/AuthorizationCodeTokenRequest.java+4 0 modified
    @@ -28,4 +28,8 @@ public Optional<String> code() {
         public Optional<String> scope() {
             return getParameter(OAuth2Constants.scope).map(String::valueOf);
         }
    +
    +    public Optional<String> redirectUri() {
    +        return getParameter(OAuth2Constants.redirect_uri).map(String::valueOf);
    +    }
     }
    
  • hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/code/DefaultAuthorizationCodeGranter.java+10 0 modified
    @@ -55,6 +55,10 @@ public Mono<AuthorizationCodeResponse> requestCode(AuthorizationCodeRequest requ
             request.getParameter(OAuth2Constants.scope).map(String::valueOf).ifPresent(codeCache::setScope);
             codeCache.setCode(code);
             codeCache.setClientId(client.getClientId());
    +        codeCache.setRedirectUri(request
    +                                         .getParameter(OAuth2Constants.redirect_uri)
    +                                         .map(String::valueOf)
    +                                         .orElse(client.getRedirectUrl()));
     
             ScopePredicate permissionPredicate = OAuth2ScopeUtils.createScopePredicate(codeCache.getScope());
     
    @@ -92,6 +96,12 @@ public Mono<AccessToken> requestToken(AuthorizationCodeTokenRequest request) {
                         if (!request.getClient().getClientId().equals(cache.getClientId())) {
                             return Mono.error(new OAuth2Exception(ErrorType.ILLEGAL_CLIENT_ID));
                         }
    +                    String redirectUri = request
    +                            .redirectUri()
    +                            .orElse(request.getClient().getRedirectUrl());
    +                    if (!request.getClient().isSameRedirectUri(redirectUri, cache.getRedirectUri())) {
    +                        return Mono.error(new OAuth2Exception(ErrorType.ILLEGAL_REDIRECT_URI));
    +                    }
                         return accessTokenManager
                                 .createAccessToken(cache.getClientId(), cache.getAuthentication(), false)
                                 .flatMap(token -> new OAuth2GrantedEvent(request.getClient(),
    
  • hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/OAuth2Client.java+118 3 modified
    @@ -4,10 +4,12 @@
     import lombok.Setter;
     import org.hswebframework.web.oauth2.ErrorType;
     import org.hswebframework.web.oauth2.OAuth2Exception;
    -import org.springframework.util.ObjectUtils;
     import org.springframework.util.StringUtils;
     
     import jakarta.validation.constraints.NotBlank;
    +import java.net.URI;
    +import java.net.URISyntaxException;
    +import java.util.Objects;
     
     @Getter
     @Setter
    @@ -31,13 +33,126 @@ public class OAuth2Client {
         private String userId;
     
         public void validateRedirectUri(String redirectUri) {
    -        if (ObjectUtils.isEmpty(redirectUri) || (!redirectUri.startsWith(this.redirectUrl))) {
    +        validateRedirectUri(redirectUri, OAuth2Properties.RedirectUriValidationMode.COMPATIBLE);
    +    }
    +
    +    public void validateRedirectUri(String redirectUri, OAuth2Properties.RedirectUriValidationMode validationMode) {
    +        if (!isValidRedirectUri(redirectUri, validationMode)) {
                 throw new OAuth2Exception(ErrorType.ILLEGAL_REDIRECT_URI);
             }
         }
     
    +    public boolean isSameRedirectUri(String redirectUri, String anotherRedirectUri) {
    +        URI left = parseUri(redirectUri);
    +        URI right = parseUri(anotherRedirectUri);
    +        return left != null
    +                && right != null
    +                && !hasFragment(left)
    +                && !hasFragment(right)
    +                && isExactMatch(left, right);
    +    }
    +
    +    private boolean isValidRedirectUri(String redirectUri, OAuth2Properties.RedirectUriValidationMode validationMode) {
    +        if (!StringUtils.hasText(redirectUri) || !StringUtils.hasText(this.redirectUrl)) {
    +            return false;
    +        }
    +        URI registered = parseUri(this.redirectUrl);
    +        URI actual = parseUri(redirectUri);
    +        if (registered == null || actual == null) {
    +            return false;
    +        }
    +        if (hasFragment(registered) || hasFragment(actual)) {
    +            return false;
    +        }
    +        registered = registered.normalize();
    +        actual = actual.normalize();
    +        if (registered.isOpaque() || actual.isOpaque()) {
    +            return isExactMatch(registered, actual);
    +        }
    +        if (!hasSameEndpoint(registered, actual)) {
    +            return false;
    +        }
    +        if (validationMode == OAuth2Properties.RedirectUriValidationMode.EXACT) {
    +            return isExactPathAndQuery(registered, actual);
    +        }
    +        return matchCompatiblePath(registered.getPath(), actual.getPath())
    +                && matchCompatibleQuery(registered.getRawQuery(), actual.getRawQuery());
    +    }
    +
    +    private boolean hasSameEndpoint(URI registered, URI actual) {
    +        return equalsIgnoreCase(registered.getScheme(), actual.getScheme())
    +                && Objects.equals(registered.getUserInfo(), actual.getUserInfo())
    +                && equalsIgnoreCase(registered.getHost(), actual.getHost())
    +                && registered.getPort() == actual.getPort();
    +    }
    +
    +    private boolean isExactMatch(URI left, URI right) {
    +        if (!equalsIgnoreCase(left.getScheme(), right.getScheme())) {
    +            return false;
    +        }
    +        if (left.isOpaque() || right.isOpaque()) {
    +            return Objects.equals(left.getRawSchemeSpecificPart(), right.getRawSchemeSpecificPart());
    +        }
    +        return Objects.equals(left.getUserInfo(), right.getUserInfo())
    +                && equalsIgnoreCase(left.getHost(), right.getHost())
    +                && left.getPort() == right.getPort()
    +                && isExactPathAndQuery(left, right)
    +                && Objects.equals(left.getRawFragment(), right.getRawFragment());
    +    }
    +
    +    private boolean isExactPathAndQuery(URI registered, URI actual) {
    +        return Objects.equals(pathOrEmpty(registered), pathOrEmpty(actual))
    +                && Objects.equals(registered.getRawQuery(), actual.getRawQuery());
    +    }
    +
    +    private URI parseUri(String value) {
    +        try {
    +            return new URI(value.trim());
    +        } catch (URISyntaxException e) {
    +            return null;
    +        }
    +    }
    +
    +    private boolean hasFragment(URI uri) {
    +        return StringUtils.hasLength(uri.getRawFragment());
    +    }
    +
    +    private String pathOrEmpty(URI uri) {
    +        return uri.getPath() == null ? "" : uri.getPath();
    +    }
    +
    +    private boolean matchCompatiblePath(String registeredPath, String actualPath) {
    +        String registered = registeredPath == null ? "" : registeredPath;
    +        String actual = actualPath == null ? "" : actualPath;
    +        if (registered.isEmpty()) {
    +            return actual.isEmpty() || actual.startsWith("/");
    +        }
    +        if (actual.equals(registered)) {
    +            return true;
    +        }
    +        if (registered.endsWith("/")) {
    +            return actual.startsWith(registered);
    +        }
    +        return actual.startsWith(registered + "/");
    +    }
    +
    +    private boolean matchCompatibleQuery(String registeredQuery, String actualQuery) {
    +        if (!StringUtils.hasLength(registeredQuery)) {
    +            return true;
    +        }
    +        if (!StringUtils.hasLength(actualQuery)) {
    +            return false;
    +        }
    +        return actualQuery.equals(registeredQuery)
    +                || actualQuery.startsWith(registeredQuery + "&");
    +    }
    +
    +    private boolean equalsIgnoreCase(String left, String right) {
    +        return left == null ? right == null : left.equalsIgnoreCase(right);
    +    }
    +
         public void validateSecret(String secret) {
    -        if (ObjectUtils.isEmpty(secret) || (!secret.equals(this.clientSecret))) {
    +        if (!StringUtils.hasLength(secret) || (!secret.equals(this.clientSecret))) {
                 throw new OAuth2Exception(ErrorType.ILLEGAL_CLIENT_SECRET);
             }
         }
    
  • hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/OAuth2Properties.java+8 0 modified
    @@ -17,4 +17,12 @@ public class OAuth2Properties {
         //refreshToken有效期
         private Duration refreshTokenIn = Duration.ofDays(30);
     
    +    //redirect_uri 校验模式
    +    private RedirectUriValidationMode redirectUriValidationMode = RedirectUriValidationMode.COMPATIBLE;
    +
    +    public enum RedirectUriValidationMode {
    +        COMPATIBLE,
    +        EXACT
    +    }
    +
     }
    
  • hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/OAuth2ServerAutoConfiguration.java+3 2 modified
    @@ -99,8 +99,9 @@ public OAuth2GrantService oAuth2GrantService(ObjectProvider<AuthorizationCodeGra
             @ConditionalOnMissingBean
             @ConditionalOnBean(OAuth2ClientManager.class)
             public OAuth2AuthorizeController oAuth2AuthorizeController(OAuth2GrantService grantService,
    -                                                                   OAuth2ClientManager clientManager) {
    -            return new OAuth2AuthorizeController(grantService, clientManager);
    +                                                                   OAuth2ClientManager clientManager,
    +                                                                   OAuth2Properties properties) {
    +            return new OAuth2AuthorizeController(grantService, clientManager, properties);
             }
     
         }
    
  • hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/web/OAuth2AuthorizeController.java+10 2 modified
    @@ -16,6 +16,7 @@
     import org.hswebframework.web.oauth2.server.OAuth2Client;
     import org.hswebframework.web.oauth2.server.OAuth2ClientManager;
     import org.hswebframework.web.oauth2.server.OAuth2GrantService;
    +import org.hswebframework.web.oauth2.server.OAuth2Properties;
     import org.hswebframework.web.oauth2.server.code.AuthorizationCodeRequest;
     import org.hswebframework.web.oauth2.server.code.AuthorizationCodeTokenRequest;
     import org.hswebframework.web.oauth2.server.credential.ClientCredentialRequest;
    @@ -49,6 +50,8 @@ public class OAuth2AuthorizeController {
     
         private final OAuth2ClientManager clientManager;
     
    +    private final OAuth2Properties properties;
    +
         @GetMapping(value = "/authorize", params = "response_type=code")
         @Operation(summary = "申请授权码,并获取重定向地址", parameters = {
                 @Parameter(name = "client_id", required = true),
    @@ -66,7 +69,12 @@ public Mono<String> authorizeByCode(ServerWebExchange exchange) {
                             .getOAuth2Client(param.get("client_id"))
                             .flatMap(client -> {
                                 String redirectUri = param.getOrDefault("redirect_uri", client.getRedirectUrl());
    -                            client.validateRedirectUri(redirectUri);
    +                            if (redirectUri != null) {
    +                                redirectUri = redirectUri.trim();
    +                            }
    +                            client.validateRedirectUri(redirectUri, properties.getRedirectUriValidationMode());
    +                            final String validatedRedirectUri = redirectUri;
    +                            param.put("redirect_uri", validatedRedirectUri);
                                 return oAuth2GrantService
                                         .authorizationCode()
                                         .requestCode(new AuthorizationCodeRequest(client, auth, param))
    @@ -75,7 +83,7 @@ public Mono<String> authorizeByCode(ServerWebExchange exchange) {
                                                     .ofNullable(param.get("state"))
                                                     .ifPresent(state -> response.with("state", state));
                                         })
    -                                    .map(response -> buildRedirect(redirectUri, response.getParameters()));
    +                                    .map(response -> buildRedirect(validatedRedirectUri, response.getParameters()));
                             }));
         }
     
    
  • hsweb-authorization/hsweb-authorization-oauth2/src/test/java/org/hswebframework/web/oauth2/server/code/DefaultAuthorizationCodeGranterRedirectUriTest.java+178 0 added
    @@ -0,0 +1,178 @@
    +package org.hswebframework.web.oauth2.server.code;
    +
    +import org.hswebframework.web.authorization.Authentication;
    +import org.hswebframework.web.authorization.simple.SimpleAuthentication;
    +import org.hswebframework.web.authorization.simple.SimpleUser;
    +import org.hswebframework.web.oauth2.ErrorType;
    +import org.hswebframework.web.oauth2.OAuth2Exception;
    +import org.hswebframework.web.oauth2.server.AccessToken;
    +import org.hswebframework.web.oauth2.server.AccessTokenManager;
    +import org.hswebframework.web.oauth2.server.OAuth2Client;
    +import org.junit.Test;
    +import org.springframework.context.support.StaticApplicationContext;
    +import org.springframework.data.redis.core.ReactiveRedisOperations;
    +import org.springframework.data.redis.core.ReactiveValueOperations;
    +import reactor.core.publisher.Mono;
    +import reactor.test.StepVerifier;
    +
    +import java.lang.reflect.Method;
    +import java.lang.reflect.Proxy;
    +import java.util.Collections;
    +import java.util.HashMap;
    +import java.util.Map;
    +
    +public class DefaultAuthorizationCodeGranterRedirectUriTest {
    +
    +    @Test
    +    public void shouldRequestTokenWithSameRedirectUri() {
    +        DefaultAuthorizationCodeGranter granter = createGranter();
    +        OAuth2Client client = createClient();
    +
    +        Map<String, String> request = Collections.singletonMap("redirect_uri", "http://hsweb.me/callback/next");
    +
    +        granter
    +                .requestCode(new AuthorizationCodeRequest(client, createAuthentication(), request))
    +                .flatMap(response -> granter.requestToken(new AuthorizationCodeTokenRequest(client, createTokenRequest(response.getCode(), "http://hsweb.me/callback/next"))))
    +                .as(StepVerifier::create)
    +                .expectNextMatches(token -> "access-token".equals(token.getAccessToken()))
    +                .verifyComplete();
    +    }
    +
    +    @Test
    +    public void shouldRejectTokenRequestWhenRedirectUriDoesNotMatchAuthorizedUri() {
    +        DefaultAuthorizationCodeGranter granter = createGranter();
    +        OAuth2Client client = createClient();
    +
    +        Map<String, String> request = Collections.singletonMap("redirect_uri", "http://hsweb.me/callback/next");
    +
    +        granter
    +                .requestCode(new AuthorizationCodeRequest(client, createAuthentication(), request))
    +                .flatMap(response -> granter.requestToken(new AuthorizationCodeTokenRequest(client, Collections.singletonMap("code", response.getCode()))))
    +                .as(StepVerifier::create)
    +                .expectErrorMatches(error -> error instanceof OAuth2Exception
    +                        && ((OAuth2Exception) error).getType() == ErrorType.ILLEGAL_REDIRECT_URI)
    +                .verify();
    +    }
    +
    +    private DefaultAuthorizationCodeGranter createGranter() {
    +        StaticApplicationContext context = new StaticApplicationContext();
    +        context.refresh();
    +        return new DefaultAuthorizationCodeGranter(
    +                new TestAccessTokenManager(),
    +                context,
    +                createRedisOperations()
    +        );
    +    }
    +
    +    private OAuth2Client createClient() {
    +        OAuth2Client client = new OAuth2Client();
    +        client.setClientId("test-client");
    +        client.setClientSecret("test-secret");
    +        client.setRedirectUrl("http://hsweb.me/callback");
    +        return client;
    +    }
    +
    +    private Authentication createAuthentication() {
    +        SimpleAuthentication authentication = new SimpleAuthentication();
    +        authentication.setUser(SimpleUser
    +                                       .builder()
    +                                       .id("test-user")
    +                                       .build());
    +        return authentication;
    +    }
    +
    +    private Map<String, String> createTokenRequest(String code, String redirectUri) {
    +        Map<String, String> request = new HashMap<>();
    +        request.put("code", code);
    +        request.put("redirect_uri", redirectUri);
    +        return request;
    +    }
    +
    +    @SuppressWarnings("unchecked")
    +    private ReactiveRedisOperations<String, AuthorizationCodeCache> createRedisOperations() {
    +        Map<String, AuthorizationCodeCache> storage = new HashMap<>();
    +
    +        ReactiveValueOperations<String, AuthorizationCodeCache> valueOperations =
    +                (ReactiveValueOperations<String, AuthorizationCodeCache>) Proxy.newProxyInstance(
    +                        getClass().getClassLoader(),
    +                        new Class[]{ReactiveValueOperations.class},
    +                        (proxy, method, args) -> {
    +                            if (isObjectMethod(method)) {
    +                                return handleObjectMethod(proxy, method, args, "InMemoryReactiveValueOperations");
    +                            }
    +                            if ("set".equals(method.getName())) {
    +                                storage.put((String) args[0], (AuthorizationCodeCache) args[1]);
    +                                return Mono.just(true);
    +                            }
    +                            if ("get".equals(method.getName())) {
    +                                return Mono.justOrEmpty(storage.get((String) args[0]));
    +                            }
    +                            if ("delete".equals(method.getName())) {
    +                                return Mono.just(storage.remove((String) args[0]) != null);
    +                            }
    +                            throw new UnsupportedOperationException(method.toGenericString());
    +                        }
    +                );
    +
    +        return (ReactiveRedisOperations<String, AuthorizationCodeCache>) Proxy.newProxyInstance(
    +                getClass().getClassLoader(),
    +                new Class[]{ReactiveRedisOperations.class},
    +                (proxy, method, args) -> {
    +                    if (isObjectMethod(method)) {
    +                        return handleObjectMethod(proxy, method, args, "InMemoryReactiveRedisOperations");
    +                    }
    +                    if ("opsForValue".equals(method.getName())) {
    +                        return valueOperations;
    +                    }
    +                    throw new UnsupportedOperationException(method.toGenericString());
    +                }
    +        );
    +    }
    +
    +    private boolean isObjectMethod(Method method) {
    +        return method.getDeclaringClass() == Object.class;
    +    }
    +
    +    private Object handleObjectMethod(Object proxy, Method method, Object[] args, String name) {
    +        if ("toString".equals(method.getName())) {
    +            return name;
    +        }
    +        if ("hashCode".equals(method.getName())) {
    +            return System.identityHashCode(proxy);
    +        }
    +        if ("equals".equals(method.getName())) {
    +            return proxy == args[0];
    +        }
    +        return null;
    +    }
    +
    +    private static class TestAccessTokenManager implements AccessTokenManager {
    +
    +        @Override
    +        public Mono<Authentication> getAuthenticationByToken(String accessToken) {
    +            return Mono.empty();
    +        }
    +
    +        @Override
    +        public Mono<AccessToken> createAccessToken(String clientId,
    +                                                   Authentication authentication,
    +                                                   boolean singleton) {
    +            return Mono.just(new AccessToken("access-token", "refresh-token", 7200));
    +        }
    +
    +        @Override
    +        public Mono<AccessToken> refreshAccessToken(String clientId, String refreshToken) {
    +            return Mono.empty();
    +        }
    +
    +        @Override
    +        public Mono<Void> removeToken(String clientId, String token) {
    +            return Mono.empty();
    +        }
    +
    +        @Override
    +        public Mono<Void> cancelGrant(String clientId, String userId) {
    +            return Mono.empty();
    +        }
    +    }
    +}
    
  • hsweb-authorization/hsweb-authorization-oauth2/src/test/java/org/hswebframework/web/oauth2/server/OAuth2ClientTest.java+92 6 modified
    @@ -1,20 +1,106 @@
     package org.hswebframework.web.oauth2.server;
     
     import org.junit.Test;
    +import org.hswebframework.web.oauth2.ErrorType;
    +import org.hswebframework.web.oauth2.OAuth2Exception;
     
     import static org.junit.Assert.*;
     
     public class OAuth2ClientTest {
     
         @Test
    -    public void test(){
    -        OAuth2Client client=new OAuth2Client();
    +    public void shouldAllowCompatibleRedirectVariants() {
    +        OAuth2Client client = createClient("http://hsweb.me/callback");
    +        client.validateRedirectUri("http://hsweb.me/callback");
    +        client.validateRedirectUri("http://hsweb.me/callback?a=1&n=1");
    +        client.validateRedirectUri("http://hsweb.me/callback/next");
    +    }
     
    -        client.setRedirectUrl("http://hsweb.me/callback");
    +    @Test
    +    public void shouldAllowSubPathWhenRegisteredUrlIsOrigin() {
    +        createClient("https://trusted.example.com")
    +                .validateRedirectUri("https://trusted.example.com/callback");
    +    }
     
    -        client.validateRedirectUri("http://hsweb.me/callback");
    +    @Test
    +    public void shouldRejectRedirectUriUserInfoBypass() {
    +        assertIllegalRedirect(
    +                createClient("https://trusted.example.com"),
    +                "https://trusted.example.com:password@evil.com/callback"
    +        );
    +    }
     
    -        client.validateRedirectUri("http://hsweb.me/callback?a=1&n=1");
    +    @Test
    +    public void shouldRejectSiblingPathWithSamePrefix() {
    +        assertIllegalRedirect(
    +                createClient("http://hsweb.me/callback"),
    +                "http://hsweb.me/callback2"
    +        );
    +    }
    +
    +    @Test
    +    public void shouldRejectDifferentHost() {
    +        assertIllegalRedirect(
    +                createClient("http://hsweb.me/callback"),
    +                "http://evil.com/callback"
    +        );
    +    }
    +
    +    @Test
    +    public void shouldRejectFragmentRedirectUri() {
    +        assertIllegalRedirect(
    +                createClient("http://hsweb.me/callback"),
    +                "http://hsweb.me/callback#code"
    +        );
    +    }
    +
    +    @Test
    +    public void shouldRequireExactRedirectUriInExactMode() {
    +        OAuth2Client client = createClient("http://hsweb.me/callback");
    +        client.validateRedirectUri(
    +                "http://hsweb.me/callback",
    +                OAuth2Properties.RedirectUriValidationMode.EXACT
    +        );
    +        createClient("http://hsweb.me/callback?a=1")
    +                .validateRedirectUri(
    +                        "http://hsweb.me/callback?a=1",
    +                        OAuth2Properties.RedirectUriValidationMode.EXACT
    +                );
    +    }
    +
    +    @Test
    +    public void shouldRejectCompatibleOnlyRedirectInExactMode() {
    +        OAuth2Client client = createClient("http://hsweb.me/callback");
    +        assertIllegalRedirect(
    +                client,
    +                "http://hsweb.me/callback/next",
    +                OAuth2Properties.RedirectUriValidationMode.EXACT
    +        );
    +        assertIllegalRedirect(
    +                createClient("http://hsweb.me/callback?a=1"),
    +                "http://hsweb.me/callback?a=1&n=1",
    +                OAuth2Properties.RedirectUriValidationMode.EXACT
    +        );
    +    }
    +
    +    private OAuth2Client createClient(String redirectUrl) {
    +        OAuth2Client client = new OAuth2Client();
    +        client.setRedirectUrl(redirectUrl);
    +        return client;
    +    }
    +
    +    private void assertIllegalRedirect(OAuth2Client client, String redirectUri) {
    +        assertIllegalRedirect(client, redirectUri, OAuth2Properties.RedirectUriValidationMode.COMPATIBLE);
    +    }
     
    +    private void assertIllegalRedirect(OAuth2Client client,
    +                                       String redirectUri,
    +                                       OAuth2Properties.RedirectUriValidationMode validationMode) {
    +        try {
    +            client.validateRedirectUri(redirectUri, validationMode);
    +            fail("expected redirect uri to be rejected");
    +        } catch (OAuth2Exception e) {
    +            assertEquals(ErrorType.ILLEGAL_REDIRECT_URI, e.getType());
    +        }
         }
    -}
    \ No newline at end of file
    +}
    

Vulnerability mechanics

Root cause

"The OAuth2 client's redirect URI validation did not properly restrict redirect URIs to only those registered with the client, allowing for open redirect vulnerabilities."

Attack vector

An attacker can craft a malicious link that redirects users to an arbitrary external website by exploiting the insufficient validation of the `redirect_uri` parameter. This can be achieved by providing a `redirect_uri` that is not strictly validated against the client's registered redirect URLs. The attack can be executed remotely by tricking a user into clicking a crafted link [ref_id=1].

Affected code

The vulnerability lies within the `OAuth2Client.java` file, specifically in the `validateRedirectUri` method, which previously performed a simple `startsWith` check. The fix modifies this method and introduces new helper methods for more granular URI validation, as seen in the diff for `OAuth2Client.java` [patch_id=5163739]. Additionally, changes were made to `OAuth2AuthorizeController.java` to incorporate the new validation mode and `DefaultAuthorizationCodeGranter.java` to store and compare redirect URIs.

What the fix does

The patch introduces a more robust validation mechanism for redirect URIs within the `OAuth2Client` class [patch_id=5163739]. It now parses URIs and compares them based on scheme, host, port, path, and query parameters, with options for exact matching or compatible path matching. This prevents redirect URIs that do not precisely match the registered ones, closing the open redirect vulnerability [ref_id=1].

Preconditions

  • inputThe attacker must be able to control the `redirect_uri` parameter in an OAuth2 authorization request.
  • configThe application must be configured to use the OAuth2 client functionality.

Generated on Jun 8, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

7

News mentions

0

No linked articles in our index yet.