diff --git a/web/src/main/java/org/springframework/security/web/server/util/matcher/IpAddressServerWebExchangeMatcher.java b/web/src/main/java/org/springframework/security/web/server/util/matcher/IpAddressServerWebExchangeMatcher.java index 1812d2bdec0..72662014c39 100644 --- a/web/src/main/java/org/springframework/security/web/server/util/matcher/IpAddressServerWebExchangeMatcher.java +++ b/web/src/main/java/org/springframework/security/web/server/util/matcher/IpAddressServerWebExchangeMatcher.java @@ -16,9 +16,12 @@ package org.springframework.security.web.server.util.matcher; +import java.util.List; + import reactor.core.publisher.Mono; -import org.springframework.security.web.util.matcher.IpAddressMatcher; +import org.springframework.security.web.util.matcher.InetAddressMatcher; +import org.springframework.security.web.util.matcher.InetAddressMatchers; import org.springframework.util.Assert; import org.springframework.web.server.ServerWebExchange; @@ -31,7 +34,7 @@ */ public final class IpAddressServerWebExchangeMatcher implements ServerWebExchangeMatcher { - private final IpAddressMatcher ipAddressMatcher; + private final InetAddressMatcher ipAddressMatcher; /** * Takes a specific IP address or a range specified using the IP/Netmask (e.g. @@ -41,7 +44,7 @@ public final class IpAddressServerWebExchangeMatcher implements ServerWebExchang */ public IpAddressServerWebExchangeMatcher(String ipAddress) { Assert.hasText(ipAddress, "IP address cannot be empty"); - this.ipAddressMatcher = new IpAddressMatcher(ipAddress); + this.ipAddressMatcher = InetAddressMatchers.builder().includeAddresses(List.of(ipAddress)).build(); } @Override diff --git a/web/src/main/java/org/springframework/security/web/util/matcher/InetAddressMatcher.java b/web/src/main/java/org/springframework/security/web/util/matcher/InetAddressMatcher.java new file mode 100644 index 00000000000..93eae82b7f5 --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/util/matcher/InetAddressMatcher.java @@ -0,0 +1,49 @@ +/* + * Copyright 2004-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.web.util.matcher; + +import java.net.InetAddress; + +import org.jspecify.annotations.Nullable; + +/** + * Matches an {@link InetAddress}. + * + * @author Rossen Stoyanchev + * @author Rob Winch + * @since 7.1 + */ +@FunctionalInterface +public interface InetAddressMatcher { + + /** + * Whether the given address matches. + * @param address the {@link InetAddress} to check (may be {@code null}) + * @return {@code true} if the address matches, {@code false} otherwise + */ + boolean matches(@Nullable InetAddress address); + + /** + * Whether the given address string matches. + * @param address the IP address string to check (may be {@code null}) + * @return {@code true} if the address matches, {@code false} otherwise + */ + default boolean matches(@Nullable String address) { + return (address != null) ? matches(InetAddressParser.parseAddress(address)) : false; + } + +} diff --git a/web/src/main/java/org/springframework/security/web/util/matcher/InetAddressMatchers.java b/web/src/main/java/org/springframework/security/web/util/matcher/InetAddressMatchers.java new file mode 100644 index 00000000000..65ff6b3b886 --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/util/matcher/InetAddressMatchers.java @@ -0,0 +1,374 @@ +/* + * Copyright 2004-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.security.web.util.matcher; + +import java.net.InetAddress; +import java.util.ArrayList; +import java.util.List; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.jspecify.annotations.Nullable; + +import org.springframework.util.Assert; + +/** + * Factory for creating {@link InetAddressMatcher} instances with various matching + * strategies for IP addresses. + * + * @author Rob Winch + * @since 7.1 + */ +public final class InetAddressMatchers { + + private InetAddressMatchers() { + } + + /** + * Creates a new builder for configuring an {@link InetAddressMatcher}. + * @return a new {@link Builder} instance + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Creates a new builder configured to match external (non-private) IP addresses. + * @return a {@link Builder} configured to match external addresses + */ + public static Builder matchExternal() { + return builder().matchAll(ExternalInetAddressMatcher.getInstance()); + } + + /** + * Creates a new builder configured to match internal (private) IP addresses. + *
+ * Internal addresses include loopback addresses (127.0.0.0/8 for IPv4, ::1 for IPv6),
+ * private IPv4 address ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16), and IPv6
+ * Unique Local Addresses (fc00::/7).
+ * @return a {@link Builder} configured to match internal addresses
+ */
+ public static Builder matchInternal() {
+ return builder().matchAll(InternalInetAddressMatcher.getInstance());
+ }
+
+ /**
+ * A builder for constructing {@link InetAddressMatcher} instances with various
+ * matching rules.
+ *
+ * @author Kian Jamali
+ * @author Gábor Vaspöri
+ * @author Rossen Stoyanchev
+ * @author Rob Winch
+ */
+ public static final class Builder {
+
+ private final List
+ * Internal addresses include loopback addresses (127.0.0.0/8 for IPv4, ::1 for IPv6),
+ * private IPv4 address ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16), and IPv6
+ * Unique Local Addresses (fc00::/7).
+ *
+ * @author Gábor Vaspöri
+ * @author Kian Jamali
+ * @author Rossen Stoyanchev
+ * @author Rob Winch
+ */
+ static final class InternalInetAddressMatcher implements InetAddressMatcher {
+
+ private static final InternalInetAddressMatcher INSTANCE = new InternalInetAddressMatcher();
+
+ static InternalInetAddressMatcher getInstance() {
+ return INSTANCE;
+ }
+
+ private InternalInetAddressMatcher() {
+ }
+
+ @Override
+ public boolean matches(@Nullable InetAddress address) {
+ if (address == null) {
+ return false;
+ }
+ if (address.isLoopbackAddress()) {
+ return true;
+ }
+
+ byte[] rawAddress = address.getAddress();
+
+ int[] iAddr = new int[rawAddress.length];
+ for (int i = 0; i < rawAddress.length; i++) {
+ iAddr[i] = Byte.toUnsignedInt(rawAddress[i]);
+ }
+
+ // Ignoring Multicast addresses
+ if (address.getAddress().length == 4) {
+ // IPv4 matching
+ // 10.x.x.x , 192.168.x.x , 172.16.x.x
+ if (iAddr[0] == 10 || (iAddr[0] == 192 && iAddr[1] == 168) || (iAddr[0] == 172 && iAddr[1] == 16)) {
+ return true;
+ }
+
+ }
+ else if (address.getAddress().length == 16) {
+ // IPv6, check for Unique Local Addresses
+ if (iAddr[0] == 0xfc || iAddr[0] == 0xfd) {
+ return true;
+ }
+
+ // IPv4/IPv6 translation, 64:ff9b
+ if (iAddr[0] == 0x00 && iAddr[1] == 0x64 && iAddr[2] == 0xff && iAddr[3] == 0x9b) {
+ int[] ipv4Part = new int[] { iAddr[12], iAddr[13], iAddr[14], iAddr[15] };
+ // same check as above plus a check for loopback
+ if (ipv4Part[0] == 10 || ipv4Part[0] == 127 || (ipv4Part[0] == 192 && ipv4Part[1] == 168)
+ || (ipv4Part[0] == 172 && ipv4Part[1] == 16)) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
+ @Override
+ public String toString() {
+ return "InternalInetAddressMatcher";
+ }
+
+ }
+
+ /**
+ * An {@link InetAddressMatcher} that matches external (public) addresses.
+ *
+ * External addresses are any addresses that are not internal (private) addresses.
+ * This matcher delegates to {@link InternalInetAddressMatcher} and negates the
+ * result.
+ *
+ * @author Gábor Vaspöri
+ * @author Kian Jamali
+ * @author Rossen Stoyanchev
+ * @author Rob Winch
+ */
+ static final class ExternalInetAddressMatcher implements InetAddressMatcher {
+
+ private static final ExternalInetAddressMatcher INSTANCE = new ExternalInetAddressMatcher();
+
+ static ExternalInetAddressMatcher getInstance() {
+ return INSTANCE;
+ }
+
+ private final InternalInetAddressMatcher internalMatcher = InternalInetAddressMatcher.getInstance();
+
+ private ExternalInetAddressMatcher() {
+ }
+
+ @Override
+ public boolean matches(@Nullable InetAddress address) {
+ return !this.internalMatcher.matches(address);
+ }
+
+ @Override
+ public String toString() {
+ return "ExternalInetAddressMatcher";
+ }
+
+ }
+
+ /**
+ * A composite {@link InetAddressMatcher} that chains multiple matchers together. All
+ * matchers must match for an address to be allowed. If report-only mode is enabled,
+ * matching results are logged but all addresses are permitted.
+ *
+ * @author Gábor Vaspöri
+ * @author Kian Jamali
+ * @author Rossen Stoyanchev
+ * @author Rob Winch
+ */
+ static final class CompositeInetAddressMatcher implements InetAddressMatcher {
+
+ private static final Log logger = LogFactory.getLog(InetAddressMatcher.class);
+
+ private final List
+ * Both IPv4 and IPv6 addresses are supported. The matcher can be configured with either a
+ * specific IP address or a subnet using CIDR notation.
+ *
+ * The logic from this class was migrated from {@link IpAddressMatcher} to provide a more
+ * general API that did not depend on the servlet APIs (e.g. HttpServletRequest).
+ *
+ * @author Luke Taylor
+ * @author Steve Riesenberg
+ * @author Andrey Litvitski
+ * @since 7.1
+ * @see IpAddressMatcher
+ */
+final class IpInetAddressMatcher implements InetAddressMatcher {
+
+ private static final Log logger = LogFactory.getLog(IpAddressMatcher.class);
+
+ private final InetAddress requiredAddress;
+
+ private final int nMaskBits;
+
+ IpInetAddressMatcher(String ipAddress) {
+ Assert.hasText(ipAddress, "ipAddress cannot be empty");
+ String requiredAddress;
+ int nMaskBits;
+ if (ipAddress.indexOf('/') > 0) {
+ String[] parts = Objects.requireNonNull(StringUtils.split(ipAddress, "/"));
+ requiredAddress = parts[0];
+ nMaskBits = Integer.parseInt(parts[1]);
+ }
+ else {
+ requiredAddress = ipAddress;
+ nMaskBits = -1;
+ }
+ this.requiredAddress = InetAddressParser.parseAddress(requiredAddress);
+ this.nMaskBits = nMaskBits;
+ Assert.isTrue(this.requiredAddress.getAddress().length * 8 >= this.nMaskBits, () -> String
+ .format("IP address %s is too short for bitmask of length %d", requiredAddress, this.nMaskBits));
+ }
+
+ private static InetAddress parse(String address) {
+ try {
+ InetAddress result = InetAddress.getByName(address);
+ if (address.matches(".*[a-zA-Z\\-].*$") && !address.contains(":")) {
+ logger.warn("Hostname '" + address + "' resolved to " + result.toString()
+ + " will be used on IP address matching");
+ }
+ return result;
+ }
+ catch (UnknownHostException ex) {
+ throw new IllegalArgumentException(String.format("Failed to parse address '%s'", address), ex);
+ }
+ }
+
+ @Override
+ public boolean matches(@Nullable InetAddress toCheck) {
+ if (toCheck == null) {
+ return false;
+ }
+ if (this.nMaskBits < 0) {
+ return toCheck.equals(this.requiredAddress);
+ }
+ byte[] remAddr = toCheck.getAddress();
+ byte[] reqAddr = this.requiredAddress.getAddress();
+ int nMaskFullBytes = this.nMaskBits / 8;
+ byte finalByte = (byte) (0xFF00 >> (this.nMaskBits & 0x07));
+ for (int i = 0; i < nMaskFullBytes; i++) {
+ if (remAddr[i] != reqAddr[i]) {
+ return false;
+ }
+ }
+ if (finalByte != 0) {
+ return (remAddr[nMaskFullBytes] & finalByte) == (reqAddr[nMaskFullBytes] & finalByte);
+ }
+ return true;
+ }
+
+ @Override
+ public String toString() {
+ String hostAddress = this.requiredAddress.getHostAddress();
+ return (this.nMaskBits < 0) ? "IpAddress [" + hostAddress + "]"
+ : "IpAddress [" + hostAddress + "/" + this.nMaskBits + "]";
+ }
+
+}
diff --git a/web/src/test/java/org/springframework/security/web/util/matcher/InetAddressMatcherTests.java b/web/src/test/java/org/springframework/security/web/util/matcher/InetAddressMatcherTests.java
new file mode 100644
index 00000000000..3471bbd725d
--- /dev/null
+++ b/web/src/test/java/org/springframework/security/web/util/matcher/InetAddressMatcherTests.java
@@ -0,0 +1,69 @@
+/*
+ * Copyright 2004-present the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.web.util.matcher;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+
+/**
+ * Tests for {@link InetAddressMatcher}.
+ *
+ * @author Rob Winch
+ */
+class InetAddressMatcherTests {
+
+ @Test
+ void matchesWhenStringValidIpv4ThenReturnsTrue() {
+ InetAddressMatcher matcher = (address) -> address.getHostAddress().equals("192.168.1.1");
+ assertThat(matcher.matches("192.168.1.1")).isTrue();
+ }
+
+ @Test
+ void matchesWhenStringValidIpv6ThenReturnsTrue() {
+ InetAddressMatcher matcher = (address) -> address.getHostAddress().equals("fe80:0:0:0:21f:5bff:fe33:bd68");
+ assertThat(matcher.matches("fe80::21f:5bff:fe33:bd68")).isTrue();
+ }
+
+ @Test
+ void matchesWhenStringNullThenReturnsFalse() {
+ InetAddressMatcher matcher = (address) -> true;
+ assertThat(matcher.matches((String) null)).isFalse();
+ }
+
+ @Test
+ void matchesWhenStringInvalidThenThrowsIllegalArgumentException() {
+ InetAddressMatcher matcher = (address) -> true;
+ assertThat(matcher.matches("192.168.1.1")).isTrue();
+ assertThatIllegalArgumentException().isThrownBy(() -> matcher.matches("not.an.ip.address"));
+ }
+
+ @Test
+ void matchesWhenStringMatchesPredicateThenReturnsTrue() {
+ InetAddressMatcher matcher = (address) -> address.getHostAddress().startsWith("192.168");
+ assertThat(matcher.matches("192.168.1.1")).isTrue();
+ assertThat(matcher.matches("192.168.100.200")).isTrue();
+ }
+
+ @Test
+ void matchesWhenStringDoesNotMatchPredicateThenReturnsFalse() {
+ InetAddressMatcher matcher = (address) -> address.getHostAddress().startsWith("192.168");
+ assertThat(matcher.matches("10.0.0.1")).isFalse();
+ }
+
+}
diff --git a/web/src/test/java/org/springframework/security/web/util/matcher/InetAddressMatchersTests.java b/web/src/test/java/org/springframework/security/web/util/matcher/InetAddressMatchersTests.java
new file mode 100644
index 00000000000..76aa9e28b9f
--- /dev/null
+++ b/web/src/test/java/org/springframework/security/web/util/matcher/InetAddressMatchersTests.java
@@ -0,0 +1,478 @@
+/*
+ * Copyright 2004-present the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.web.util.matcher;
+
+import java.net.InetAddress;
+import java.util.List;
+
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+
+/**
+ * Tests for {@link InetAddressMatchers}.
+ *
+ * @author Rob Winch
+ */
+class InetAddressMatchersTests {
+
+ @Test
+ void builderWhenInvokedThenReturnsBuilder() {
+ assertThat(InetAddressMatchers.builder()).isNotNull();
+ }
+
+ @Test
+ void matchExternalWhenInvokedThenReturnsBuilder() {
+ InetAddressMatchers.Builder builder = InetAddressMatchers.matchExternal();
+ assertThat(builder).isNotNull();
+ }
+
+ @Test
+ void matchInternalWhenInvokedThenReturnsBuilder() {
+ InetAddressMatchers.Builder builder = InetAddressMatchers.matchInternal();
+ assertThat(builder).isNotNull();
+ }
+
+ @Nested
+ class BuilderTests {
+
+ @Test
+ void includeAddressesWhenNullThenThrowsIllegalArgumentException() {
+ assertThatIllegalArgumentException().isThrownBy(() -> InetAddressMatchers.builder().includeAddresses(null))
+ .withMessage("addresses cannot be empty");
+ }
+
+ @Test
+ void includeAddressesWhenEmptyListThenThrowsIllegalArgumentException() {
+ assertThatIllegalArgumentException()
+ .isThrownBy(() -> InetAddressMatchers.builder().includeAddresses(List.of()))
+ .withMessage("addresses cannot be empty");
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = { "192.168.1.1", "192.168.1.2" })
+ void includeAddressesWhenSingleAddressThenMatchesOnlyThatAddress(String testAddress) throws Exception {
+ InetAddressMatcher matcher = InetAddressMatchers.builder().includeAddresses(List.of("192.168.1.1")).build();
+ InetAddress address = InetAddress.getByName(testAddress);
+ boolean expected = testAddress.equals("192.168.1.1");
+ assertThat(matcher.matches(address)).isEqualTo(expected);
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = { "192.168.1.1", "10.0.0.1", "8.8.8.8" })
+ void includeAddressesWhenMultipleAddressesThenMatchesAny(String testAddress) throws Exception {
+ InetAddressMatcher matcher = InetAddressMatchers.builder()
+ .includeAddresses(List.of("192.168.1.1", "10.0.0.1"))
+ .build();
+ InetAddress address = InetAddress.getByName(testAddress);
+ boolean expected = testAddress.equals("192.168.1.1") || testAddress.equals("10.0.0.1");
+ assertThat(matcher.matches(address)).isEqualTo(expected);
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = { "192.168.1.1", "192.168.1.255", "192.168.2.1" })
+ void includeAddressesWhenCidrNotationThenMatchesSubnet(String testAddress) throws Exception {
+ InetAddressMatcher matcher = InetAddressMatchers.builder()
+ .includeAddresses(List.of("192.168.1.0/24"))
+ .build();
+ InetAddress address = InetAddress.getByName(testAddress);
+ boolean expected = testAddress.startsWith("192.168.1.");
+ assertThat(matcher.matches(address)).isEqualTo(expected);
+ }
+
+ @Test
+ void excludeAddressesWhenNullThenThrowsIllegalArgumentException() {
+ assertThatIllegalArgumentException().isThrownBy(() -> InetAddressMatchers.builder().excludeAddresses(null))
+ .withMessage("addresses cannot be empty");
+ }
+
+ @Test
+ void excludeAddressesWhenEmptyListThenThrowsIllegalArgumentException() {
+ assertThatIllegalArgumentException()
+ .isThrownBy(() -> InetAddressMatchers.builder().excludeAddresses(List.of()))
+ .withMessage("addresses cannot be empty");
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = { "192.168.1.1", "192.168.1.2" })
+ void excludeAddressesWhenSingleAddressThenBlocksOnlyThatAddress(String testAddress) throws Exception {
+ InetAddressMatcher matcher = InetAddressMatchers.builder().excludeAddresses(List.of("192.168.1.1")).build();
+ InetAddress address = InetAddress.getByName(testAddress);
+ boolean expected = !testAddress.equals("192.168.1.1");
+ assertThat(matcher.matches(address)).isEqualTo(expected);
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = { "192.168.1.1", "10.0.0.1", "8.8.8.8" })
+ void excludeAddressesWhenMultipleAddressesThenBlocksAll(String testAddress) throws Exception {
+ InetAddressMatcher matcher = InetAddressMatchers.builder()
+ .excludeAddresses(List.of("192.168.1.1", "10.0.0.1"))
+ .build();
+ InetAddress address = InetAddress.getByName(testAddress);
+ boolean expected = !testAddress.equals("192.168.1.1") && !testAddress.equals("10.0.0.1");
+ assertThat(matcher.matches(address)).isEqualTo(expected);
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = { "192.168.1.1", "192.168.1.255", "192.168.2.1" })
+ void excludeAddressesWhenCidrNotationThenBlocksSubnet(String testAddress) throws Exception {
+ InetAddressMatcher matcher = InetAddressMatchers.builder()
+ .excludeAddresses(List.of("192.168.1.0/24"))
+ .build();
+ InetAddress address = InetAddress.getByName(testAddress);
+ boolean expected = !testAddress.startsWith("192.168.1.");
+ assertThat(matcher.matches(address)).isEqualTo(expected);
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = { "10.0.0.1", "192.168.1.1" })
+ void matchAllWhenVarargsThenAddsMatchersToChain(String testAddress) throws Exception {
+ InetAddressMatcher customMatcher = (address) -> address.getHostAddress().startsWith("10.");
+ InetAddressMatcher matcher = InetAddressMatchers.builder().matchAll(customMatcher).build();
+ InetAddress address = InetAddress.getByName(testAddress);
+ boolean expected = testAddress.startsWith("10.");
+ assertThat(matcher.matches(address)).isEqualTo(expected);
+ }
+
+ @Test
+ void matchAllWhenNullVarargsThenThrowsIllegalArgumentException() {
+ assertThatIllegalArgumentException()
+ .isThrownBy(() -> InetAddressMatchers.builder().matchAll((InetAddressMatcher[]) null))
+ .withMessage("matchers cannot be empty");
+ }
+
+ @Test
+ void matchAllWhenEmptyVarargsThenThrowsIllegalArgumentException() {
+ assertThatIllegalArgumentException()
+ .isThrownBy(() -> InetAddressMatchers.builder().matchAll(new InetAddressMatcher[0]))
+ .withMessage("matchers cannot be empty");
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = { "10.0.0.1", "10.0.0.2", "192.168.1.1" })
+ void matchAllWhenMultipleMatchersThenAppliesAndLogic(String testAddress) throws Exception {
+ InetAddressMatcher startsWithTen = (address) -> address.getHostAddress().startsWith("10.");
+ InetAddressMatcher endsWithOne = (address) -> address.getHostAddress().endsWith(".1");
+ InetAddressMatcher matcher = InetAddressMatchers.builder().matchAll(startsWithTen, endsWithOne).build();
+ InetAddress address = InetAddress.getByName(testAddress);
+ boolean expected = testAddress.startsWith("10.") && testAddress.endsWith(".1");
+ assertThat(matcher.matches(address)).isEqualTo(expected);
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = { "192.168.1.1", "8.8.8.8" })
+ void reportOnlyWhenSetThenAllowsAllAddresses(String testAddress) throws Exception {
+ InetAddressMatcher matcher = InetAddressMatchers.builder()
+ .excludeAddresses(List.of("192.168.1.1"))
+ .reportOnly()
+ .build();
+ InetAddress address = InetAddress.getByName(testAddress);
+ assertThat(matcher.matches(address)).isTrue();
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = { "192.168.1.1", "192.168.1.100", "192.168.2.1" })
+ void buildWhenMultipleMatchersThenAppliesAndLogic(String testAddress) throws Exception {
+ InetAddressMatcher matcher = InetAddressMatchers.builder()
+ .includeAddresses(List.of("192.168.1.0/24"))
+ .excludeAddresses(List.of("192.168.1.100"))
+ .build();
+ InetAddress address = InetAddress.getByName(testAddress);
+ boolean expected = testAddress.startsWith("192.168.1.") && !testAddress.equals("192.168.1.100");
+ assertThat(matcher.matches(address)).isEqualTo(expected);
+ }
+
+ }
+
+ @Nested
+ class IncludeListInetAddressMatcherTests {
+
+ @Test
+ void constructorWhenNullListThenThrowsIllegalArgumentException() {
+ assertThatIllegalArgumentException()
+ .isThrownBy(() -> new InetAddressMatchers.IncludeListInetAddressMatcher(null))
+ .withMessage("includeList cannot be null or empty");
+ }
+
+ @Test
+ void constructorWhenEmptyListThenThrowsIllegalArgumentException() {
+ assertThatIllegalArgumentException()
+ .isThrownBy(() -> new InetAddressMatchers.IncludeListInetAddressMatcher(List.of()))
+ .withMessage("includeList cannot be null or empty");
+ }
+
+ @Test
+ void matchesWhenAddressInListThenReturnsTrue() throws Exception {
+ String addressString = "192.168.1.1";
+ InetAddressMatcher matcher = InetAddressMatchers.builder().includeAddresses(List.of(addressString)).build();
+ assertThat(matcher.matches(InetAddress.getByName(addressString))).isTrue();
+ }
+
+ @Test
+ void matchesWhenAddressNotInListThenReturnsFalse() throws Exception {
+ InetAddressMatcher matcher = InetAddressMatchers.builder().includeAddresses(List.of("192.168.1.1")).build();
+ assertThat(matcher.matches(InetAddress.getByName("192.168.1.2"))).isFalse();
+ }
+
+ }
+
+ @Nested
+ class ExcludeListInetAddressMatcherTests {
+
+ @Test
+ void constructorWhenNullListThenThrowsIllegalArgumentException() {
+ assertThatIllegalArgumentException()
+ .isThrownBy(() -> new InetAddressMatchers.ExcludeListInetAddressMatcher(null))
+ .withMessage("excludeList cannot be null or empty");
+ }
+
+ @Test
+ void constructorWhenEmptyListThenThrowsIllegalArgumentException() {
+ assertThatIllegalArgumentException()
+ .isThrownBy(() -> new InetAddressMatchers.ExcludeListInetAddressMatcher(List.of()))
+ .withMessage("excludeList cannot be null or empty");
+ }
+
+ @Test
+ void matchesWhenAddressInListThenReturnsFalse() throws Exception {
+ String addressString = "192.168.1.1";
+ InetAddressMatcher matcher = InetAddressMatchers.builder().excludeAddresses(List.of(addressString)).build();
+ assertThat(matcher.matches(InetAddress.getByName(addressString))).isFalse();
+ }
+
+ @Test
+ void matchesWhenAddressNotInListThenReturnsTrue() throws Exception {
+ InetAddressMatcher matcher = InetAddressMatchers.builder().excludeAddresses(List.of("192.168.1.1")).build();
+ assertThat(matcher.matches(InetAddress.getByName("192.168.1.2"))).isTrue();
+ }
+
+ }
+
+ @Nested
+ class InternalInetAddressMatcherTests {
+
+ @Test
+ void matchesWhenInetAddressNullThenReturnsFalse() {
+ InetAddressMatcher matcher = InetAddressMatchers.matchInternal().build();
+ assertThat(matcher.matches((InetAddress) null)).isFalse();
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = { "127.0.0.1", "127.0.0.255" })
+ void matchesWhenIpv4LoopbackThenReturnsTrue(String address) throws Exception {
+ InetAddressMatcher matcher = InetAddressMatchers.matchInternal().build();
+ assertThat(matcher.matches(InetAddress.getByName(address))).isTrue();
+ }
+
+ @Test
+ void matchesWhenIpv6LoopbackThenReturnsTrue() throws Exception {
+ InetAddressMatcher matcher = InetAddressMatchers.matchInternal().build();
+ assertThat(matcher.matches(InetAddress.getByName("::1"))).isTrue();
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = { "10.0.0.1", "10.255.255.255" })
+ void matchesWhenIpv4PrivateClass10ThenReturnsTrue(String address) throws Exception {
+ InetAddressMatcher matcher = InetAddressMatchers.matchInternal().build();
+ assertThat(matcher.matches(InetAddress.getByName(address))).isTrue();
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = { "192.168.0.1", "192.168.255.255" })
+ void matchesWhenIpv4PrivateClass192ThenReturnsTrue(String address) throws Exception {
+ InetAddressMatcher matcher = InetAddressMatchers.matchInternal().build();
+ assertThat(matcher.matches(InetAddress.getByName(address))).isTrue();
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = { "172.16.0.1", "172.16.255.255" })
+ void matchesWhenIpv4PrivateClass172ThenReturnsTrue(String address) throws Exception {
+ InetAddressMatcher matcher = InetAddressMatchers.matchInternal().build();
+ assertThat(matcher.matches(InetAddress.getByName(address))).isTrue();
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = { "fc00::1", "fd00::1" })
+ void matchesWhenIpv6UniqueLocalThenReturnsTrue(String address) throws Exception {
+ InetAddressMatcher matcher = InetAddressMatchers.matchInternal().build();
+ assertThat(matcher.matches(InetAddress.getByName(address))).isTrue();
+ }
+
+ @ParameterizedTest
+ @ValueSource(
+ strings = { "64:ff9b::10.0.0.1", "64:ff9b::127.0.0.1", "64:ff9b::192.168.1.1", "64:ff9b::172.16.0.1" })
+ void matchesWhenIpv6TranslationWithInternalIpv4ThenReturnsTrue(String address) throws Exception {
+ InetAddressMatcher matcher = InetAddressMatchers.matchInternal().build();
+ assertThat(matcher.matches(InetAddress.getByName(address))).isTrue();
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = { "64:ff9b::192.0.2.1", "64:ff9b::192.167.1.1" })
+ void matchesWhenIpv6TranslationWithIpv4StartsWith192ButNot168ThenReturnsFalse(String address) throws Exception {
+ InetAddressMatcher matcher = InetAddressMatchers.matchInternal().build();
+ assertThat(matcher.matches(InetAddress.getByName(address))).isFalse();
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = { "64:ff9b::172.16.0.1", "64:ff9b::172.16.255.255" })
+ void matchesWhenIpv6TranslationWithIpv4StartsWith172And16ThenReturnsTrue(String address) throws Exception {
+ InetAddressMatcher matcher = InetAddressMatchers.matchInternal().build();
+ assertThat(matcher.matches(InetAddress.getByName(address))).isTrue();
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = { "64:ff9b::8.8.8.8", "64:ff9b::1.1.1.1" })
+ void matchesWhenIpv6TranslationWithExternalIpv4ThenReturnsFalse(String address) throws Exception {
+ InetAddressMatcher matcher = InetAddressMatchers.matchInternal().build();
+ assertThat(matcher.matches(InetAddress.getByName(address))).isFalse();
+ }
+
+ @Test
+ void matchesWhenIpv6NonTranslationPrefixByte0ThenReturnsFalse() throws Exception {
+ InetAddressMatcher matcher = InetAddressMatchers.matchInternal().build();
+ assertThat(matcher.matches(InetAddress.getByName("65:ff9b::10.0.0.1"))).isFalse();
+ }
+
+ @Test
+ void matchesWhenIpv6NonTranslationPrefixByte1ThenReturnsFalse() throws Exception {
+ InetAddressMatcher matcher = InetAddressMatchers.matchInternal().build();
+ assertThat(matcher.matches(InetAddress.getByName("64:fe9b::10.0.0.1"))).isFalse();
+ }
+
+ @Test
+ void matchesWhenIpv6NonTranslationPrefixByte2ThenReturnsFalse() throws Exception {
+ InetAddressMatcher matcher = InetAddressMatchers.matchInternal().build();
+ assertThat(matcher.matches(InetAddress.getByName("64:ff9a::10.0.0.1"))).isFalse();
+ }
+
+ @Test
+ void matchesWhenIpv6NonTranslationPrefixByte3ThenReturnsFalse() throws Exception {
+ InetAddressMatcher matcher = InetAddressMatchers.matchInternal().build();
+ assertThat(matcher.matches(InetAddress.getByName("64:ff9c::10.0.0.1"))).isFalse();
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = { "8.8.8.8", "1.1.1.1" })
+ void matchesWhenIpv4PublicThenReturnsFalse(String address) throws Exception {
+ InetAddressMatcher matcher = InetAddressMatchers.matchInternal().build();
+ assertThat(matcher.matches(InetAddress.getByName(address))).isFalse();
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = { "192.0.2.1", "192.167.1.1", "192.169.1.1" })
+ void matchesWhenIpv4StartsWith192ButNot168ThenReturnsFalse(String address) throws Exception {
+ InetAddressMatcher matcher = InetAddressMatchers.matchInternal().build();
+ assertThat(matcher.matches(InetAddress.getByName(address))).isFalse();
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = { "172.15.1.1", "172.17.1.1", "172.31.1.1" })
+ void matchesWhenIpv4StartsWith172ButNot16ThenReturnsFalse(String address) throws Exception {
+ InetAddressMatcher matcher = InetAddressMatchers.matchInternal().build();
+ assertThat(matcher.matches(InetAddress.getByName(address))).isFalse();
+ }
+
+ @Test
+ void matchesWhenIpv6PublicThenReturnsFalse() throws Exception {
+ InetAddressMatcher matcher = InetAddressMatchers.matchInternal().build();
+ assertThat(matcher.matches(InetAddress.getByName("2001:4860:4860::8888"))).isFalse();
+ }
+
+ }
+
+ @Nested
+ class ExternalInetAddressMatcherTests {
+
+ @ParameterizedTest
+ @ValueSource(strings = { "8.8.8.8", "1.1.1.1" })
+ void matchesWhenIpv4PublicThenReturnsTrue(String address) throws Exception {
+ InetAddressMatcher matcher = InetAddressMatchers.matchExternal().build();
+ assertThat(matcher.matches(InetAddress.getByName(address))).isTrue();
+ }
+
+ @Test
+ void matchesWhenIpv6PublicThenReturnsTrue() throws Exception {
+ InetAddressMatcher matcher = InetAddressMatchers.matchExternal().build();
+ assertThat(matcher.matches(InetAddress.getByName("2001:4860:4860::8888"))).isTrue();
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = { "192.168.1.1", "10.0.0.1", "172.16.0.1" })
+ void matchesWhenIpv4PrivateThenReturnsFalse(String address) throws Exception {
+ InetAddressMatcher matcher = InetAddressMatchers.matchExternal().build();
+ assertThat(matcher.matches(InetAddress.getByName(address))).isFalse();
+ }
+
+ @Test
+ void matchesWhenIpv4LoopbackThenReturnsFalse() throws Exception {
+ InetAddressMatcher matcher = InetAddressMatchers.matchExternal().build();
+ assertThat(matcher.matches(InetAddress.getByName("127.0.0.1"))).isFalse();
+ }
+
+ @Test
+ void matchesWhenIpv6LoopbackThenReturnsFalse() throws Exception {
+ InetAddressMatcher matcher = InetAddressMatchers.matchExternal().build();
+ assertThat(matcher.matches(InetAddress.getByName("::1"))).isFalse();
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = { "fc00::1", "fd00::1" })
+ void matchesWhenIpv6UniqueLocalThenReturnsFalse(String address) throws Exception {
+ InetAddressMatcher matcher = InetAddressMatchers.matchExternal().build();
+ assertThat(matcher.matches(InetAddress.getByName(address))).isFalse();
+ }
+
+ }
+
+ @Nested
+ class CompositeInetAddressMatcherTests {
+
+ @Test
+ void matchesWhenAllMatchersTrueThenReturnsTrue() throws Exception {
+ InetAddressMatcher matcher = InetAddressMatchers.builder()
+ .includeAddresses(List.of("192.168.1.0/24"))
+ .matchAll((address) -> address.getHostAddress().endsWith(".1"))
+ .build();
+ assertThat(matcher.matches(InetAddress.getByName("192.168.1.1"))).isTrue();
+ }
+
+ @Test
+ void matchesWhenOneMatcherFalseThenReturnsFalse() throws Exception {
+ InetAddressMatcher matcher = InetAddressMatchers.builder()
+ .includeAddresses(List.of("192.168.1.0/24"))
+ .matchAll((address) -> address.getHostAddress().endsWith(".1"))
+ .build();
+ assertThat(matcher.matches(InetAddress.getByName("192.168.1.2"))).isFalse();
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = { "192.168.1.1", "8.8.8.8" })
+ void matchesWhenReportOnlyThenAlwaysReturnsTrue(String testAddress) throws Exception {
+ InetAddressMatcher matcher = InetAddressMatchers.builder()
+ .excludeAddresses(List.of("192.168.1.1"))
+ .reportOnly()
+ .build();
+ assertThat(matcher.matches(InetAddress.getByName(testAddress))).isTrue();
+ }
+
+ }
+
+}
diff --git a/web/src/test/java/org/springframework/security/web/util/matcher/IpInetAddressMatcherTests.java b/web/src/test/java/org/springframework/security/web/util/matcher/IpInetAddressMatcherTests.java
new file mode 100644
index 00000000000..e87f9a05bf5
--- /dev/null
+++ b/web/src/test/java/org/springframework/security/web/util/matcher/IpInetAddressMatcherTests.java
@@ -0,0 +1,138 @@
+/*
+ * Copyright 2004-present the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.springframework.security.web.util.matcher;
+
+import java.net.InetAddress;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+
+/**
+ * Tests for {@link IpInetAddressMatcher}.
+ *
+ * @author Rob Winch
+ */
+class IpInetAddressMatcherTests {
+
+ @Test
+ void constructorWhenNullIpAddressThenThrowsIllegalArgumentException() {
+ assertThatIllegalArgumentException().isThrownBy(() -> new IpInetAddressMatcher(null))
+ .withMessage("ipAddress cannot be empty");
+ }
+
+ @Test
+ void constructorWhenEmptyIpAddressThenThrowsIllegalArgumentException() {
+ assertThatIllegalArgumentException().isThrownBy(() -> new IpInetAddressMatcher(""))
+ .withMessage("ipAddress cannot be empty");
+ }
+
+ @Test
+ void constructorWhenHostnameThenThrowsIllegalArgumentException() {
+ assertThatIllegalArgumentException().isThrownBy(() -> new IpInetAddressMatcher("example.com"))
+ .withMessageContaining("doesn't look like an IP Address");
+ }
+
+ @Test
+ void matchesWhenIpv4ExactMatchThenReturnsTrue() throws Exception {
+ IpInetAddressMatcher matcher = new IpInetAddressMatcher("192.168.1.1");
+ assertThat(matcher.matches(InetAddress.getByName("192.168.1.1"))).isTrue();
+ }
+
+ @Test
+ void matchesWhenIpv4NoMatchThenReturnsFalse() throws Exception {
+ IpInetAddressMatcher matcher = new IpInetAddressMatcher("192.168.1.1");
+ assertThat(matcher.matches(InetAddress.getByName("192.168.1.2"))).isFalse();
+ }
+
+ @Test
+ void matchesWhenIpv6ExactMatchThenReturnsTrue() throws Exception {
+ IpInetAddressMatcher matcher = new IpInetAddressMatcher("fe80::21f:5bff:fe33:bd68");
+ assertThat(matcher.matches(InetAddress.getByName("fe80::21f:5bff:fe33:bd68"))).isTrue();
+ }
+
+ @Test
+ void matchesWhenIpv6NoMatchThenReturnsFalse() throws Exception {
+ IpInetAddressMatcher matcher = new IpInetAddressMatcher("fe80::21f:5bff:fe33:bd68");
+ assertThat(matcher.matches(InetAddress.getByName("fe80::21f:5bff:fe33:bd69"))).isFalse();
+ }
+
+ @Test
+ void matchesWhenIpv4WithCidrMatchesSubnetThenReturnsTrue() throws Exception {
+ IpInetAddressMatcher matcher = new IpInetAddressMatcher("192.168.1.0/24");
+ assertThat(matcher.matches(InetAddress.getByName("192.168.1.1"))).isTrue();
+ assertThat(matcher.matches(InetAddress.getByName("192.168.1.255"))).isTrue();
+ }
+
+ @Test
+ void matchesWhenIpv4WithCidrOutsideSubnetThenReturnsFalse() throws Exception {
+ IpInetAddressMatcher matcher = new IpInetAddressMatcher("192.168.1.0/24");
+ assertThat(matcher.matches(InetAddress.getByName("192.168.2.1"))).isFalse();
+ assertThat(matcher.matches(InetAddress.getByName("192.168.0.255"))).isFalse();
+ }
+
+ @Test
+ void matchesWhenIpv6WithCidrMatchesSubnetThenReturnsTrue() throws Exception {
+ IpInetAddressMatcher matcher = new IpInetAddressMatcher("2001:db8::/48");
+ assertThat(matcher.matches(InetAddress.getByName("2001:db8:0:0:0:0:0:0"))).isTrue();
+ assertThat(matcher.matches(InetAddress.getByName("2001:db8:0:ffff:ffff:ffff:ffff:ffff"))).isTrue();
+ }
+
+ @Test
+ void matchesWhenIpv6WithCidrOutsideSubnetThenReturnsFalse() throws Exception {
+ IpInetAddressMatcher matcher = new IpInetAddressMatcher("2001:db8::/48");
+ assertThat(matcher.matches(InetAddress.getByName("2001:db8:1:0:0:0:0:0"))).isFalse();
+ }
+
+ @Test
+ void matchesWhenIpv4AndIpv6AddressThenReturnsFalse() throws Exception {
+ IpInetAddressMatcher matcher = new IpInetAddressMatcher("192.168.1.1");
+ assertThat(matcher.matches(InetAddress.getByName("fe80::21f:5bff:fe33:bd68"))).isFalse();
+ }
+
+ @Test
+ void matchesWhenIpv6AndIpv4AddressThenReturnsFalse() throws Exception {
+ IpInetAddressMatcher matcher = new IpInetAddressMatcher("fe80::21f:5bff:fe33:bd68");
+ assertThat(matcher.matches(InetAddress.getByName("192.168.1.1"))).isFalse();
+ }
+
+ @Test
+ void matchesWhenStringIpv4MatchThenReturnsTrue() {
+ IpInetAddressMatcher matcher = new IpInetAddressMatcher("192.168.1.1");
+ assertThat(matcher.matches("192.168.1.1")).isTrue();
+ }
+
+ @Test
+ void matchesWhenStringIpv4NoMatchThenReturnsFalse() {
+ IpInetAddressMatcher matcher = new IpInetAddressMatcher("192.168.1.1");
+ assertThat(matcher.matches("192.168.1.2")).isFalse();
+ }
+
+ @Test
+ void matchesWhenStringNullThenReturnsFalse() {
+ IpInetAddressMatcher matcher = new IpInetAddressMatcher("192.168.1.1");
+ assertThat(matcher.matches((String) null)).isFalse();
+ }
+
+ @Test
+ void matchesWhenInetAddressNullThenReturnsFalse() {
+ IpInetAddressMatcher matcher = new IpInetAddressMatcher("192.168.1.1");
+ assertThat(matcher.matches((InetAddress) null)).isFalse();
+ }
+
+}