Skip to content

Commit c288267

Browse files
fix(oauth2): 修复 redirect_uri 校验绕过 (#355)
* fix(oauth2): 修复 redirect_uri 校验绕过 * fix(oauth2): 配置化 redirect_uri 严格校验 --------- Co-authored-by: zhou-hao <zh.sqy@qq.com>
1 parent 3843e1b commit c288267

9 files changed

Lines changed: 426 additions & 14 deletions

File tree

hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/OAuth2Client.java

Lines changed: 118 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@
44
import lombok.Setter;
55
import org.hswebframework.web.oauth2.ErrorType;
66
import org.hswebframework.web.oauth2.OAuth2Exception;
7-
import org.springframework.util.ObjectUtils;
87
import org.springframework.util.StringUtils;
98

109
import jakarta.validation.constraints.NotBlank;
10+
import java.net.URI;
11+
import java.net.URISyntaxException;
12+
import java.util.Objects;
1113

1214
@Getter
1315
@Setter
@@ -31,13 +33,126 @@ public class OAuth2Client {
3133
private String userId;
3234

3335
public void validateRedirectUri(String redirectUri) {
34-
if (ObjectUtils.isEmpty(redirectUri) || (!redirectUri.startsWith(this.redirectUrl))) {
36+
validateRedirectUri(redirectUri, OAuth2Properties.RedirectUriValidationMode.COMPATIBLE);
37+
}
38+
39+
public void validateRedirectUri(String redirectUri, OAuth2Properties.RedirectUriValidationMode validationMode) {
40+
if (!isValidRedirectUri(redirectUri, validationMode)) {
3541
throw new OAuth2Exception(ErrorType.ILLEGAL_REDIRECT_URI);
3642
}
3743
}
3844

45+
public boolean isSameRedirectUri(String redirectUri, String anotherRedirectUri) {
46+
URI left = parseUri(redirectUri);
47+
URI right = parseUri(anotherRedirectUri);
48+
return left != null
49+
&& right != null
50+
&& !hasFragment(left)
51+
&& !hasFragment(right)
52+
&& isExactMatch(left, right);
53+
}
54+
55+
private boolean isValidRedirectUri(String redirectUri, OAuth2Properties.RedirectUriValidationMode validationMode) {
56+
if (!StringUtils.hasText(redirectUri) || !StringUtils.hasText(this.redirectUrl)) {
57+
return false;
58+
}
59+
URI registered = parseUri(this.redirectUrl);
60+
URI actual = parseUri(redirectUri);
61+
if (registered == null || actual == null) {
62+
return false;
63+
}
64+
if (hasFragment(registered) || hasFragment(actual)) {
65+
return false;
66+
}
67+
registered = registered.normalize();
68+
actual = actual.normalize();
69+
if (registered.isOpaque() || actual.isOpaque()) {
70+
return isExactMatch(registered, actual);
71+
}
72+
if (!hasSameEndpoint(registered, actual)) {
73+
return false;
74+
}
75+
if (validationMode == OAuth2Properties.RedirectUriValidationMode.EXACT) {
76+
return isExactPathAndQuery(registered, actual);
77+
}
78+
return matchCompatiblePath(registered.getPath(), actual.getPath())
79+
&& matchCompatibleQuery(registered.getRawQuery(), actual.getRawQuery());
80+
}
81+
82+
private boolean hasSameEndpoint(URI registered, URI actual) {
83+
return equalsIgnoreCase(registered.getScheme(), actual.getScheme())
84+
&& Objects.equals(registered.getUserInfo(), actual.getUserInfo())
85+
&& equalsIgnoreCase(registered.getHost(), actual.getHost())
86+
&& registered.getPort() == actual.getPort();
87+
}
88+
89+
private boolean isExactMatch(URI left, URI right) {
90+
if (!equalsIgnoreCase(left.getScheme(), right.getScheme())) {
91+
return false;
92+
}
93+
if (left.isOpaque() || right.isOpaque()) {
94+
return Objects.equals(left.getRawSchemeSpecificPart(), right.getRawSchemeSpecificPart());
95+
}
96+
return Objects.equals(left.getUserInfo(), right.getUserInfo())
97+
&& equalsIgnoreCase(left.getHost(), right.getHost())
98+
&& left.getPort() == right.getPort()
99+
&& isExactPathAndQuery(left, right)
100+
&& Objects.equals(left.getRawFragment(), right.getRawFragment());
101+
}
102+
103+
private boolean isExactPathAndQuery(URI registered, URI actual) {
104+
return Objects.equals(pathOrEmpty(registered), pathOrEmpty(actual))
105+
&& Objects.equals(registered.getRawQuery(), actual.getRawQuery());
106+
}
107+
108+
private URI parseUri(String value) {
109+
try {
110+
return new URI(value.trim());
111+
} catch (URISyntaxException e) {
112+
return null;
113+
}
114+
}
115+
116+
private boolean hasFragment(URI uri) {
117+
return StringUtils.hasLength(uri.getRawFragment());
118+
}
119+
120+
private String pathOrEmpty(URI uri) {
121+
return uri.getPath() == null ? "" : uri.getPath();
122+
}
123+
124+
private boolean matchCompatiblePath(String registeredPath, String actualPath) {
125+
String registered = registeredPath == null ? "" : registeredPath;
126+
String actual = actualPath == null ? "" : actualPath;
127+
if (registered.isEmpty()) {
128+
return actual.isEmpty() || actual.startsWith("/");
129+
}
130+
if (actual.equals(registered)) {
131+
return true;
132+
}
133+
if (registered.endsWith("/")) {
134+
return actual.startsWith(registered);
135+
}
136+
return actual.startsWith(registered + "/");
137+
}
138+
139+
private boolean matchCompatibleQuery(String registeredQuery, String actualQuery) {
140+
if (!StringUtils.hasLength(registeredQuery)) {
141+
return true;
142+
}
143+
if (!StringUtils.hasLength(actualQuery)) {
144+
return false;
145+
}
146+
return actualQuery.equals(registeredQuery)
147+
|| actualQuery.startsWith(registeredQuery + "&");
148+
}
149+
150+
private boolean equalsIgnoreCase(String left, String right) {
151+
return left == null ? right == null : left.equalsIgnoreCase(right);
152+
}
153+
39154
public void validateSecret(String secret) {
40-
if (ObjectUtils.isEmpty(secret) || (!secret.equals(this.clientSecret))) {
155+
if (!StringUtils.hasLength(secret) || (!secret.equals(this.clientSecret))) {
41156
throw new OAuth2Exception(ErrorType.ILLEGAL_CLIENT_SECRET);
42157
}
43158
}

hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/OAuth2Properties.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,12 @@ public class OAuth2Properties {
1717
//refreshToken有效期
1818
private Duration refreshTokenIn = Duration.ofDays(30);
1919

20+
//redirect_uri 校验模式
21+
private RedirectUriValidationMode redirectUriValidationMode = RedirectUriValidationMode.COMPATIBLE;
22+
23+
public enum RedirectUriValidationMode {
24+
COMPATIBLE,
25+
EXACT
26+
}
27+
2028
}

hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/OAuth2ServerAutoConfiguration.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,8 +99,9 @@ public OAuth2GrantService oAuth2GrantService(ObjectProvider<AuthorizationCodeGra
9999
@ConditionalOnMissingBean
100100
@ConditionalOnBean(OAuth2ClientManager.class)
101101
public OAuth2AuthorizeController oAuth2AuthorizeController(OAuth2GrantService grantService,
102-
OAuth2ClientManager clientManager) {
103-
return new OAuth2AuthorizeController(grantService, clientManager);
102+
OAuth2ClientManager clientManager,
103+
OAuth2Properties properties) {
104+
return new OAuth2AuthorizeController(grantService, clientManager, properties);
104105
}
105106

106107
}

hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/code/AuthorizationCodeCache.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,4 +23,6 @@ public class AuthorizationCodeCache implements Serializable {
2323

2424
private String scope;
2525

26-
}
26+
private String redirectUri;
27+
28+
}

hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/code/AuthorizationCodeTokenRequest.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,4 +28,8 @@ public Optional<String> code() {
2828
public Optional<String> scope() {
2929
return getParameter(OAuth2Constants.scope).map(String::valueOf);
3030
}
31+
32+
public Optional<String> redirectUri() {
33+
return getParameter(OAuth2Constants.redirect_uri).map(String::valueOf);
34+
}
3135
}

hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/code/DefaultAuthorizationCodeGranter.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,10 @@ public Mono<AuthorizationCodeResponse> requestCode(AuthorizationCodeRequest requ
5555
request.getParameter(OAuth2Constants.scope).map(String::valueOf).ifPresent(codeCache::setScope);
5656
codeCache.setCode(code);
5757
codeCache.setClientId(client.getClientId());
58+
codeCache.setRedirectUri(request
59+
.getParameter(OAuth2Constants.redirect_uri)
60+
.map(String::valueOf)
61+
.orElse(client.getRedirectUrl()));
5862

5963
ScopePredicate permissionPredicate = OAuth2ScopeUtils.createScopePredicate(codeCache.getScope());
6064

@@ -92,6 +96,12 @@ public Mono<AccessToken> requestToken(AuthorizationCodeTokenRequest request) {
9296
if (!request.getClient().getClientId().equals(cache.getClientId())) {
9397
return Mono.error(new OAuth2Exception(ErrorType.ILLEGAL_CLIENT_ID));
9498
}
99+
String redirectUri = request
100+
.redirectUri()
101+
.orElse(request.getClient().getRedirectUrl());
102+
if (!request.getClient().isSameRedirectUri(redirectUri, cache.getRedirectUri())) {
103+
return Mono.error(new OAuth2Exception(ErrorType.ILLEGAL_REDIRECT_URI));
104+
}
95105
return accessTokenManager
96106
.createAccessToken(cache.getClientId(), cache.getAuthentication(), false)
97107
.flatMap(token -> new OAuth2GrantedEvent(request.getClient(),

hsweb-authorization/hsweb-authorization-oauth2/src/main/java/org/hswebframework/web/oauth2/server/web/OAuth2AuthorizeController.java

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import org.hswebframework.web.oauth2.server.OAuth2Client;
1717
import org.hswebframework.web.oauth2.server.OAuth2ClientManager;
1818
import org.hswebframework.web.oauth2.server.OAuth2GrantService;
19+
import org.hswebframework.web.oauth2.server.OAuth2Properties;
1920
import org.hswebframework.web.oauth2.server.code.AuthorizationCodeRequest;
2021
import org.hswebframework.web.oauth2.server.code.AuthorizationCodeTokenRequest;
2122
import org.hswebframework.web.oauth2.server.credential.ClientCredentialRequest;
@@ -49,6 +50,8 @@ public class OAuth2AuthorizeController {
4950

5051
private final OAuth2ClientManager clientManager;
5152

53+
private final OAuth2Properties properties;
54+
5255
@GetMapping(value = "/authorize", params = "response_type=code")
5356
@Operation(summary = "申请授权码,并获取重定向地址", parameters = {
5457
@Parameter(name = "client_id", required = true),
@@ -66,7 +69,12 @@ public Mono<String> authorizeByCode(ServerWebExchange exchange) {
6669
.getOAuth2Client(param.get("client_id"))
6770
.flatMap(client -> {
6871
String redirectUri = param.getOrDefault("redirect_uri", client.getRedirectUrl());
69-
client.validateRedirectUri(redirectUri);
72+
if (redirectUri != null) {
73+
redirectUri = redirectUri.trim();
74+
}
75+
client.validateRedirectUri(redirectUri, properties.getRedirectUriValidationMode());
76+
final String validatedRedirectUri = redirectUri;
77+
param.put("redirect_uri", validatedRedirectUri);
7078
return oAuth2GrantService
7179
.authorizationCode()
7280
.requestCode(new AuthorizationCodeRequest(client, auth, param))
@@ -75,7 +83,7 @@ public Mono<String> authorizeByCode(ServerWebExchange exchange) {
7583
.ofNullable(param.get("state"))
7684
.ifPresent(state -> response.with("state", state));
7785
})
78-
.map(response -> buildRedirect(redirectUri, response.getParameters()));
86+
.map(response -> buildRedirect(validatedRedirectUri, response.getParameters()));
7987
}));
8088
}
8189

Lines changed: 92 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,106 @@
11
package org.hswebframework.web.oauth2.server;
22

33
import org.junit.Test;
4+
import org.hswebframework.web.oauth2.ErrorType;
5+
import org.hswebframework.web.oauth2.OAuth2Exception;
46

57
import static org.junit.Assert.*;
68

79
public class OAuth2ClientTest {
810

911
@Test
10-
public void test(){
11-
OAuth2Client client=new OAuth2Client();
12+
public void shouldAllowCompatibleRedirectVariants() {
13+
OAuth2Client client = createClient("http://hsweb.me/callback");
14+
client.validateRedirectUri("http://hsweb.me/callback");
15+
client.validateRedirectUri("http://hsweb.me/callback?a=1&n=1");
16+
client.validateRedirectUri("http://hsweb.me/callback/next");
17+
}
1218

13-
client.setRedirectUrl("http://hsweb.me/callback");
19+
@Test
20+
public void shouldAllowSubPathWhenRegisteredUrlIsOrigin() {
21+
createClient("https://trusted.example.com")
22+
.validateRedirectUri("https://trusted.example.com/callback");
23+
}
1424

15-
client.validateRedirectUri("http://hsweb.me/callback");
25+
@Test
26+
public void shouldRejectRedirectUriUserInfoBypass() {
27+
assertIllegalRedirect(
28+
createClient("https://trusted.example.com"),
29+
"https://trusted.example.com:password@evil.com/callback"
30+
);
31+
}
1632

17-
client.validateRedirectUri("http://hsweb.me/callback?a=1&n=1");
33+
@Test
34+
public void shouldRejectSiblingPathWithSamePrefix() {
35+
assertIllegalRedirect(
36+
createClient("http://hsweb.me/callback"),
37+
"http://hsweb.me/callback2"
38+
);
39+
}
40+
41+
@Test
42+
public void shouldRejectDifferentHost() {
43+
assertIllegalRedirect(
44+
createClient("http://hsweb.me/callback"),
45+
"http://evil.com/callback"
46+
);
47+
}
48+
49+
@Test
50+
public void shouldRejectFragmentRedirectUri() {
51+
assertIllegalRedirect(
52+
createClient("http://hsweb.me/callback"),
53+
"http://hsweb.me/callback#code"
54+
);
55+
}
56+
57+
@Test
58+
public void shouldRequireExactRedirectUriInExactMode() {
59+
OAuth2Client client = createClient("http://hsweb.me/callback");
60+
client.validateRedirectUri(
61+
"http://hsweb.me/callback",
62+
OAuth2Properties.RedirectUriValidationMode.EXACT
63+
);
64+
createClient("http://hsweb.me/callback?a=1")
65+
.validateRedirectUri(
66+
"http://hsweb.me/callback?a=1",
67+
OAuth2Properties.RedirectUriValidationMode.EXACT
68+
);
69+
}
70+
71+
@Test
72+
public void shouldRejectCompatibleOnlyRedirectInExactMode() {
73+
OAuth2Client client = createClient("http://hsweb.me/callback");
74+
assertIllegalRedirect(
75+
client,
76+
"http://hsweb.me/callback/next",
77+
OAuth2Properties.RedirectUriValidationMode.EXACT
78+
);
79+
assertIllegalRedirect(
80+
createClient("http://hsweb.me/callback?a=1"),
81+
"http://hsweb.me/callback?a=1&n=1",
82+
OAuth2Properties.RedirectUriValidationMode.EXACT
83+
);
84+
}
85+
86+
private OAuth2Client createClient(String redirectUrl) {
87+
OAuth2Client client = new OAuth2Client();
88+
client.setRedirectUrl(redirectUrl);
89+
return client;
90+
}
91+
92+
private void assertIllegalRedirect(OAuth2Client client, String redirectUri) {
93+
assertIllegalRedirect(client, redirectUri, OAuth2Properties.RedirectUriValidationMode.COMPATIBLE);
94+
}
1895

96+
private void assertIllegalRedirect(OAuth2Client client,
97+
String redirectUri,
98+
OAuth2Properties.RedirectUriValidationMode validationMode) {
99+
try {
100+
client.validateRedirectUri(redirectUri, validationMode);
101+
fail("expected redirect uri to be rejected");
102+
} catch (OAuth2Exception e) {
103+
assertEquals(ErrorType.ILLEGAL_REDIRECT_URI, e.getType());
104+
}
19105
}
20-
}
106+
}

0 commit comments

Comments
 (0)