From d82601f1372db92c18aba22113b7e93f46d0f3e2 Mon Sep 17 00:00:00 2001 From: Robert Winch <362503+rwinch@users.noreply.github.com> Date: Mon, 2 Feb 2026 16:01:24 -0600 Subject: [PATCH 1/6] Add InetAddressMatcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Gábor Vaspöri Co-authored-by: Kian Jamali Co-authored-by: Rossen Stoyanchev --- .../web/util/matcher/InetAddressMatcher.java | 51 ++ .../web/util/matcher/InetAddressMatchers.java | 369 ++++++++++++++ .../web/util/matcher/InetAddressParser.java | 73 +++ .../web/util/matcher/IpAddressMatcher.java | 98 +--- .../util/matcher/IpInetAddressMatcher.java | 110 ++++ .../util/matcher/InetAddressMatcherTests.java | 69 +++ .../matcher/InetAddressMatchersTests.java | 470 ++++++++++++++++++ .../matcher/IpInetAddressMatcherTests.java | 132 +++++ 8 files changed, 1286 insertions(+), 86 deletions(-) create mode 100644 web/src/main/java/org/springframework/security/web/util/matcher/InetAddressMatcher.java create mode 100644 web/src/main/java/org/springframework/security/web/util/matcher/InetAddressMatchers.java create mode 100644 web/src/main/java/org/springframework/security/web/util/matcher/InetAddressParser.java create mode 100644 web/src/main/java/org/springframework/security/web/util/matcher/IpInetAddressMatcher.java create mode 100644 web/src/test/java/org/springframework/security/web/util/matcher/InetAddressMatcherTests.java create mode 100644 web/src/test/java/org/springframework/security/web/util/matcher/InetAddressMatchersTests.java create mode 100644 web/src/test/java/org/springframework/security/web/util/matcher/IpInetAddressMatcherTests.java 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..8c5fb7f70a8 --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/util/matcher/InetAddressMatcher.java @@ -0,0 +1,51 @@ +/* + * 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 + * @return {@code true} if the address matches, {@code false} otherwise + */ + boolean matches(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 or if + * {@code null} + * @since 7.1 + */ + 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..ccbcbf15a00 --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/util/matcher/InetAddressMatchers.java @@ -0,0 +1,369 @@ +/* + * 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.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().allowList(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().allowList(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 allow list matcher that permits only the specified addresses. + * @param addresses the list of IP address patterns to allow (cannot be null or + * empty) + * @return this builder for method chaining + * @throws IllegalArgumentException if addresses is null or empty + */ + public Builder allowAddresses(List addresses) { + Assert.notEmpty(addresses, "addresses cannot be empty"); + List matchers = addresses.stream() + .map(IpInetAddressMatcher::new) + .toList(); + this.matchers.add(new AllowListInetAddressMatcher(matchers)); + return this; + } + + /** + * Adds a deny list matcher that blocks the specified addresses. + * @param addresses the list of IP address patterns to deny (cannot be null or + * empty) + * @return this builder for method chaining + * @throws IllegalArgumentException if addresses is null or empty + */ + public Builder denyAddresses(List addresses) { + Assert.notEmpty(addresses, "addresses cannot be empty"); + List matchers = addresses.stream() + .map(IpInetAddressMatcher::new) + .toList(); + this.matchers.add(new DenyListInetAddressMatcher(matchers)); + return this; + } + + /** + * Adds custom matchers to the matcher chain. + * @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 allowList(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 allow list. Only + * addresses that match an entry in the allow list are permitted. + * + * @author Rossen Stoyanchev + * @author Rob Winch + */ + static final class AllowListInetAddressMatcher implements InetAddressMatcher { + + private final List allowList; + + AllowListInetAddressMatcher(List allowList) { + Assert.notEmpty(allowList, "allowList cannot be null or empty"); + this.allowList = new ArrayList<>(allowList); + } + + @Override + public boolean matches(InetAddress address) { + for (InetAddressMatcher matcher : this.allowList) { + if (matcher.matches(address)) { + return true; + } + } + return false; + } + + @Override + public String toString() { + return "AllowListInetAddressMatcher[\"" + this.allowList + "\"]"; + } + + } + + /** + * An {@link InetAddressMatcher} that matches addresses against a deny list. Addresses + * that match an entry in the deny list are rejected. + * + * @author Rossen Stoyanchev + * @author Rob Winch + */ + static final class DenyListInetAddressMatcher implements InetAddressMatcher { + + private final List disallowList; + + DenyListInetAddressMatcher(List disallowList) { + Assert.notEmpty(disallowList, "disallowList cannot be null or empty"); + this.disallowList = new ArrayList<>(disallowList); + } + + @Override + public boolean matches(InetAddress address) { + for (InetAddressMatcher matcher : this.disallowList) { + if (matcher.matches(address)) { + return false; + } + } + return true; + } + + @Override + public String toString() { + return "DenyListInetAddressMatcher[\"" + this.disallowList + "\"]"; + } + + } + + /** + * 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(InetAddress address) { + 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(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(InetAddress address) { + boolean result = doMatch(address); + return (this.reportOnly || result); + } + + private boolean doMatch(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..982b5243959 --- /dev/null +++ b/web/src/main/java/org/springframework/security/web/util/matcher/IpInetAddressMatcher.java @@ -0,0 +1,110 @@ +/* + * 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.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. + * + * @author Rossen Stoyanchev + * @author Gábor Vaspöri + * @author Kian Jamali + * @author Rob Winch + * @since 7.1 + */ +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(InetAddress toCheck) { + 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..e6323ba7e99 --- /dev/null +++ b/web/src/test/java/org/springframework/security/web/util/matcher/InetAddressMatchersTests.java @@ -0,0 +1,470 @@ +/* + * 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 allowAddressesWhenNullThenThrowsIllegalArgumentException() { + assertThatIllegalArgumentException().isThrownBy(() -> InetAddressMatchers.builder().allowAddresses(null)) + .withMessage("addresses cannot be empty"); + } + + @Test + void allowAddressesWhenEmptyListThenThrowsIllegalArgumentException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> InetAddressMatchers.builder().allowAddresses(List.of())) + .withMessage("addresses cannot be empty"); + } + + @ParameterizedTest + @ValueSource(strings = { "192.168.1.1", "192.168.1.2" }) + void allowAddressesWhenSingleAddressThenMatchesOnlyThatAddress(String testAddress) throws Exception { + InetAddressMatcher matcher = InetAddressMatchers.builder().allowAddresses(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 allowAddressesWhenMultipleAddressesThenMatchesAny(String testAddress) throws Exception { + InetAddressMatcher matcher = InetAddressMatchers.builder() + .allowAddresses(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 allowAddressesWhenCidrNotationThenMatchesSubnet(String testAddress) throws Exception { + InetAddressMatcher matcher = InetAddressMatchers.builder() + .allowAddresses(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 denyAddressesWhenNullThenThrowsIllegalArgumentException() { + assertThatIllegalArgumentException().isThrownBy(() -> InetAddressMatchers.builder().denyAddresses(null)) + .withMessage("addresses cannot be empty"); + } + + @Test + void denyAddressesWhenEmptyListThenThrowsIllegalArgumentException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> InetAddressMatchers.builder().denyAddresses(List.of())) + .withMessage("addresses cannot be empty"); + } + + @ParameterizedTest + @ValueSource(strings = { "192.168.1.1", "192.168.1.2" }) + void denyAddressesWhenSingleAddressThenBlocksOnlyThatAddress(String testAddress) throws Exception { + InetAddressMatcher matcher = InetAddressMatchers.builder().denyAddresses(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 denyAddressesWhenMultipleAddressesThenBlocksAll(String testAddress) throws Exception { + InetAddressMatcher matcher = InetAddressMatchers.builder() + .denyAddresses(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 denyAddressesWhenCidrNotationThenBlocksSubnet(String testAddress) throws Exception { + InetAddressMatcher matcher = InetAddressMatchers.builder().denyAddresses(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 allowListWhenVarargsThenAddsMatchersToChain(String testAddress) throws Exception { + InetAddressMatcher customMatcher = (address) -> address.getHostAddress().startsWith("10."); + InetAddressMatcher matcher = InetAddressMatchers.builder().allowList(customMatcher).build(); + InetAddress address = InetAddress.getByName(testAddress); + boolean expected = testAddress.startsWith("10."); + assertThat(matcher.matches(address)).isEqualTo(expected); + } + + @Test + void allowListWhenNullVarargsThenThrowsIllegalArgumentException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> InetAddressMatchers.builder().allowList((InetAddressMatcher[]) null)) + .withMessage("matchers cannot be empty"); + } + + @Test + void allowListWhenEmptyVarargsThenThrowsIllegalArgumentException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> InetAddressMatchers.builder().allowList(new InetAddressMatcher[0])) + .withMessage("matchers cannot be empty"); + } + + @ParameterizedTest + @ValueSource(strings = { "10.0.0.1", "10.0.0.2", "192.168.1.1" }) + void allowListWhenMultipleMatchersThenAppliesAndLogic(String testAddress) throws Exception { + InetAddressMatcher startsWithTen = (address) -> address.getHostAddress().startsWith("10."); + InetAddressMatcher endsWithOne = (address) -> address.getHostAddress().endsWith(".1"); + InetAddressMatcher matcher = InetAddressMatchers.builder().allowList(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() + .denyAddresses(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() + .allowAddresses(List.of("192.168.1.0/24")) + .denyAddresses(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 AllowListInetAddressMatcherTests { + + @Test + void constructorWhenNullListThenThrowsIllegalArgumentException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new InetAddressMatchers.AllowListInetAddressMatcher(null)) + .withMessage("allowList cannot be null or empty"); + } + + @Test + void constructorWhenEmptyListThenThrowsIllegalArgumentException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new InetAddressMatchers.AllowListInetAddressMatcher(List.of())) + .withMessage("allowList cannot be null or empty"); + } + + @Test + void matchesWhenAddressInListThenReturnsTrue() throws Exception { + String addressString = "192.168.1.1"; + InetAddressMatcher matcher = InetAddressMatchers.builder().allowAddresses(List.of(addressString)).build(); + assertThat(matcher.matches(InetAddress.getByName(addressString))).isTrue(); + } + + @Test + void matchesWhenAddressNotInListThenReturnsFalse() throws Exception { + InetAddressMatcher matcher = InetAddressMatchers.builder().allowAddresses(List.of("192.168.1.1")).build(); + assertThat(matcher.matches(InetAddress.getByName("192.168.1.2"))).isFalse(); + } + + } + + @Nested + class DenyListInetAddressMatcherTests { + + @Test + void constructorWhenNullListThenThrowsIllegalArgumentException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new InetAddressMatchers.DenyListInetAddressMatcher(null)) + .withMessage("disallowList cannot be null or empty"); + } + + @Test + void constructorWhenEmptyListThenThrowsIllegalArgumentException() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new InetAddressMatchers.DenyListInetAddressMatcher(List.of())) + .withMessage("disallowList cannot be null or empty"); + } + + @Test + void matchesWhenAddressInListThenReturnsFalse() throws Exception { + String addressString = "192.168.1.1"; + InetAddressMatcher matcher = InetAddressMatchers.builder().denyAddresses(List.of(addressString)).build(); + assertThat(matcher.matches(InetAddress.getByName(addressString))).isFalse(); + } + + @Test + void matchesWhenAddressNotInListThenReturnsTrue() throws Exception { + InetAddressMatcher matcher = InetAddressMatchers.builder().denyAddresses(List.of("192.168.1.1")).build(); + assertThat(matcher.matches(InetAddress.getByName("192.168.1.2"))).isTrue(); + } + + } + + @Nested + class InternalInetAddressMatcherTests { + + @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() + .allowAddresses(List.of("192.168.1.0/24")) + .allowList((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() + .allowAddresses(List.of("192.168.1.0/24")) + .allowList((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() + .denyAddresses(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..6c64628a38b --- /dev/null +++ b/web/src/test/java/org/springframework/security/web/util/matcher/IpInetAddressMatcherTests.java @@ -0,0 +1,132 @@ +/* + * 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(); + } + +} From 7d614ac5e51ecdaea2a5c3c6ca0269209be08a28 Mon Sep 17 00:00:00 2001 From: Robert Winch <362503+rwinch@users.noreply.github.com> Date: Tue, 3 Feb 2026 08:37:41 -0600 Subject: [PATCH 2/6] Use consistent match terminology Previously the terminology blurred allow/deny into the matching APIs which was confusing. The API now consistently uses matching logic. --- .../web/util/matcher/InetAddressMatchers.java | 61 +++++------ .../matcher/InetAddressMatchersTests.java | 102 +++++++++--------- 2 files changed, 83 insertions(+), 80 deletions(-) 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 index ccbcbf15a00..ac13321d997 100644 --- 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 @@ -50,7 +50,7 @@ public static Builder builder() { * @return a {@link Builder} configured to match external addresses */ public static Builder matchExternal() { - return builder().allowList(ExternalInetAddressMatcher.getInstance()); + return builder().matchAll(ExternalInetAddressMatcher.getInstance()); } /** @@ -62,7 +62,7 @@ public static Builder matchExternal() { * @return a {@link Builder} configured to match internal addresses */ public static Builder matchInternal() { - return builder().allowList(InternalInetAddressMatcher.getInstance()); + return builder().matchAll(InternalInetAddressMatcher.getInstance()); } /** @@ -81,45 +81,46 @@ public static final class Builder { private boolean reportOnly; /** - * Adds an allow list matcher that permits only the specified addresses. - * @param addresses the list of IP address patterns to allow (cannot be null or + * 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 allowAddresses(List addresses) { + public Builder includeAddresses(List addresses) { Assert.notEmpty(addresses, "addresses cannot be empty"); List matchers = addresses.stream() .map(IpInetAddressMatcher::new) .toList(); - this.matchers.add(new AllowListInetAddressMatcher(matchers)); + this.matchers.add(new IncludeListInetAddressMatcher(matchers)); return this; } /** - * Adds a deny list matcher that blocks the specified addresses. - * @param addresses the list of IP address patterns to deny (cannot be null or + * 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 denyAddresses(List addresses) { + public Builder excludeAddresses(List addresses) { Assert.notEmpty(addresses, "addresses cannot be empty"); List matchers = addresses.stream() .map(IpInetAddressMatcher::new) .toList(); - this.matchers.add(new DenyListInetAddressMatcher(matchers)); + this.matchers.add(new ExcludeListInetAddressMatcher(matchers)); return this; } /** - * Adds custom matchers to the matcher chain. + * 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 allowList(InetAddressMatcher... matchers) { + public Builder matchAll(InetAddressMatcher... matchers) { Assert.notEmpty(matchers, "matchers cannot be empty"); for (InetAddressMatcher matcher : matchers) { this.matchers.add(matcher); @@ -149,24 +150,24 @@ public InetAddressMatcher build() { } /** - * An {@link InetAddressMatcher} that matches addresses against an allow list. Only - * addresses that match an entry in the allow list are permitted. + * 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 AllowListInetAddressMatcher implements InetAddressMatcher { + static final class IncludeListInetAddressMatcher implements InetAddressMatcher { - private final List allowList; + private final List includeList; - AllowListInetAddressMatcher(List allowList) { - Assert.notEmpty(allowList, "allowList cannot be null or empty"); - this.allowList = new ArrayList<>(allowList); + IncludeListInetAddressMatcher(List includeList) { + Assert.notEmpty(includeList, "includeList cannot be null or empty"); + this.includeList = new ArrayList<>(includeList); } @Override public boolean matches(InetAddress address) { - for (InetAddressMatcher matcher : this.allowList) { + for (InetAddressMatcher matcher : this.includeList) { if (matcher.matches(address)) { return true; } @@ -176,30 +177,30 @@ public boolean matches(InetAddress address) { @Override public String toString() { - return "AllowListInetAddressMatcher[\"" + this.allowList + "\"]"; + return "IncludeListInetAddressMatcher[\"" + this.includeList + "\"]"; } } /** - * An {@link InetAddressMatcher} that matches addresses against a deny list. Addresses - * that match an entry in the deny list are rejected. + * 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 DenyListInetAddressMatcher implements InetAddressMatcher { + static final class ExcludeListInetAddressMatcher implements InetAddressMatcher { - private final List disallowList; + private final List excludeList; - DenyListInetAddressMatcher(List disallowList) { - Assert.notEmpty(disallowList, "disallowList cannot be null or empty"); - this.disallowList = new ArrayList<>(disallowList); + ExcludeListInetAddressMatcher(List excludeList) { + Assert.notEmpty(excludeList, "excludeList cannot be null or empty"); + this.excludeList = new ArrayList<>(excludeList); } @Override public boolean matches(InetAddress address) { - for (InetAddressMatcher matcher : this.disallowList) { + for (InetAddressMatcher matcher : this.excludeList) { if (matcher.matches(address)) { return false; } @@ -209,7 +210,7 @@ public boolean matches(InetAddress address) { @Override public String toString() { - return "DenyListInetAddressMatcher[\"" + this.disallowList + "\"]"; + return "ExcludeListInetAddressMatcher[\"" + this.excludeList + "\"]"; } } 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 index e6323ba7e99..6d1fdd416c3 100644 --- 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 @@ -55,22 +55,22 @@ void matchInternalWhenInvokedThenReturnsBuilder() { class BuilderTests { @Test - void allowAddressesWhenNullThenThrowsIllegalArgumentException() { - assertThatIllegalArgumentException().isThrownBy(() -> InetAddressMatchers.builder().allowAddresses(null)) + void includeAddressesWhenNullThenThrowsIllegalArgumentException() { + assertThatIllegalArgumentException().isThrownBy(() -> InetAddressMatchers.builder().includeAddresses(null)) .withMessage("addresses cannot be empty"); } @Test - void allowAddressesWhenEmptyListThenThrowsIllegalArgumentException() { + void includeAddressesWhenEmptyListThenThrowsIllegalArgumentException() { assertThatIllegalArgumentException() - .isThrownBy(() -> InetAddressMatchers.builder().allowAddresses(List.of())) + .isThrownBy(() -> InetAddressMatchers.builder().includeAddresses(List.of())) .withMessage("addresses cannot be empty"); } @ParameterizedTest @ValueSource(strings = { "192.168.1.1", "192.168.1.2" }) - void allowAddressesWhenSingleAddressThenMatchesOnlyThatAddress(String testAddress) throws Exception { - InetAddressMatcher matcher = InetAddressMatchers.builder().allowAddresses(List.of("192.168.1.1")).build(); + 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); @@ -78,9 +78,9 @@ void allowAddressesWhenSingleAddressThenMatchesOnlyThatAddress(String testAddres @ParameterizedTest @ValueSource(strings = { "192.168.1.1", "10.0.0.1", "8.8.8.8" }) - void allowAddressesWhenMultipleAddressesThenMatchesAny(String testAddress) throws Exception { + void includeAddressesWhenMultipleAddressesThenMatchesAny(String testAddress) throws Exception { InetAddressMatcher matcher = InetAddressMatchers.builder() - .allowAddresses(List.of("192.168.1.1", "10.0.0.1")) + .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"); @@ -89,9 +89,9 @@ void allowAddressesWhenMultipleAddressesThenMatchesAny(String testAddress) throw @ParameterizedTest @ValueSource(strings = { "192.168.1.1", "192.168.1.255", "192.168.2.1" }) - void allowAddressesWhenCidrNotationThenMatchesSubnet(String testAddress) throws Exception { + void includeAddressesWhenCidrNotationThenMatchesSubnet(String testAddress) throws Exception { InetAddressMatcher matcher = InetAddressMatchers.builder() - .allowAddresses(List.of("192.168.1.0/24")) + .includeAddresses(List.of("192.168.1.0/24")) .build(); InetAddress address = InetAddress.getByName(testAddress); boolean expected = testAddress.startsWith("192.168.1."); @@ -99,22 +99,22 @@ void allowAddressesWhenCidrNotationThenMatchesSubnet(String testAddress) throws } @Test - void denyAddressesWhenNullThenThrowsIllegalArgumentException() { - assertThatIllegalArgumentException().isThrownBy(() -> InetAddressMatchers.builder().denyAddresses(null)) + void excludeAddressesWhenNullThenThrowsIllegalArgumentException() { + assertThatIllegalArgumentException().isThrownBy(() -> InetAddressMatchers.builder().excludeAddresses(null)) .withMessage("addresses cannot be empty"); } @Test - void denyAddressesWhenEmptyListThenThrowsIllegalArgumentException() { + void excludeAddressesWhenEmptyListThenThrowsIllegalArgumentException() { assertThatIllegalArgumentException() - .isThrownBy(() -> InetAddressMatchers.builder().denyAddresses(List.of())) + .isThrownBy(() -> InetAddressMatchers.builder().excludeAddresses(List.of())) .withMessage("addresses cannot be empty"); } @ParameterizedTest @ValueSource(strings = { "192.168.1.1", "192.168.1.2" }) - void denyAddressesWhenSingleAddressThenBlocksOnlyThatAddress(String testAddress) throws Exception { - InetAddressMatcher matcher = InetAddressMatchers.builder().denyAddresses(List.of("192.168.1.1")).build(); + 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); @@ -122,9 +122,9 @@ void denyAddressesWhenSingleAddressThenBlocksOnlyThatAddress(String testAddress) @ParameterizedTest @ValueSource(strings = { "192.168.1.1", "10.0.0.1", "8.8.8.8" }) - void denyAddressesWhenMultipleAddressesThenBlocksAll(String testAddress) throws Exception { + void excludeAddressesWhenMultipleAddressesThenBlocksAll(String testAddress) throws Exception { InetAddressMatcher matcher = InetAddressMatchers.builder() - .denyAddresses(List.of("192.168.1.1", "10.0.0.1")) + .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"); @@ -133,8 +133,10 @@ void denyAddressesWhenMultipleAddressesThenBlocksAll(String testAddress) throws @ParameterizedTest @ValueSource(strings = { "192.168.1.1", "192.168.1.255", "192.168.2.1" }) - void denyAddressesWhenCidrNotationThenBlocksSubnet(String testAddress) throws Exception { - InetAddressMatcher matcher = InetAddressMatchers.builder().denyAddresses(List.of("192.168.1.0/24")).build(); + 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); @@ -142,34 +144,34 @@ void denyAddressesWhenCidrNotationThenBlocksSubnet(String testAddress) throws Ex @ParameterizedTest @ValueSource(strings = { "10.0.0.1", "192.168.1.1" }) - void allowListWhenVarargsThenAddsMatchersToChain(String testAddress) throws Exception { + void matchAllWhenVarargsThenAddsMatchersToChain(String testAddress) throws Exception { InetAddressMatcher customMatcher = (address) -> address.getHostAddress().startsWith("10."); - InetAddressMatcher matcher = InetAddressMatchers.builder().allowList(customMatcher).build(); + 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 allowListWhenNullVarargsThenThrowsIllegalArgumentException() { + void matchAllWhenNullVarargsThenThrowsIllegalArgumentException() { assertThatIllegalArgumentException() - .isThrownBy(() -> InetAddressMatchers.builder().allowList((InetAddressMatcher[]) null)) + .isThrownBy(() -> InetAddressMatchers.builder().matchAll((InetAddressMatcher[]) null)) .withMessage("matchers cannot be empty"); } @Test - void allowListWhenEmptyVarargsThenThrowsIllegalArgumentException() { + void matchAllWhenEmptyVarargsThenThrowsIllegalArgumentException() { assertThatIllegalArgumentException() - .isThrownBy(() -> InetAddressMatchers.builder().allowList(new InetAddressMatcher[0])) + .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 allowListWhenMultipleMatchersThenAppliesAndLogic(String testAddress) throws Exception { + void matchAllWhenMultipleMatchersThenAppliesAndLogic(String testAddress) throws Exception { InetAddressMatcher startsWithTen = (address) -> address.getHostAddress().startsWith("10."); InetAddressMatcher endsWithOne = (address) -> address.getHostAddress().endsWith(".1"); - InetAddressMatcher matcher = InetAddressMatchers.builder().allowList(startsWithTen, endsWithOne).build(); + 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); @@ -179,7 +181,7 @@ void allowListWhenMultipleMatchersThenAppliesAndLogic(String testAddress) throws @ValueSource(strings = { "192.168.1.1", "8.8.8.8" }) void reportOnlyWhenSetThenAllowsAllAddresses(String testAddress) throws Exception { InetAddressMatcher matcher = InetAddressMatchers.builder() - .denyAddresses(List.of("192.168.1.1")) + .excludeAddresses(List.of("192.168.1.1")) .reportOnly() .build(); InetAddress address = InetAddress.getByName(testAddress); @@ -190,8 +192,8 @@ void reportOnlyWhenSetThenAllowsAllAddresses(String testAddress) throws Exceptio @ValueSource(strings = { "192.168.1.1", "192.168.1.100", "192.168.2.1" }) void buildWhenMultipleMatchersThenAppliesAndLogic(String testAddress) throws Exception { InetAddressMatcher matcher = InetAddressMatchers.builder() - .allowAddresses(List.of("192.168.1.0/24")) - .denyAddresses(List.of("192.168.1.100")) + .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"); @@ -201,64 +203,64 @@ void buildWhenMultipleMatchersThenAppliesAndLogic(String testAddress) throws Exc } @Nested - class AllowListInetAddressMatcherTests { + class IncludeListInetAddressMatcherTests { @Test void constructorWhenNullListThenThrowsIllegalArgumentException() { assertThatIllegalArgumentException() - .isThrownBy(() -> new InetAddressMatchers.AllowListInetAddressMatcher(null)) - .withMessage("allowList cannot be null or empty"); + .isThrownBy(() -> new InetAddressMatchers.IncludeListInetAddressMatcher(null)) + .withMessage("includeList cannot be null or empty"); } @Test void constructorWhenEmptyListThenThrowsIllegalArgumentException() { assertThatIllegalArgumentException() - .isThrownBy(() -> new InetAddressMatchers.AllowListInetAddressMatcher(List.of())) - .withMessage("allowList cannot be null or empty"); + .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().allowAddresses(List.of(addressString)).build(); + 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().allowAddresses(List.of("192.168.1.1")).build(); + InetAddressMatcher matcher = InetAddressMatchers.builder().includeAddresses(List.of("192.168.1.1")).build(); assertThat(matcher.matches(InetAddress.getByName("192.168.1.2"))).isFalse(); } } @Nested - class DenyListInetAddressMatcherTests { + class ExcludeListInetAddressMatcherTests { @Test void constructorWhenNullListThenThrowsIllegalArgumentException() { assertThatIllegalArgumentException() - .isThrownBy(() -> new InetAddressMatchers.DenyListInetAddressMatcher(null)) - .withMessage("disallowList cannot be null or empty"); + .isThrownBy(() -> new InetAddressMatchers.ExcludeListInetAddressMatcher(null)) + .withMessage("excludeList cannot be null or empty"); } @Test void constructorWhenEmptyListThenThrowsIllegalArgumentException() { assertThatIllegalArgumentException() - .isThrownBy(() -> new InetAddressMatchers.DenyListInetAddressMatcher(List.of())) - .withMessage("disallowList cannot be null or empty"); + .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().denyAddresses(List.of(addressString)).build(); + 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().denyAddresses(List.of("192.168.1.1")).build(); + InetAddressMatcher matcher = InetAddressMatchers.builder().excludeAddresses(List.of("192.168.1.1")).build(); assertThat(matcher.matches(InetAddress.getByName("192.168.1.2"))).isTrue(); } @@ -440,8 +442,8 @@ class CompositeInetAddressMatcherTests { @Test void matchesWhenAllMatchersTrueThenReturnsTrue() throws Exception { InetAddressMatcher matcher = InetAddressMatchers.builder() - .allowAddresses(List.of("192.168.1.0/24")) - .allowList((address) -> address.getHostAddress().endsWith(".1")) + .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(); } @@ -449,8 +451,8 @@ void matchesWhenAllMatchersTrueThenReturnsTrue() throws Exception { @Test void matchesWhenOneMatcherFalseThenReturnsFalse() throws Exception { InetAddressMatcher matcher = InetAddressMatchers.builder() - .allowAddresses(List.of("192.168.1.0/24")) - .allowList((address) -> address.getHostAddress().endsWith(".1")) + .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(); } @@ -459,7 +461,7 @@ void matchesWhenOneMatcherFalseThenReturnsFalse() throws Exception { @ValueSource(strings = { "192.168.1.1", "8.8.8.8" }) void matchesWhenReportOnlyThenAlwaysReturnsTrue(String testAddress) throws Exception { InetAddressMatcher matcher = InetAddressMatchers.builder() - .denyAddresses(List.of("192.168.1.1")) + .excludeAddresses(List.of("192.168.1.1")) .reportOnly() .build(); assertThat(matcher.matches(InetAddress.getByName(testAddress))).isTrue(); From 59984df9bb129243210b16a1becd3935c4cfee12 Mon Sep 17 00:00:00 2001 From: Robert Winch <362503+rwinch@users.noreply.github.com> Date: Tue, 3 Feb 2026 08:58:48 -0600 Subject: [PATCH 3/6] Remove unnecessary since tag --- .../security/web/util/matcher/InetAddressMatcher.java | 1 - 1 file changed, 1 deletion(-) 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 index 8c5fb7f70a8..ed88a3edb70 100644 --- 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 @@ -42,7 +42,6 @@ public interface InetAddressMatcher { * @param address the IP address string to check (may be {@code null}) * @return {@code true} if the address matches, {@code false} otherwise or if * {@code null} - * @since 7.1 */ default boolean matches(@Nullable String address) { return (address != null) ? matches(InetAddressParser.parseAddress(address)) : false; From 46120d2a4dcab0c03cbfc34c3f55e0241a35133a Mon Sep 17 00:00:00 2001 From: Robert Winch <362503+rwinch@users.noreply.github.com> Date: Tue, 3 Feb 2026 10:12:40 -0600 Subject: [PATCH 4/6] IpInetAddressMatcher Should Use Authors from IpAddressMatcher --- .../web/util/matcher/IpInetAddressMatcher.java | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) 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 index 982b5243959..70539e92656 100644 --- 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 @@ -32,12 +32,15 @@ *

* 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 Rossen Stoyanchev - * @author Gábor Vaspöri - * @author Kian Jamali - * @author Rob Winch + * @author Luke Taylor + * @author Steve Riesenberg + * @author Andrey Litvitski * @since 7.1 + * @see IpAddressMatcher */ final class IpInetAddressMatcher implements InetAddressMatcher { From d85df0c431c24b991780a9c110f0bb736034990e Mon Sep 17 00:00:00 2001 From: Robert Winch <362503+rwinch@users.noreply.github.com> Date: Tue, 3 Feb 2026 10:57:12 -0600 Subject: [PATCH 5/6] Update InetAddressMatcher.matches(InetAddress) to allow null This is more consistent with the matches(String) method and it allows APIs like ServerHttpRequest.getRemoteAddress() to be passed in without needing to check if the value is null. --- .../web/util/matcher/InetAddressMatcher.java | 7 +++---- .../web/util/matcher/InetAddressMatchers.java | 16 ++++++++++------ .../web/util/matcher/IpInetAddressMatcher.java | 6 +++++- .../util/matcher/InetAddressMatchersTests.java | 6 ++++++ .../util/matcher/IpInetAddressMatcherTests.java | 6 ++++++ 5 files changed, 30 insertions(+), 11 deletions(-) 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 index ed88a3edb70..93eae82b7f5 100644 --- 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 @@ -32,16 +32,15 @@ public interface InetAddressMatcher { /** * Whether the given address matches. - * @param address the {@link InetAddress} to check + * @param address the {@link InetAddress} to check (may be {@code null}) * @return {@code true} if the address matches, {@code false} otherwise */ - boolean matches(InetAddress address); + 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 or if - * {@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 index ac13321d997..65ff6b3b886 100644 --- 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 @@ -22,6 +22,7 @@ import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; +import org.jspecify.annotations.Nullable; import org.springframework.util.Assert; @@ -166,7 +167,7 @@ static final class IncludeListInetAddressMatcher implements InetAddressMatcher { } @Override - public boolean matches(InetAddress address) { + public boolean matches(@Nullable InetAddress address) { for (InetAddressMatcher matcher : this.includeList) { if (matcher.matches(address)) { return true; @@ -199,7 +200,7 @@ static final class ExcludeListInetAddressMatcher implements InetAddressMatcher { } @Override - public boolean matches(InetAddress address) { + public boolean matches(@Nullable InetAddress address) { for (InetAddressMatcher matcher : this.excludeList) { if (matcher.matches(address)) { return false; @@ -239,7 +240,10 @@ private InternalInetAddressMatcher() { } @Override - public boolean matches(InetAddress address) { + public boolean matches(@Nullable InetAddress address) { + if (address == null) { + return false; + } if (address.isLoopbackAddress()) { return true; } @@ -313,7 +317,7 @@ private ExternalInetAddressMatcher() { } @Override - public boolean matches(InetAddress address) { + public boolean matches(@Nullable InetAddress address) { return !this.internalMatcher.matches(address); } @@ -348,12 +352,12 @@ static final class CompositeInetAddressMatcher implements InetAddressMatcher { } @Override - public boolean matches(InetAddress address) { + public boolean matches(@Nullable InetAddress address) { boolean result = doMatch(address); return (this.reportOnly || result); } - private boolean doMatch(InetAddress address) { + private boolean doMatch(@Nullable InetAddress address) { for (InetAddressMatcher matcher : this.matchers) { if (!matcher.matches(address)) { if (logger.isDebugEnabled()) { 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 index 70539e92656..62ae9daf589 100644 --- 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 @@ -22,6 +22,7 @@ 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; @@ -84,7 +85,10 @@ private static InetAddress parse(String address) { } @Override - public boolean matches(InetAddress toCheck) { + public boolean matches(@Nullable InetAddress toCheck) { + if (toCheck == null) { + return false; + } if (this.nMaskBits < 0) { return toCheck.equals(this.requiredAddress); } 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 index 6d1fdd416c3..76aa9e28b9f 100644 --- 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 @@ -269,6 +269,12 @@ void matchesWhenAddressNotInListThenReturnsTrue() throws Exception { @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 { 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 index 6c64628a38b..e87f9a05bf5 100644 --- 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 @@ -129,4 +129,10 @@ void matchesWhenStringNullThenReturnsFalse() { assertThat(matcher.matches((String) null)).isFalse(); } + @Test + void matchesWhenInetAddressNullThenReturnsFalse() { + IpInetAddressMatcher matcher = new IpInetAddressMatcher("192.168.1.1"); + assertThat(matcher.matches((InetAddress) null)).isFalse(); + } + } From 65108382b50a955735d7dafee3137c86dcc0bd43 Mon Sep 17 00:00:00 2001 From: Robert Winch <362503+rwinch@users.noreply.github.com> Date: Tue, 3 Feb 2026 11:21:54 -0600 Subject: [PATCH 6/6] IpAddressServerWebExchangeMatcher uses InetAddressMatcher IpAddressServerWebExchangeMatcher was used for WebFlux applications which used IpAddressMatcher. This was likely problematic because IpAddressMatcher contains servlet APIs in it's method signatures. Even if those specific methods aren't used by IpAddressServerWebExchangeMatcher it can cause classpath issues for webflux applications. This commit switches to using InetAddressMatcher which does not contain any dependencies on the ServletAPI --- .../util/matcher/IpAddressServerWebExchangeMatcher.java | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) 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