Skip to content

Commit 1856c4c

Browse files
committed
Support specify IP whitelist for Spring Security Webflux
Closes gh-7765
1 parent ba5a68e commit 1856c4c

File tree

5 files changed

+335
-0
lines changed

5 files changed

+335
-0
lines changed

config/src/main/java/org/springframework/security/config/web/server/ServerHttpSecurity.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@
133133
import org.springframework.security.web.server.authorization.AuthorizationWebFilter;
134134
import org.springframework.security.web.server.authorization.DelegatingReactiveAuthorizationManager;
135135
import org.springframework.security.web.server.authorization.ExceptionTranslationWebFilter;
136+
import org.springframework.security.web.server.authorization.IpAddressReactiveAuthorizationManager;
136137
import org.springframework.security.web.server.authorization.ServerAccessDeniedHandler;
137138
import org.springframework.security.web.server.authorization.ServerWebExchangeDelegatingServerAccessDeniedHandler;
138139
import org.springframework.security.web.server.context.NoOpServerSecurityContextRepository;
@@ -1682,6 +1683,17 @@ public AuthorizeExchangeSpec authenticated() {
16821683
return access(AuthenticatedReactiveAuthorizationManager.authenticated());
16831684
}
16841685

1686+
/**
1687+
* Require a specific IP address or range using an IP/Netmask (e.g.
1688+
* 192.168.1.0/24).
1689+
* @param ipAddress the address or range of addresses from which the request
1690+
* must come.
1691+
* @return the {@link AuthorizeExchangeSpec} to configure
1692+
*/
1693+
public AuthorizeExchangeSpec hasIpAddress(String ipAddress) {
1694+
return access(IpAddressReactiveAuthorizationManager.hasIpAddress(ipAddress));
1695+
}
1696+
16851697
/**
16861698
* Allows plugging in a custom authorization strategy
16871699
* @param manager the authorization manager to use
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
/*
2+
* Copyright 2002-2021 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.security.web.server.authorization;
18+
19+
import reactor.core.publisher.Mono;
20+
21+
import org.springframework.security.authorization.AuthorizationDecision;
22+
import org.springframework.security.authorization.ReactiveAuthorizationManager;
23+
import org.springframework.security.core.Authentication;
24+
import org.springframework.security.web.server.util.matcher.IpAddressServerWebExchangeMatcher;
25+
import org.springframework.util.Assert;
26+
27+
/**
28+
* A {@link ReactiveAuthorizationManager}, that determines if the current request contains
29+
* the specified address or range of addresses
30+
*
31+
* @author Guirong Hu
32+
* @since 5.7
33+
*/
34+
public final class IpAddressReactiveAuthorizationManager implements ReactiveAuthorizationManager<AuthorizationContext> {
35+
36+
private final IpAddressServerWebExchangeMatcher ipAddressExchangeMatcher;
37+
38+
IpAddressReactiveAuthorizationManager(String ipAddress) {
39+
this.ipAddressExchangeMatcher = new IpAddressServerWebExchangeMatcher(ipAddress);
40+
}
41+
42+
@Override
43+
public Mono<AuthorizationDecision> check(Mono<Authentication> authentication, AuthorizationContext context) {
44+
return Mono.just(context.getExchange()).flatMap(this.ipAddressExchangeMatcher::matches)
45+
.map((matchResult) -> new AuthorizationDecision(matchResult.isMatch()));
46+
}
47+
48+
/**
49+
* Creates an instance of {@link IpAddressReactiveAuthorizationManager} with the
50+
* provided IP address.
51+
* @param ipAddress the address or range of addresses from which the request must
52+
* @return the new instance
53+
*/
54+
public static IpAddressReactiveAuthorizationManager hasIpAddress(String ipAddress) {
55+
Assert.notNull(ipAddress, "This IP address is required; it must not be null");
56+
return new IpAddressReactiveAuthorizationManager(ipAddress);
57+
}
58+
59+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/*
2+
* Copyright 2002-2021 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.security.web.server.util.matcher;
18+
19+
import reactor.core.publisher.Mono;
20+
21+
import org.springframework.security.web.util.matcher.IpAddressMatcher;
22+
import org.springframework.util.Assert;
23+
import org.springframework.web.server.ServerWebExchange;
24+
25+
/**
26+
* Matches a request based on IP Address or subnet mask matching against the remote
27+
* address.
28+
*
29+
* @author Guirong Hu
30+
* @since 5.7
31+
*/
32+
public class IpAddressServerWebExchangeMatcher implements ServerWebExchangeMatcher {
33+
34+
private final IpAddressMatcher ipAddressMatcher;
35+
36+
/**
37+
* Takes a specific IP address or a range specified using the IP/Netmask (e.g.
38+
* 192.168.1.0/24 or 202.24.0.0/14).
39+
* @param ipAddress the address or range of addresses from which the request must
40+
* come.
41+
*/
42+
public IpAddressServerWebExchangeMatcher(String ipAddress) {
43+
Assert.hasText(ipAddress, "IP address cannot be empty");
44+
this.ipAddressMatcher = new IpAddressMatcher(ipAddress);
45+
}
46+
47+
@Override
48+
public Mono<MatchResult> matches(ServerWebExchange exchange) {
49+
// @formatter:off
50+
return Mono.justOrEmpty(exchange.getRequest().getRemoteAddress())
51+
.map((remoteAddress) -> remoteAddress.getAddress().getHostAddress())
52+
.map(this.ipAddressMatcher::matches)
53+
.flatMap((matches) -> matches ? MatchResult.match() : MatchResult.notMatch())
54+
.switchIfEmpty(MatchResult.notMatch());
55+
// @formatter:on
56+
}
57+
58+
@Override
59+
public String toString() {
60+
return "IpAddressServerWebExchangeMatcher{ipAddressMatcher=" + this.ipAddressMatcher + '}';
61+
}
62+
63+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
/*
2+
* Copyright 2002-2021 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.security.web.server.authorization;
18+
19+
import java.net.InetAddress;
20+
import java.net.InetSocketAddress;
21+
import java.net.UnknownHostException;
22+
23+
import org.junit.jupiter.api.Test;
24+
25+
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
26+
import org.springframework.mock.web.server.MockServerWebExchange;
27+
28+
import static org.assertj.core.api.Assertions.assertThat;
29+
30+
/**
31+
* Tests for {@link IpAddressReactiveAuthorizationManager}
32+
*
33+
* @author Guirong Hu
34+
*/
35+
public class IpAddressReactiveAuthorizationManagerTests {
36+
37+
@Test
38+
public void checkWhenHasIpv6AddressThenReturnTrue() throws UnknownHostException {
39+
IpAddressReactiveAuthorizationManager v6manager = IpAddressReactiveAuthorizationManager
40+
.hasIpAddress("fe80::21f:5bff:fe33:bd68");
41+
boolean granted = v6manager.check(null, context("fe80::21f:5bff:fe33:bd68")).block().isGranted();
42+
assertThat(granted).isTrue();
43+
}
44+
45+
@Test
46+
public void checkWhenHasIpv6AddressThenReturnFalse() throws UnknownHostException {
47+
IpAddressReactiveAuthorizationManager v6manager = IpAddressReactiveAuthorizationManager
48+
.hasIpAddress("fe80::21f:5bff:fe33:bd68");
49+
boolean granted = v6manager.check(null, context("fe80::1c9a:7cfd:29a8:a91e")).block().isGranted();
50+
assertThat(granted).isFalse();
51+
}
52+
53+
@Test
54+
public void checkWhenHasIpv4AddressThenReturnTrue() throws UnknownHostException {
55+
IpAddressReactiveAuthorizationManager v4manager = IpAddressReactiveAuthorizationManager
56+
.hasIpAddress("192.168.1.104");
57+
boolean granted = v4manager.check(null, context("192.168.1.104")).block().isGranted();
58+
assertThat(granted).isTrue();
59+
}
60+
61+
@Test
62+
public void checkWhenHasIpv4AddressThenReturnFalse() throws UnknownHostException {
63+
IpAddressReactiveAuthorizationManager v4manager = IpAddressReactiveAuthorizationManager
64+
.hasIpAddress("192.168.1.104");
65+
boolean granted = v4manager.check(null, context("192.168.100.15")).block().isGranted();
66+
assertThat(granted).isFalse();
67+
}
68+
69+
private static AuthorizationContext context(String ipAddress) throws UnknownHostException {
70+
MockServerWebExchange exchange = MockServerWebExchange.builder(MockServerHttpRequest.get("/")
71+
.remoteAddress(new InetSocketAddress(InetAddress.getByName(ipAddress), 8080))).build();
72+
return new AuthorizationContext(exchange);
73+
}
74+
75+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
/*
2+
* Copyright 2002-2021 the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.security.web.server.util.matcher;
18+
19+
import java.net.InetAddress;
20+
import java.net.InetSocketAddress;
21+
import java.net.UnknownHostException;
22+
23+
import org.junit.jupiter.api.Test;
24+
import org.junit.jupiter.api.extension.ExtendWith;
25+
import org.mockito.junit.jupiter.MockitoExtension;
26+
27+
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
28+
import org.springframework.mock.web.server.MockServerWebExchange;
29+
import org.springframework.web.server.ServerWebExchange;
30+
31+
import static org.assertj.core.api.Assertions.assertThat;
32+
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
33+
34+
/**
35+
* Tests for {@link IpAddressServerWebExchangeMatcher}
36+
*
37+
* @author Guirong Hu
38+
*/
39+
@ExtendWith(MockitoExtension.class)
40+
public class IpAddressServerWebExchangeMatcherTests {
41+
42+
@Test
43+
public void matchesWhenIpv6RangeAndIpv6AddressThenTrue() throws UnknownHostException {
44+
ServerWebExchange ipv6Exchange = exchange("fe80::21f:5bff:fe33:bd68");
45+
ServerWebExchangeMatcher.MatchResult matches = new IpAddressServerWebExchangeMatcher("fe80::21f:5bff:fe33:bd68")
46+
.matches(ipv6Exchange).block();
47+
assertThat(matches.isMatch()).isTrue();
48+
}
49+
50+
@Test
51+
public void matchesWhenIpv6RangeAndIpv4AddressThenFalse() throws UnknownHostException {
52+
ServerWebExchange ipv4Exchange = exchange("192.168.1.104");
53+
ServerWebExchangeMatcher.MatchResult matches = new IpAddressServerWebExchangeMatcher("fe80::21f:5bff:fe33:bd68")
54+
.matches(ipv4Exchange).block();
55+
assertThat(matches.isMatch()).isFalse();
56+
}
57+
58+
@Test
59+
public void matchesWhenIpv4RangeAndIpv4AddressThenTrue() throws UnknownHostException {
60+
ServerWebExchange ipv4Exchange = exchange("192.168.1.104");
61+
ServerWebExchangeMatcher.MatchResult matches = new IpAddressServerWebExchangeMatcher("192.168.1.104")
62+
.matches(ipv4Exchange).block();
63+
assertThat(matches.isMatch()).isTrue();
64+
}
65+
66+
@Test
67+
public void matchesWhenIpv4SubnetAndIpv4AddressThenTrue() throws UnknownHostException {
68+
ServerWebExchange ipv4Exchange = exchange("192.168.1.104");
69+
IpAddressServerWebExchangeMatcher matcher = new IpAddressServerWebExchangeMatcher("192.168.1.0/24");
70+
assertThat(matcher.matches(ipv4Exchange).block().isMatch()).isTrue();
71+
}
72+
73+
@Test
74+
public void matchesWhenIpv4SubnetAndIpv4AddressThenFalse() throws UnknownHostException {
75+
ServerWebExchange ipv4Exchange = exchange("192.168.1.104");
76+
IpAddressServerWebExchangeMatcher matcher = new IpAddressServerWebExchangeMatcher("192.168.1.128/25");
77+
assertThat(matcher.matches(ipv4Exchange).block().isMatch()).isFalse();
78+
}
79+
80+
@Test
81+
public void matchesWhenIpv6SubnetAndIpv6AddressThenTrue() throws UnknownHostException {
82+
ServerWebExchange ipv6Exchange = exchange("2001:DB8:0:FFFF:FFFF:FFFF:FFFF:FFFF");
83+
IpAddressServerWebExchangeMatcher matcher = new IpAddressServerWebExchangeMatcher("2001:DB8::/48");
84+
assertThat(matcher.matches(ipv6Exchange).block().isMatch()).isTrue();
85+
}
86+
87+
@Test
88+
public void matchesWhenIpv6SubnetAndIpv6AddressThenFalse() throws UnknownHostException {
89+
ServerWebExchange ipv6Exchange = exchange("2001:DB8:1:0:0:0:0:0");
90+
IpAddressServerWebExchangeMatcher matcher = new IpAddressServerWebExchangeMatcher("2001:DB8::/48");
91+
assertThat(matcher.matches(ipv6Exchange).block().isMatch()).isFalse();
92+
}
93+
94+
@Test
95+
public void matchesWhenZeroMaskAndAnythingThenTrue() throws UnknownHostException {
96+
IpAddressServerWebExchangeMatcher matcher = new IpAddressServerWebExchangeMatcher("0.0.0.0/0");
97+
assertThat(matcher.matches(exchange("123.4.5.6")).block().isMatch()).isTrue();
98+
assertThat(matcher.matches(exchange("192.168.0.159")).block().isMatch()).isTrue();
99+
matcher = new IpAddressServerWebExchangeMatcher("192.168.0.159/0");
100+
assertThat(matcher.matches(exchange("123.4.5.6")).block().isMatch()).isTrue();
101+
assertThat(matcher.matches(exchange("192.168.0.159")).block().isMatch()).isTrue();
102+
}
103+
104+
@Test
105+
public void constructorWhenIpv4AddressMaskTooLongThenIllegalArgumentException() {
106+
String ipv4AddressWithTooLongMask = "192.168.1.104/33";
107+
assertThatIllegalArgumentException()
108+
.isThrownBy(() -> new IpAddressServerWebExchangeMatcher(ipv4AddressWithTooLongMask))
109+
.withMessage(String.format("IP address %s is too short for bitmask of length %d", "192.168.1.104", 33));
110+
}
111+
112+
@Test
113+
public void constructorWhenIpv6AddressMaskTooLongThenIllegalArgumentException() {
114+
String ipv6AddressWithTooLongMask = "fe80::21f:5bff:fe33:bd68/129";
115+
assertThatIllegalArgumentException()
116+
.isThrownBy(() -> new IpAddressServerWebExchangeMatcher(ipv6AddressWithTooLongMask))
117+
.withMessage(String.format("IP address %s is too short for bitmask of length %d",
118+
"fe80::21f:5bff:fe33:bd68", 129));
119+
}
120+
121+
private static ServerWebExchange exchange(String ipAddress) throws UnknownHostException {
122+
return MockServerWebExchange.builder(MockServerHttpRequest.get("/")
123+
.remoteAddress(new InetSocketAddress(InetAddress.getByName(ipAddress), 8080))).build();
124+
}
125+
126+
}

0 commit comments

Comments
 (0)