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 matchers = new ArrayList<>(); + + private boolean reportOnly; + + /** + * Adds an include list matcher that permits only the specified addresses. + * @param addresses the list of IP address patterns to include (cannot be null or + * empty) + * @return this builder for method chaining + * @throws IllegalArgumentException if addresses is null or empty + */ + public Builder includeAddresses(List addresses) { + Assert.notEmpty(addresses, "addresses cannot be empty"); + List matchers = addresses.stream() + .map(IpInetAddressMatcher::new) + .toList(); + this.matchers.add(new IncludeListInetAddressMatcher(matchers)); + return this; + } + + /** + * Adds an exclude list matcher that blocks the specified addresses. + * @param addresses the list of IP address patterns to exclude (cannot be null or + * empty) + * @return this builder for method chaining + * @throws IllegalArgumentException if addresses is null or empty + */ + public Builder excludeAddresses(List addresses) { + Assert.notEmpty(addresses, "addresses cannot be empty"); + List matchers = addresses.stream() + .map(IpInetAddressMatcher::new) + .toList(); + this.matchers.add(new ExcludeListInetAddressMatcher(matchers)); + return this; + } + + /** + * Adds custom matchers to the matcher chain. All matchers must match for an + * address to be permitted. + * @param matchers the custom {@link InetAddressMatcher} instances to add (cannot + * be null or empty) + * @return this builder for method chaining + * @throws IllegalArgumentException if matchers is null or empty + */ + public Builder matchAll(InetAddressMatcher... matchers) { + Assert.notEmpty(matchers, "matchers cannot be empty"); + for (InetAddressMatcher matcher : matchers) { + this.matchers.add(matcher); + } + return this; + } + + /** + * Configures the matcher to operate in report-only mode. In this mode, matching + * logic is evaluated and logged, but all addresses are allowed regardless of + * match results. + * @return this builder for method chaining + */ + public Builder reportOnly() { + this.reportOnly = true; + return this; + } + + /** + * Builds the configured {@link InetAddressMatcher}. + * @return the constructed {@link InetAddressMatcher} + */ + public InetAddressMatcher build() { + return new CompositeInetAddressMatcher(this.matchers, this.reportOnly); + } + + } + + /** + * An {@link InetAddressMatcher} that matches addresses against an include list. Only + * addresses that match an entry in the include list are permitted. + * + * @author Rossen Stoyanchev + * @author Rob Winch + */ + static final class IncludeListInetAddressMatcher implements InetAddressMatcher { + + private final List includeList; + + IncludeListInetAddressMatcher(List includeList) { + Assert.notEmpty(includeList, "includeList cannot be null or empty"); + this.includeList = new ArrayList<>(includeList); + } + + @Override + public boolean matches(@Nullable InetAddress address) { + for (InetAddressMatcher matcher : this.includeList) { + if (matcher.matches(address)) { + return true; + } + } + return false; + } + + @Override + public String toString() { + return "IncludeListInetAddressMatcher[\"" + this.includeList + "\"]"; + } + + } + + /** + * An {@link InetAddressMatcher} that matches addresses against an exclude list. + * Addresses that match an entry in the exclude list are rejected. + * + * @author Rossen Stoyanchev + * @author Rob Winch + */ + static final class ExcludeListInetAddressMatcher implements InetAddressMatcher { + + private final List excludeList; + + ExcludeListInetAddressMatcher(List excludeList) { + Assert.notEmpty(excludeList, "excludeList cannot be null or empty"); + this.excludeList = new ArrayList<>(excludeList); + } + + @Override + public boolean matches(@Nullable InetAddress address) { + for (InetAddressMatcher matcher : this.excludeList) { + if (matcher.matches(address)) { + return false; + } + } + return true; + } + + @Override + public String toString() { + return "ExcludeListInetAddressMatcher[\"" + this.excludeList + "\"]"; + } + + } + + /** + * An {@link InetAddressMatcher} that matches internal (private) 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). + * + * @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 matchers; + + private final boolean reportOnly; + + CompositeInetAddressMatcher(List matchers, boolean reportOnly) { + this.matchers = new ArrayList<>(matchers); + this.reportOnly = reportOnly; + } + + @Override + public boolean matches(@Nullable InetAddress address) { + boolean result = doMatch(address); + return (this.reportOnly || result); + } + + private boolean doMatch(@Nullable InetAddress address) { + for (InetAddressMatcher matcher : this.matchers) { + if (!matcher.matches(address)) { + if (logger.isDebugEnabled()) { + logger.debug("InetAddress " + address + " blocked by " + matcher); + } + return false; + } + } + return true; + } + + } + +} diff --git a/web/src/main/java/org/springframework/security/web/util/matcher/InetAddressParser.java b/web/src/main/java/org/springframework/security/web/util/matcher/InetAddressParser.java new file mode 100644 index 00000000000..d30701d77d3 --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/util/matcher/InetAddressParser.java @@ -0,0 +1,73 @@ +/* + * 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.net.UnknownHostException; +import java.util.regex.Pattern; + +import org.springframework.util.Assert; + +/** + * Utility class for parsing IP addresses. + * + * @author Luke Taylor + * @author Steve Riesenberg + * @author Andrey Litvitski + * @author Rob Winch + * @since 7.1 + */ +final class InetAddressParser { + + private static Pattern IPV4 = Pattern.compile("^\\d{1,3}(?:\\.\\d{1,3}){0,3}(?:/\\d{1,2})?$"); + + /** + * Parses the given address string into an {@link InetAddress}. + * @param address the IP address string to parse + * @return the parsed {@link InetAddress} + * @throws IllegalArgumentException if the address cannot be parsed or appears to be a + * hostname + */ + static InetAddress parseAddress(String address) { + assertNotHostName(address); + try { + return InetAddress.getByName(address); + } + catch (UnknownHostException ex) { + throw new IllegalArgumentException("Failed to parse address '" + address + "'", ex); + } + } + + static void assertNotHostName(String ipAddress) { + Assert.isTrue(isIpAddress(ipAddress), + () -> String.format("ipAddress %s doesn't look like an IP Address. Is it a host name?", ipAddress)); + } + + private static boolean isIpAddress(String ipAddress) { + // @formatter:off + return IPV4.matcher(ipAddress).matches() + || ipAddress.charAt(0) == '[' + || ipAddress.charAt(0) == ':' + || Character.digit(ipAddress.charAt(0), 16) != -1 + && ipAddress.indexOf(':') > 0; + // @formatter:on + } + + private InetAddressParser() { + } + +} diff --git a/web/src/main/java/org/springframework/security/web/util/matcher/IpAddressMatcher.java b/web/src/main/java/org/springframework/security/web/util/matcher/IpAddressMatcher.java index 4b891f3dabd..4e2ea3fdbc0 100644 --- a/web/src/main/java/org/springframework/security/web/util/matcher/IpAddressMatcher.java +++ b/web/src/main/java/org/springframework/security/web/util/matcher/IpAddressMatcher.java @@ -16,15 +16,8 @@ package org.springframework.security.web.util.matcher; -import java.net.InetAddress; -import java.net.UnknownHostException; -import java.util.Objects; -import java.util.regex.Pattern; - import jakarta.servlet.http.HttpServletRequest; - -import org.springframework.util.Assert; -import org.springframework.util.StringUtils; +import org.jspecify.annotations.Nullable; /** * Matches a request based on IP Address or subnet mask matching against the remote @@ -40,11 +33,7 @@ */ public final class IpAddressMatcher implements RequestMatcher { - private static Pattern IPV4 = Pattern.compile("^\\d{1,3}(?:\\.\\d{1,3}){0,3}(?:/\\d{1,2})?$"); - - private final InetAddress requiredAddress; - - private final int nMaskBits; + private final InetAddressMatcher matcher; /** * Takes a specific IP address or a range specified using the IP/Netmask (e.g. @@ -53,89 +42,26 @@ public final class IpAddressMatcher implements RequestMatcher { * come. */ public IpAddressMatcher(String ipAddress) { - Assert.hasText(ipAddress, "ipAddress cannot be empty"); - assertNotHostName(ipAddress); - - 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 = 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)); + this.matcher = new IpInetAddressMatcher(ipAddress); } @Override public boolean matches(HttpServletRequest request) { - return matches(request.getRemoteAddr()); - } - - public boolean matches(String ipAddress) { - // Do not match null or blank address - if (!StringUtils.hasText(ipAddress)) { - return false; - } - - assertNotHostName(ipAddress); - InetAddress remoteAddress = parseAddress(ipAddress); - if (!this.requiredAddress.getClass().equals(remoteAddress.getClass())) { - return false; - } - if (this.nMaskBits < 0) { - return remoteAddress.equals(this.requiredAddress); - } - byte[] remAddr = remoteAddress.getAddress(); - byte[] reqAddr = this.requiredAddress.getAddress(); - int nMaskFullBytes = this.nMaskBits / 8; - for (int i = 0; i < nMaskFullBytes; i++) { - if (remAddr[i] != reqAddr[i]) { - return false; - } - } - byte finalByte = (byte) (0xFF00 >> (this.nMaskBits & 0x07)); - if (finalByte != 0) { - return (remAddr[nMaskFullBytes] & finalByte) == (reqAddr[nMaskFullBytes] & finalByte); - } - return true; - } - - private static void assertNotHostName(String ipAddress) { - Assert.isTrue(isIpAddress(ipAddress), - () -> String.format("ipAddress %s doesn't look like an IP Address. Is it a host name?", ipAddress)); + return this.matcher.matches(request.getRemoteAddr()); } - private static boolean isIpAddress(String ipAddress) { - // @formatter:off - return IPV4.matcher(ipAddress).matches() - || ipAddress.charAt(0) == '[' - || ipAddress.charAt(0) == ':' - || Character.digit(ipAddress.charAt(0), 16) != -1 - && ipAddress.indexOf(':') > 0; - // @formatter:on - } - - private InetAddress parseAddress(String address) { - try { - return InetAddress.getByName(address); - } - catch (UnknownHostException ex) { - throw new IllegalArgumentException("Failed to parse address '" + address + "'", ex); - } + /** + * Checks if the given IP address string matches the configured address pattern. + * @param ipAddress the IP address string to check (may be {@code null}) + * @return {@code true} if the address matches, {@code false} otherwise + */ + public boolean matches(@Nullable String ipAddress) { + return this.matcher.matches(ipAddress); } @Override public String toString() { - String hostAddress = this.requiredAddress.getHostAddress(); - return (this.nMaskBits < 0) ? "IpAddress [" + hostAddress + "]" - : "IpAddress [" + hostAddress + "/" + this.nMaskBits + "]"; + return this.matcher.toString(); } } diff --git a/web/src/main/java/org/springframework/security/web/util/matcher/IpInetAddressMatcher.java b/web/src/main/java/org/springframework/security/web/util/matcher/IpInetAddressMatcher.java new file mode 100644 index 00000000000..62ae9daf589 --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/util/matcher/IpInetAddressMatcher.java @@ -0,0 +1,117 @@ +/* + * 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.net.UnknownHostException; +import java.util.Objects; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import org.jspecify.annotations.Nullable; + +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +/** + * Implementation of {@link InetAddressMatcher} that matches IP addresses with support for + * CIDR notation (e.g., 192.168.1.0/24). + *

+ * 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(); + } + +}