From 7cd861170f003359d7de9b12dd072023f6b1326b Mon Sep 17 00:00:00 2001 From: Matt Pavlovich Date: Sat, 2 May 2026 11:35:06 -0500 Subject: [PATCH] [#1770] Feature: IP address allow/deny support --- .../org/apache/activemq/broker/Connector.java | 38 ++++ .../activemq/broker/TransportConnector.java | 124 ++++++++++ .../activemq/broker/jmx/ConnectorView.java | 55 +++++ .../broker/jmx/ConnectorViewMBean.java | 33 +++ .../transport/tcp/TcpTransportServer.java | 51 ++++- .../java/org/apache/activemq/util/Cidr.java | 130 +++++++++++ .../apache/activemq/util/CidrConverter.java | 111 +++++++++ .../apache/activemq/util/CidrListLoader.java | 208 +++++++++++++++++ .../activemq/util/RemoteAddressValidator.java | 196 ++++++++++++++++ .../activemq/util/CidrConverterTest.java | 130 +++++++++++ .../activemq/util/CidrListLoaderTest.java | 166 ++++++++++++++ .../util/RemoteAddressValidatorTest.java | 141 ++++++++++++ .../broker/TransportConnectorCidrTest.java | 215 ++++++++++++++++++ 13 files changed, 1596 insertions(+), 2 deletions(-) create mode 100644 activemq-client/src/main/java/org/apache/activemq/util/Cidr.java create mode 100644 activemq-client/src/main/java/org/apache/activemq/util/CidrConverter.java create mode 100644 activemq-client/src/main/java/org/apache/activemq/util/CidrListLoader.java create mode 100644 activemq-client/src/main/java/org/apache/activemq/util/RemoteAddressValidator.java create mode 100644 activemq-client/src/test/java/org/apache/activemq/util/CidrConverterTest.java create mode 100644 activemq-client/src/test/java/org/apache/activemq/util/CidrListLoaderTest.java create mode 100644 activemq-client/src/test/java/org/apache/activemq/util/RemoteAddressValidatorTest.java create mode 100644 activemq-unit-tests/src/test/java/org/apache/activemq/broker/TransportConnectorCidrTest.java diff --git a/activemq-broker/src/main/java/org/apache/activemq/broker/Connector.java b/activemq-broker/src/main/java/org/apache/activemq/broker/Connector.java index bf6fd7bec76..2772f92b4a0 100644 --- a/activemq-broker/src/main/java/org/apache/activemq/broker/Connector.java +++ b/activemq-broker/src/main/java/org/apache/activemq/broker/Connector.java @@ -86,6 +86,44 @@ public interface Connector extends Service { long getMaxConnectionExceededCount(); + /** @return the configured remote address allow list, comma separated CIDRs or a file: URI, or null */ + String getAllowList(); + + /** @return the configured remote address deny list, comma separated CIDRs or a file: URI, or null */ + String getDenyList(); + + /** @return true if remote addresses are checked against the allow and deny lists */ + boolean isAllowDenyValidationEnabled(); + + /** Turn the remote address check on or off at runtime; the lists stay configured */ + void setAllowDenyValidationEnabled(boolean enabled); + + /** @return connections accepted by the remote address check since the last statistics reset */ + long getAllowedCount(); + + /** @return connections refused by the remote address check since the last statistics reset */ + long getDeniedCount(); + + /** @return number of valid CIDR entries loaded into the allow list */ + long getAllowListCount(); + + /** @return number of valid CIDR entries loaded into the deny list */ + long getDenyListCount(); + + /** @return number of allow list entries skipped because they were not valid CIDR blocks */ + long getAllowListInvalidCount(); + + /** @return number of deny list entries skipped because they were not valid CIDR blocks */ + long getDenyListInvalidCount(); + + /** + * Runs an IP literal or a CIDR block through the connector's allow/deny + * decision, ignoring whether enforcement is enabled. An address gets the exact + * decision a connection would; a block is allowed when an allow entry covers it + * and no deny entry covers the whole block. + */ + boolean allowed(String addressOrCidr); + boolean isAutoStart(); /** diff --git a/activemq-broker/src/main/java/org/apache/activemq/broker/TransportConnector.java b/activemq-broker/src/main/java/org/apache/activemq/broker/TransportConnector.java index 938165195c2..b92d683e0d9 100644 --- a/activemq-broker/src/main/java/org/apache/activemq/broker/TransportConnector.java +++ b/activemq-broker/src/main/java/org/apache/activemq/broker/TransportConnector.java @@ -43,7 +43,10 @@ import org.apache.activemq.transport.TransportServer; import org.apache.activemq.transport.discovery.DiscoveryAgent; import org.apache.activemq.transport.discovery.DiscoveryAgentFactory; +import org.apache.activemq.transport.tcp.TcpTransportServer; +import org.apache.activemq.util.CidrListLoader; import org.apache.activemq.util.ExceptionUtils; +import org.apache.activemq.util.RemoteAddressValidator; import org.apache.activemq.util.ServiceStopper; import org.apache.activemq.util.ServiceSupport; import org.slf4j.Logger; @@ -80,6 +83,15 @@ public class TransportConnector implements Connector, BrokerServiceAware { private int maximumConsumersAllowedPerConnection = Integer.MAX_VALUE; private PublishedAddressPolicy publishedAddressPolicy = new PublishedAddressPolicy(); private boolean allowLinkStealing = false; + // remote address allow/deny lists; see setAllowList / setDenyList + private String allowList; + private String denyList; + private boolean allowDenyValidationEnabled = false; + private RemoteAddressValidator remoteAddressValidator; + private long allowListCount; + private long denyListCount; + private long allowListInvalidCount; + private long denyListInvalidCount; private boolean warnOnRemoteClose = false; private boolean displayStackTrace = false; private boolean autoStart = true; @@ -134,6 +146,9 @@ public ManagedTransportConnector asManagedConnector(ManagementContext context, O rc.setMaximumProducersAllowedPerConnection(getMaximumProducersAllowedPerConnection()); rc.setPublishedAddressPolicy(getPublishedAddressPolicy()); rc.setAllowLinkStealing(allowLinkStealing); + rc.setAllowList(allowList); + rc.setDenyList(denyList); + rc.setAllowDenyValidationEnabled(allowDenyValidationEnabled); rc.setWarnOnRemoteClose(isWarnOnRemoteClose()); rc.setAutoStart(isAutoStart()); return rc; @@ -268,6 +283,7 @@ private void onAcceptError(Exception error, String remoteHost) { } } }); + installRemoteAddressValidator(); getServer().setBrokerInfo(brokerInfo); getServer().start(); @@ -620,6 +636,104 @@ public void setAllowLinkStealing(boolean allowLinkStealing) { this.allowLinkStealing = allowLinkStealing; } + /** + * Loads the allow and deny lists, builds the validator and hands it to the + * transport server. Only socket based servers (tcp, nio, ssl, auto and the + * protocol variants built on them) can check remote addresses. + */ + private void installRemoteAddressValidator() throws Exception { + var allow = CidrListLoader.load(allowList, getName() + " allowList"); + var deny = CidrListLoader.load(denyList, getName() + " denyList"); + allowListCount = allow.cidrs().size(); + denyListCount = deny.cidrs().size(); + allowListInvalidCount = allow.invalidCount(); + denyListInvalidCount = deny.invalidCount(); + remoteAddressValidator = new RemoteAddressValidator(allow.cidrs(), deny.cidrs()); + remoteAddressValidator.setEnabled(allowDenyValidationEnabled); + var transportServer = getServer(); + if (transportServer instanceof TcpTransportServer) { + ((TcpTransportServer) transportServer).setRemoteAddressValidator(remoteAddressValidator); + } else if (allowList != null || denyList != null) { + LOG.warn("allowList/denyList configured on connector {} but its transport does not support remote address validation", getName()); + } + } + + @Override + public String getAllowList() { + return allowList; + } + + /** + * CIDR blocks that remote addresses must fall within to connect, as a comma + * separated list (e.g. {@code 10.0.0.0/8,192.168.1.0/24}) or a {@code file:} URI + * to a file with one CIDR block per line. {@code ${activemq.conf}} and + * {@code ${activemq.data}} may be used in the URI. Empty means no restriction + * beyond the deny list. + */ + public void setAllowList(String allowList) { + this.allowList = allowList; + } + + @Override + public String getDenyList() { + return denyList; + } + + /** + * CIDR blocks that are refused regardless of the allow list, in the same + * comma separated or {@code file:} URI form as the allow list. Deny entries + * are checked first. + */ + public void setDenyList(String denyList) { + this.denyList = denyList; + } + + @Override + public boolean isAllowDenyValidationEnabled() { + return allowDenyValidationEnabled; + } + + /** + * Turns the allow/deny check on or off without removing the lists. Default + * false, so configured lists take effect only when this is set; the default + * may change in a future major release. May be changed at runtime through JMX. + */ + @Override + public void setAllowDenyValidationEnabled(boolean allowDenyValidationEnabled) { + this.allowDenyValidationEnabled = allowDenyValidationEnabled; + var validator = remoteAddressValidator; + if (validator != null) { + validator.setEnabled(allowDenyValidationEnabled); + } + } + + @Override + public long getAllowListCount() { + return allowListCount; + } + + @Override + public long getDenyListCount() { + return denyListCount; + } + + @Override + public long getAllowListInvalidCount() { + return allowListInvalidCount; + } + + @Override + public long getDenyListInvalidCount() { + return denyListInvalidCount; + } + + @Override + public boolean allowed(String addressOrCidr) { + var validator = remoteAddressValidator; + // before start no lists are in effect, so nothing is refused + return validator == null || validator.isAllowed(addressOrCidr); + } + @Override public boolean isAuditNetworkProducers() { return auditNetworkProducers; @@ -693,6 +807,16 @@ public long getMaxConnectionExceededCount() { return (server != null ? server.getMaxConnectionExceededCount() : 0l); } + @Override + public long getAllowedCount() { + return server instanceof TcpTransportServer ? ((TcpTransportServer) server).getAllowedCount() : 0L; + } + + @Override + public long getDeniedCount() { + return server instanceof TcpTransportServer ? ((TcpTransportServer) server).getDeniedCount() : 0L; + } + @Override public boolean isStarted() { return started.get(); diff --git a/activemq-broker/src/main/java/org/apache/activemq/broker/jmx/ConnectorView.java b/activemq-broker/src/main/java/org/apache/activemq/broker/jmx/ConnectorView.java index acd00377d04..a34b05d8a1f 100644 --- a/activemq-broker/src/main/java/org/apache/activemq/broker/jmx/ConnectorView.java +++ b/activemq-broker/src/main/java/org/apache/activemq/broker/jmx/ConnectorView.java @@ -145,6 +145,61 @@ public long getMaxConnectionExceededCount() { return this.connector.getMaxConnectionExceededCount(); } + @Override + public String getAllowList() { + return this.connector.getAllowList(); + } + + @Override + public String getDenyList() { + return this.connector.getDenyList(); + } + + @Override + public boolean isAllowDenyValidationEnabled() { + return this.connector.isAllowDenyValidationEnabled(); + } + + @Override + public void setAllowDenyValidationEnabled(boolean enabled) { + this.connector.setAllowDenyValidationEnabled(enabled); + } + + @Override + public long getAllowedCount() { + return this.connector.getAllowedCount(); + } + + @Override + public long getDeniedCount() { + return this.connector.getDeniedCount(); + } + + @Override + public long getAllowListCount() { + return this.connector.getAllowListCount(); + } + + @Override + public long getDenyListCount() { + return this.connector.getDenyListCount(); + } + + @Override + public long getAllowListInvalidCount() { + return this.connector.getAllowListInvalidCount(); + } + + @Override + public long getDenyListInvalidCount() { + return this.connector.getDenyListInvalidCount(); + } + + @Override + public boolean allowed(String addressOrCidr) { + return this.connector.allowed(addressOrCidr); + } + @Override public boolean isAutoStart() { return this.connector.isAutoStart(); diff --git a/activemq-broker/src/main/java/org/apache/activemq/broker/jmx/ConnectorViewMBean.java b/activemq-broker/src/main/java/org/apache/activemq/broker/jmx/ConnectorViewMBean.java index 4d040225619..6b16c3888a1 100644 --- a/activemq-broker/src/main/java/org/apache/activemq/broker/jmx/ConnectorViewMBean.java +++ b/activemq-broker/src/main/java/org/apache/activemq/broker/jmx/ConnectorViewMBean.java @@ -95,6 +95,39 @@ public interface ConnectorViewMBean extends Service { @MBeanInfo("Max connection exceeded count") long getMaxConnectionExceededCount(); + @MBeanInfo("Remote address allow list: comma separated CIDR blocks or a file: URI") + String getAllowList(); + + @MBeanInfo("Remote address deny list: comma separated CIDR blocks or a file: URI") + String getDenyList(); + + @MBeanInfo("Remote address allow/deny validation enabled") + boolean isAllowDenyValidationEnabled(); + + @MBeanInfo("Enable or disable remote address allow/deny validation; the lists stay configured") + void setAllowDenyValidationEnabled(boolean enabled); + + @MBeanInfo("Connections accepted by the remote address check") + long getAllowedCount(); + + @MBeanInfo("Connections refused by the remote address check") + long getDeniedCount(); + + @MBeanInfo("Valid CIDR entries loaded into the allow list") + long getAllowListCount(); + + @MBeanInfo("Valid CIDR entries loaded into the deny list") + long getDenyListCount(); + + @MBeanInfo("Allow list entries skipped as invalid") + long getAllowListInvalidCount(); + + @MBeanInfo("Deny list entries skipped as invalid") + long getDenyListInvalidCount(); + + @MBeanInfo("Would the IP address, or the CIDR block as a whole, be allowed by the allow/deny lists (enabled flag ignored)") + boolean allowed(@MBeanInfo("addressOrCidr") String addressOrCidr); + /** * @return true if transport connector auto start is enabled */ diff --git a/activemq-client/src/main/java/org/apache/activemq/transport/tcp/TcpTransportServer.java b/activemq-client/src/main/java/org/apache/activemq/transport/tcp/TcpTransportServer.java index 32cc6261c25..a446384c258 100644 --- a/activemq-client/src/main/java/org/apache/activemq/transport/tcp/TcpTransportServer.java +++ b/activemq-client/src/main/java/org/apache/activemq/transport/tcp/TcpTransportServer.java @@ -56,6 +56,7 @@ import org.apache.activemq.util.IOExceptionSupport; import org.apache.activemq.util.InetAddressUtil; import org.apache.activemq.util.IntrospectionSupport; +import org.apache.activemq.util.RemoteAddressValidator; import org.apache.activemq.util.ServiceListener; import org.apache.activemq.util.ServiceStopper; import org.apache.activemq.util.ServiceSupport; @@ -124,7 +125,15 @@ public class TcpTransportServer extends TransportServerThreadSupport implements * The maximum number of sockets allowed for this server */ protected int maximumConnections = Integer.MAX_VALUE; - protected final AtomicLong maximumConnectionsExceededCount = new AtomicLong(0l); + protected final AtomicLong maximumConnectionsExceededCount = new AtomicLong(0L); + + /** + * Optional allow/deny check applied to the remote address of every accepted + * socket before any other processing. Null means no check. + */ + protected volatile RemoteAddressValidator remoteAddressValidator; + protected final AtomicLong allowedCount = new AtomicLong(0L); + protected final AtomicLong deniedCount = new AtomicLong(0L); protected final AtomicInteger currentTransportCount = new AtomicInteger(); public TcpTransportServer(TcpTransportFactory transportFactory, URI location, ServerSocketFactory serverSocketFactory) throws IOException, @@ -577,6 +586,24 @@ final protected void doHandleSocket(Socket socket) { boolean closeSocket = true; boolean countIncremented = false; try { + + // A refused remote address is policy at work, not an error: count it, close the + // socket without writing anything, and stay out of the accept error path. One + // line per attempt is kept at DEBUG so a scanner cannot flood the log. + var validator = remoteAddressValidator; + if (validator != null && validator.isEnabled()) { + if (!validator.isAllowed(socket)) { + deniedCount.incrementAndGet(); + LOG.debug("Refused connection from {}: remote address not allowed", socket.getRemoteSocketAddress()); + try { + socket.close(); + } catch (IOException ignore) { + } + return; + } + allowedCount.incrementAndGet(); + } + int currentCount; do { currentCount = currentTransportCount.get(); @@ -735,8 +762,28 @@ public long getMaxConnectionExceededCount() { return this.maximumConnectionsExceededCount.get(); } + public RemoteAddressValidator getRemoteAddressValidator() { + return remoteAddressValidator; + } + + public void setRemoteAddressValidator(RemoteAddressValidator remoteAddressValidator) { + this.remoteAddressValidator = remoteAddressValidator; + } + + /** connections accepted by the remote address check since the last reset */ + public long getAllowedCount() { + return allowedCount.get(); + } + + /** connections refused by the remote address check since the last reset */ + public long getDeniedCount() { + return deniedCount.get(); + } + @Override public void resetStatistics() { - this.maximumConnectionsExceededCount.set(0l); + this.maximumConnectionsExceededCount.set(0L); + this.allowedCount.set(0L); + this.deniedCount.set(0L); } } diff --git a/activemq-client/src/main/java/org/apache/activemq/util/Cidr.java b/activemq-client/src/main/java/org/apache/activemq/util/Cidr.java new file mode 100644 index 00000000000..9f2fce51169 --- /dev/null +++ b/activemq-client/src/main/java/org/apache/activemq/util/Cidr.java @@ -0,0 +1,130 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 + * + * http://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.apache.activemq.util; + +import java.net.InetAddress; +import java.util.Arrays; +import java.util.Objects; + +/** + * An IPv4 or IPv6 network block in CIDR notation. + * + *

Deliberately a plain class rather than a record: the activemq-spring schema + * generator parses this module's sources with QDox 1.x, which does not understand + * record declarations and fails the activemq-spring build on one. + */ +public final class Cidr { + + private final String cidr; + private final byte[] networkAddress; + private final byte[] mask; + + public Cidr(String cidr, byte[] networkAddress, byte[] mask) { + this.cidr = cidr; + this.networkAddress = networkAddress; + this.mask = mask; + } + + /** the block as written, e.g. {@code 192.168.1.0/24} */ + public String cidr() { + return cidr; + } + + /** the network address with the mask applied */ + public byte[] networkAddress() { + return networkAddress; + } + + public byte[] mask() { + return mask; + } + + /** number of leading one bits in the mask, i.e. the prefix length after the slash */ + public int prefixLength() { + var bits = 0; + for (var b : mask) { + bits += Integer.bitCount(b & 0xFF); + } + return bits; + } + + /** Returns {@code true} if every address of {@code other} falls within this block. */ + boolean covers(final Cidr other) { + if (other.networkAddress.length != networkAddress.length || other.prefixLength() < prefixLength()) { + return false; + } + return Arrays.equals(applyMask(other.networkAddress, mask), networkAddress); + } + + /** Returns {@code true} if {@code address} falls within this block. */ + boolean contains(final InetAddress address) { + var raw = address.getAddress(); + if (raw.length != networkAddress.length) { + // IPv4 vs IPv6 mismatch — never match. + return false; + } + var masked = applyMask(raw, mask); + return Arrays.equals(masked, networkAddress); + } + + static byte[] buildMask(final int byteLen, final int prefixBits) { + var m = new byte[byteLen]; + for (var i = 0; i < byteLen; i++) { + var bitsLeft = prefixBits - i * 8; + if (bitsLeft >= 8) { + m[i] = (byte) 0xFF; + } else if (bitsLeft > 0) { + m[i] = (byte) (0xFF & (0xFF << (8 - bitsLeft))); + } else { + m[i] = 0; + } + } + return m; + } + + static byte[] applyMask(final byte[] addr, final byte[] mask) { + var result = new byte[addr.length]; + for (var i = 0; i < addr.length; i++) { + result[i] = (byte) (addr[i] & mask[i]); + } + return result; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof Cidr)) { + return false; + } + var that = (Cidr) o; + return Objects.equals(cidr, that.cidr) + && Arrays.equals(networkAddress, that.networkAddress) + && Arrays.equals(mask, that.mask); + } + + @Override + public int hashCode() { + return 31 * (31 * Objects.hashCode(cidr) + Arrays.hashCode(networkAddress)) + Arrays.hashCode(mask); + } + + @Override + public String toString() { + return cidr; + } +} diff --git a/activemq-client/src/main/java/org/apache/activemq/util/CidrConverter.java b/activemq-client/src/main/java/org/apache/activemq/util/CidrConverter.java new file mode 100644 index 00000000000..69899be454f --- /dev/null +++ b/activemq-client/src/main/java/org/apache/activemq/util/CidrConverter.java @@ -0,0 +1,111 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 + * + * http://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.apache.activemq.util; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +public class CidrConverter { + + private static final Logger logger = LoggerFactory.getLogger(CidrConverter.class); + + public static List parseCidrStrings(final List cidrStrings, final String locationHint) { + if (cidrStrings == null || cidrStrings.isEmpty()) { + return Collections.emptyList(); + } + + var cidrs = new ArrayList(cidrStrings.size()); + for (var cidrString : cidrStrings) { + var trimmedCidrString = cidrString.trim(); + + if (trimmedCidrString.isBlank()) { + continue; + } + + try { + cidrs.add(CidrConverter.fromString(trimmedCidrString)); + } catch (IllegalArgumentException e) { + logger.warn("Invalid CIDR string:{} from:{}", trimmedCidrString, locationHint); + } + } + return Collections.unmodifiableList(cidrs); + } + + public static Cidr fromString(String cidr) { + int slash = cidr.lastIndexOf('/'); + if (slash < 0) { + throw new IllegalArgumentException("Invalid CIDR (missing '/'): " + cidr); + } + + String ipPart = cidr.substring(0, slash); + String prefixPart = cidr.substring(slash + 1); + + InetAddress base; + try { + base = parseIpLiteral(ipPart); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Invalid IP in CIDR: " + cidr, e); + } + + var mask = parseMask(cidr, base, prefixPart); + var networkAddress = Cidr.applyMask(base.getAddress(), mask); + return new Cidr(cidr, networkAddress, mask); + } + + /** + * Parses an IPv4 or IPv6 literal. Host names are rejected rather than resolved, + * so configuration and JMX input never trigger a DNS lookup. + * + * @throws IllegalArgumentException if the value is not an IP literal + */ + public static InetAddress parseIpLiteral(String value) { + var trimmed = value == null ? "" : value.trim(); + var looksLikeIpv4 = trimmed.matches("\\d{1,3}(\\.\\d{1,3}){3}"); + var looksLikeIpv6 = trimmed.indexOf(':') >= 0 && trimmed.matches("[0-9a-fA-F:.]+(%[0-9a-zA-Z]+)?"); + if (!looksLikeIpv4 && !looksLikeIpv6) { + throw new IllegalArgumentException("Not an IP address literal: " + value); + } + try { + // a literal never triggers a lookup + return InetAddress.getByName(trimmed); + } catch (UnknownHostException e) { + throw new IllegalArgumentException("Not an IP address literal: " + value, e); + } + } + + private static byte[] parseMask(String cidr, InetAddress base, String prefixPart) { + int totalBits = base.getAddress().length * 8; // 32 or 128 + int prefix; + try { + prefix = Integer.parseInt(prefixPart); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("Invalid prefix length in CIDR: " + cidr, e); + } + if (prefix < 0 || prefix > totalBits) { + throw new IllegalArgumentException("Prefix length out of range [0," + totalBits + "]: " + cidr); + } + + var mask = Cidr.buildMask(totalBits / 8, prefix); + return mask; + } +} diff --git a/activemq-client/src/main/java/org/apache/activemq/util/CidrListLoader.java b/activemq-client/src/main/java/org/apache/activemq/util/CidrListLoader.java new file mode 100644 index 00000000000..d68a453dc60 --- /dev/null +++ b/activemq-client/src/main/java/org/apache/activemq/util/CidrListLoader.java @@ -0,0 +1,208 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 + * + * http://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.apache.activemq.util; + +import java.io.BufferedReader; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Loads a CIDR allow or deny list from a transport connector setting. The value + * is either a comma separated list of CIDR blocks or a {@code file:} URI to a + * text file with one CIDR block per line ({@code #} starts a comment). Only the + * {@code file} scheme is accepted, and the URI may use the {@code ${activemq.conf}} + * and {@code ${activemq.data}} macros, which expand from system properties. + * + *

Malformed CIDR entries are logged and skipped, and their number is reported + * on the result. Anything wrong with the file reference itself (scheme, path + * traversal, missing file, more than {@value #MAX_ENTRIES} entries or more than + * {@value #MAX_FILE_BYTES} bytes) is a configuration error and throws. + */ +public final class CidrListLoader { + + private static final Logger LOG = LoggerFactory.getLogger(CidrListLoader.class); + + public static final int MAX_ENTRIES = 100_000; + public static final long MAX_FILE_BYTES = 10L * 1024 * 1024; + + private static final String FILE_PREFIX = "file:"; + private static final Map MACROS = Map.of( + "${activemq.conf}", "activemq.conf", + "${activemq.data}", "activemq.data"); + + private CidrListLoader() { + } + + /** The parsed entries of one list plus the number of entries that were skipped. */ + public static final class CidrList { + public static final CidrList EMPTY = new CidrList(Collections.emptyList(), 0); + + private final List cidrs; + private final int invalidCount; + + CidrList(List cidrs, int invalidCount) { + this.cidrs = Collections.unmodifiableList(cidrs); + this.invalidCount = invalidCount; + } + + public List cidrs() { + return cidrs; + } + + public int invalidCount() { + return invalidCount; + } + } + + /** + * @param value comma separated CIDR blocks, a {@code file:} URI, or null/blank for none + * @param locationHint names the setting in log messages, e.g. "openwire allowList" + */ + public static CidrList load(String value, String locationHint) { + if (value == null || value.isBlank()) { + return CidrList.EMPTY; + } + var trimmed = value.trim(); + if (trimmed.regionMatches(true, 0, FILE_PREFIX, 0, FILE_PREFIX.length())) { + return loadFile(trimmed, locationHint); + } + // "://" never appears in a CIDR block (IPv6 uses "::/"), so this is some other URI scheme + if (trimmed.contains("://")) { + throw new IllegalArgumentException("Only file: URIs are supported for CIDR lists (" + locationHint + "): " + trimmed); + } + return parse(List.of(trimmed.split(",")), locationHint); + } + + static CidrList loadFile(String fileUri, String locationHint) { + var path = resolveFile(fileUri); + try { + var size = Files.size(path); + if (size > MAX_FILE_BYTES) { + throw new IllegalArgumentException("CIDR list file " + path + " is " + size + + " bytes, larger than the " + MAX_FILE_BYTES + " byte limit (" + locationHint + ")"); + } + var entries = new ArrayList(); + try (BufferedReader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) { + String line; + while ((line = reader.readLine()) != null) { + var comment = line.indexOf('#'); + var entry = (comment >= 0 ? line.substring(0, comment) : line).trim(); + if (entry.isEmpty()) { + continue; + } + if (entries.size() >= MAX_ENTRIES) { + throw new IllegalArgumentException("CIDR list file " + path + " has more than " + + MAX_ENTRIES + " entries (" + locationHint + ")"); + } + entries.add(entry); + } + } + return parse(entries, locationHint + " (" + path + ")"); + } catch (IOException e) { + throw new IllegalArgumentException("Cannot read CIDR list file " + path + " (" + locationHint + ")", e); + } + } + + /** + * Expands the permitted macros, insists on the {@code file} scheme with no + * authority, query or fragment, refuses any {@code ..} segment, and when a + * macro was used requires the file to stay under that macro's directory. + */ + static Path resolveFile(String fileUri) { + Path macroRoot = null; + var expanded = fileUri; + var start = expanded.indexOf("${"); + while (start >= 0) { + var end = expanded.indexOf('}', start); + if (end < 0) { + throw new IllegalArgumentException("Unterminated macro in CIDR list location: " + fileUri); + } + var macro = expanded.substring(start, end + 1); + var property = MACROS.get(macro); + if (property == null) { + throw new IllegalArgumentException("Unsupported macro " + macro + " in CIDR list location: " + fileUri + + " (only " + MACROS.keySet() + " are permitted)"); + } + var replacement = System.getProperty(property); + if (replacement == null || replacement.isBlank()) { + throw new IllegalArgumentException("System property " + property + " is not set, needed by CIDR list location: " + fileUri); + } + if (macroRoot == null) { + macroRoot = Path.of(replacement).toAbsolutePath().normalize(); + } + expanded = expanded.substring(0, start) + replacement + expanded.substring(end + 1); + start = expanded.indexOf("${"); + } + + if (!expanded.regionMatches(true, 0, FILE_PREFIX, 0, FILE_PREFIX.length())) { + throw new IllegalArgumentException("Only file: URIs are supported for CIDR lists: " + fileUri); + } + var pathPart = expanded.substring(FILE_PREFIX.length()); + if (pathPart.startsWith("//")) { + // file:///path is fine, file://host/path is not + if (pathPart.length() < 3 || pathPart.charAt(2) != '/') { + throw new IllegalArgumentException("file: URI with an authority is not supported for CIDR lists: " + fileUri); + } + pathPart = pathPart.substring(2); + } + if (pathPart.indexOf('?') >= 0 || pathPart.indexOf('#') >= 0) { + throw new IllegalArgumentException("file: URI with a query or fragment is not supported for CIDR lists: " + fileUri); + } + for (var segment : pathPart.split("[/\\\\]")) { + if ("..".equals(segment)) { + throw new IllegalArgumentException("CIDR list location must not contain '..': " + fileUri); + } + } + + var path = Path.of(pathPart).toAbsolutePath().normalize(); + if (macroRoot != null && !path.startsWith(macroRoot)) { + throw new IllegalArgumentException("CIDR list location escapes its macro directory " + macroRoot + ": " + fileUri); + } + if (!Files.isRegularFile(path) || !Files.isReadable(path)) { + throw new IllegalArgumentException("CIDR list file is not a readable regular file: " + path); + } + return path; + } + + /** Parses entries one at a time so skipped entries can be counted; each failure logs a WARN. */ + static CidrList parse(List entries, String locationHint) { + var cidrs = new ArrayList(entries.size()); + var invalid = 0; + for (var entry : entries) { + var trimmed = entry.trim(); + if (trimmed.isEmpty()) { + continue; + } + try { + cidrs.add(CidrConverter.fromString(trimmed)); + } catch (IllegalArgumentException e) { + invalid++; + LOG.warn("Invalid CIDR string:{} from:{}", trimmed, locationHint); + } + } + return new CidrList(cidrs, invalid); + } +} diff --git a/activemq-client/src/main/java/org/apache/activemq/util/RemoteAddressValidator.java b/activemq-client/src/main/java/org/apache/activemq/util/RemoteAddressValidator.java new file mode 100644 index 00000000000..e07a8e91ab1 --- /dev/null +++ b/activemq-client/src/main/java/org/apache/activemq/util/RemoteAddressValidator.java @@ -0,0 +1,196 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 + * + * http://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.apache.activemq.util; + +import java.net.Inet4Address; +import java.net.Inet6Address; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.net.UnknownHostException; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Validates a socket's remote IP address against configurable allow and deny + * lists expressed in CIDR notation (e.g. "192.168.1.0/24", "10.0.0.0/8"). + * + *

Evaluation order: + *

    + *
  1. If the address matches any deny entry → DENIED.
  2. + *
  3. If the allow list is non-empty and the address matches any allow + * entry → ALLOWED.
  4. + *
  5. If the allow list is empty (no restrictions) → ALLOWED.
  6. + *
  7. Otherwise → DENIED (not in allow list).
  8. + *
+ * + *

Both IPv4 and IPv6 addresses are supported. IPv4-mapped IPv6 addresses + * (e.g. {@code ::ffff:192.168.1.1}) are automatically normalized to their + * IPv4 equivalent before matching, so a single IPv4 CIDR rule covers both + * plain IPv4 connections and dual-stack sockets that represent the same peer. + * + *

Build the lists with {@link CidrListLoader}, which accepts a comma separated + * string or a {@code file:} URI and reports entries it had to skip. + */ +public class RemoteAddressValidator { + + private final List allowList; + private final List denyList; + private final AtomicBoolean enabled = new AtomicBoolean(true); + + /** + * @param allowCidrs CIDR strings that are explicitly permitted. + * Pass an empty list to permit all addresses. + * @param denyCidrs CIDR strings that are explicitly forbidden. + * Deny rules are evaluated before allow rules. + */ + public RemoteAddressValidator(final List allowCidrs, final List denyCidrs) { + this.allowList = Collections.unmodifiableList(allowCidrs); + this.denyList = Collections.unmodifiableList(denyCidrs); + } + + public boolean isEnabled() { + return enabled.get(); + } + + public void setEnabled(boolean enabled) { + this.enabled.set(enabled); + } + + /** + * Returns {@code true} if the remote address of the given socket is allowed. + * + * @throws IllegalArgumentException if the socket is not connected or has + * no remote address. + */ + public boolean isAllowed(Socket socket) { + if (socket == null) { + return false; + } + var remote = socket.getRemoteSocketAddress(); + if (!(remote instanceof InetSocketAddress)) { + return false; + } + return isAllowed(((InetSocketAddress) remote).getAddress()); + } + + /** + * Returns {@code true} if the given {@link InetAddress} passes the + * allow/deny policy. + */ + public boolean isAllowed(InetAddress address) { + if (address == null) { + return false; + } + + // Normalise IPv4-mapped IPv6 addresses (::ffff:a.b.c.d) to plain IPv4 + // so that a dual-stack socket still matches IPv4 CIDR rules. + address = normalise(address); + + // 1. Deny list takes priority. + for (var block : denyList) { + if (block.contains(address)) { + return false; + } + } + + // 2. Allow list (empty = allow everything not denied). + if (allowList.isEmpty()) { + return true; + } + + for (var block : allowList) { + if (block.contains(address)) { + return true; + } + } + + // 3. Not in any allow entry. + return false; + } + + /** + * Runs a single IP address or a whole CIDR block through the allow/deny + * decision. A single address gets exactly the decision an accepted socket + * would: deny entries first, then the allow list, an empty allow list + * permitting, an IPv4-mapped IPv6 address normalised first. A block is judged + * as a whole: it is allowed when an allow entry covers it (or the allow list is + * empty) and no deny entry covers the entire block, so a few denied hosts + * carved out of an allowed network do not change its answer. The enabled flag + * is ignored so lists can be checked before enforcement is switched on. Host + * names are rejected, never resolved. + * + * @throws IllegalArgumentException if the value is neither an IP literal nor a CIDR block + */ + public boolean isAllowed(String addressOrCidr) { + if (addressOrCidr == null || addressOrCidr.isBlank()) { + throw new IllegalArgumentException("an IP address or CIDR block is required"); + } + var value = addressOrCidr.trim(); + if (value.indexOf('/') < 0) { + return isAllowed(CidrConverter.parseIpLiteral(value)); + } + var block = CidrConverter.fromString(value); + for (var entry : denyList) { + if (entry.covers(block)) { + return false; + } + } + if (allowList.isEmpty()) { + return true; + } + for (var entry : allowList) { + if (entry.covers(block)) { + return true; + } + } + return false; + } + + /** + * If {@code addr} is an IPv4-mapped IPv6 address ({@code ::ffff:a.b.c.d}), + * returns the equivalent {@link Inet4Address}; otherwise returns {@code addr} + * unchanged. + * + *

The JDK does not expose a public API for this, so we detect the + * 16-byte pattern {@code [0,0,0,0, 0,0,0,0, 0,0,0xFF,0xFF, a,b,c,d]} + * manually and reconstruct the IPv4 address from the last four bytes. + */ + static InetAddress normalise(final InetAddress addr) { + if (!(addr instanceof Inet6Address)) { + return addr; + } + var raw = addr.getAddress(); // always 16 bytes for Inet6Address + // Check for the ::ffff:0:0/96 prefix (bytes 0-9 = 0x00, bytes 10-11 = 0xFF) + for (var i = 0; i < 10; i++) { + if (raw[i] != 0) return addr; + } + if ((raw[10] & 0xFF) != 0xFF || (raw[11] & 0xFF) != 0xFF) { + return addr; + } + // Extract the embedded IPv4 address from the last 4 bytes. + var ipv4 = new byte[]{raw[12], raw[13], raw[14], raw[15]}; + try { + return InetAddress.getByAddress(ipv4); + } catch (UnknownHostException e) { + // Should never happen for a 4-byte array; return original if it does. + return addr; + } + } + +} diff --git a/activemq-client/src/test/java/org/apache/activemq/util/CidrConverterTest.java b/activemq-client/src/test/java/org/apache/activemq/util/CidrConverterTest.java new file mode 100644 index 00000000000..21a677b3d23 --- /dev/null +++ b/activemq-client/src/test/java/org/apache/activemq/util/CidrConverterTest.java @@ -0,0 +1,130 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 + * + * http://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.apache.activemq.util; + +import org.junit.Test; + +import java.util.List; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +public class CidrConverterTest { + + @Test + public void testHostNamesAreNotResolved() { + // a CIDR must start with an IP literal; a host name is a configuration error, never a DNS lookup + assertThrows(IllegalArgumentException.class, () -> CidrConverter.fromString("localhost/32")); + assertThrows(IllegalArgumentException.class, () -> CidrConverter.fromString("example.com/24")); + assertEquals(0, CidrConverter.parseCidrStrings(java.util.List.of("example.com/24"), "test").size()); + assertNotNull(CidrConverter.parseIpLiteral("192.168.1.1")); + assertNotNull(CidrConverter.parseIpLiteral("2001:db8::1")); + assertThrows(IllegalArgumentException.class, () -> CidrConverter.parseIpLiteral("localhost")); + } + + @Test + public void fromStringValid() { + var cidr = CidrConverter.fromString("192.168.1.1/32"); + assertEquals("192.168.1.1/32", cidr.cidr()); + assertArrayEquals(new byte[]{-1, -1, -1, -1}, cidr.mask()); + assertArrayEquals(new byte[]{-64, -88, 1, 1}, cidr.networkAddress()); + + cidr = CidrConverter.fromString("192.168.1.0/24"); + assertEquals("192.168.1.0/24", cidr.cidr()); + assertArrayEquals(new byte[]{-1, -1, -1, 0}, cidr.mask()); + assertArrayEquals(new byte[]{-64, -88, 1, 0}, cidr.networkAddress()); + + cidr = CidrConverter.fromString("192.168.0.0/16"); + assertEquals("192.168.0.0/16", cidr.cidr()); + assertArrayEquals(new byte[]{-1, -1, 0, 0}, cidr.mask()); + assertArrayEquals(new byte[]{-64, -88, 0, 0}, cidr.networkAddress()); + + cidr = CidrConverter.fromString("192.0.0.0/8"); + assertEquals("192.0.0.0/8", cidr.cidr()); + assertArrayEquals(new byte[]{-1, 0, 0, 0}, cidr.mask()); + assertArrayEquals(new byte[]{-64, 0, 0, 0}, cidr.networkAddress()); + + cidr = CidrConverter.fromString("0.0.0.0/0"); + assertEquals("0.0.0.0/0", cidr.cidr()); + assertArrayEquals(new byte[]{0, 0, 0, 0}, cidr.mask()); + assertArrayEquals(new byte[]{0, 0, 0, 0}, cidr.networkAddress()); + + cidr = CidrConverter.fromString("127.0.0.1/32"); + assertEquals("127.0.0.1/32", cidr.cidr()); + assertArrayEquals(new byte[]{-1, -1, -1, -1}, cidr.mask()); + assertArrayEquals(new byte[]{127, 0, 0, 1}, cidr.networkAddress()); + } + + @Test + public void fromStringInvalid() { + var invalidInputs = new String[]{"not-an-ip/16", "1.2.3.4/-32", "1.2.3.4/33", "1.2.3.4/a", "1.2.3.4", "invalid3", "", "256.3.5.2/32"}; + + for (var input : invalidInputs) { + assertThrows(IllegalArgumentException.class, () -> { + CidrConverter.fromString(input); + }); + } + } + + @Test + public void parseCidrStrings() { + var cidrs = CidrConverter.parseCidrStrings(null, "empty.txt"); + assertNotNull(cidrs); + assertTrue(cidrs.isEmpty()); + + cidrs = CidrConverter.parseCidrStrings(List.of(), "empty.txt"); + assertNotNull(cidrs); + assertTrue(cidrs.isEmpty()); + + cidrs = CidrConverter.parseCidrStrings(List.of(""), "empty.txt"); + assertNotNull(cidrs); + assertTrue(cidrs.isEmpty()); + + cidrs = CidrConverter.parseCidrStrings(List.of("192.168.1.1/32", "192.168.2.0/24", "10.10.0.0/16", "11.0.0.0/8"), "four.txt"); + assertNotNull(cidrs); + assertFalse(cidrs.isEmpty()); + assertEquals(Integer.valueOf(4), Integer.valueOf(cidrs.size())); + + cidrs = CidrConverter.parseCidrStrings(List.of("192.168.1.1/32", "192.168.2.0/24", "10.10.0.0/16", "1.2.3.4/-32"), "four-one-invalid.txt"); + assertNotNull(cidrs); + assertFalse(cidrs.isEmpty()); + assertEquals(Integer.valueOf(3), Integer.valueOf(cidrs.size())); + } +// {"192.168.1.50", "ALLOW — in allow /24, not denied"}, +// {"192.168.1.100", "DENY — explicit deny /32"}, +// {"10.5.6.7", "ALLOW — in allow 10/8, not denied"}, +// {"10.0.0.1", "DENY — explicit deny /32"}, +// {"10.255.255.99", "DENY — in denied /24 subnet"}, +// {"172.16.5.1", "ALLOW — in allow 172.16/12"}, +// {"8.8.8.8", "DENY — not in any allow entry"}, +// // ---- IPv6 ---- +// {"::1", "ALLOW — loopback in allow list"}, +// {"2001:db8::1", "ALLOW — in allow 2001:db8::/32"}, +// {"2001:db8:bad::1", "DENY — in deny 2001:db8:bad::/48"}, +// {"fe80::1", "ALLOW — link-local in allow fe80::/10"}, +// {"2001:db9::1", "DENY — not in any allow entry"}, +// // ---- IPv4-mapped IPv6 (dual-stack sockets) ---- +// {"::ffff:192.168.1.50", "ALLOW — mapped; normalised to 192.168.1.50"}, +// {"::ffff:192.168.1.100","DENY — mapped; normalised, hits deny /32"}, +// {"::ffff:8.8.8.8", "DENY — mapped; normalised, not in allow"}, +// }; + +} diff --git a/activemq-client/src/test/java/org/apache/activemq/util/CidrListLoaderTest.java b/activemq-client/src/test/java/org/apache/activemq/util/CidrListLoaderTest.java new file mode 100644 index 00000000000..139acfdc232 --- /dev/null +++ b/activemq-client/src/test/java/org/apache/activemq/util/CidrListLoaderTest.java @@ -0,0 +1,166 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 + * + * http://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.apache.activemq.util; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.io.RandomAccessFile; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +public class CidrListLoaderTest { + + private Path dir; + private String savedConf; + + @Before + public void setUp() throws Exception { + dir = Files.createDirectories(Path.of("target", "cidr-list-loader-test", Long.toString(System.nanoTime()))); + savedConf = System.getProperty("activemq.conf"); + } + + @After + public void tearDown() { + if (savedConf == null) { + System.clearProperty("activemq.conf"); + } else { + System.setProperty("activemq.conf", savedConf); + } + } + + private Path write(String name, String content) throws Exception { + return Files.writeString(dir.resolve(name), content, StandardCharsets.UTF_8); + } + + private static String uri(Path path) { + return "file:" + path.toAbsolutePath(); + } + + @Test + public void testCommaSeparatedValueCountsInvalidEntries() { + var list = CidrListLoader.load("10.0.0.0/8, 192.168.1.0/24 ,bogus,,", "test"); + assertEquals(2, list.cidrs().size()); + assertEquals(1, list.invalidCount()); + assertEquals("10.0.0.0/8", list.cidrs().get(0).cidr()); + } + + @Test + public void testNullOrBlankIsEmpty() { + assertTrue(CidrListLoader.load(null, "test").cidrs().isEmpty()); + assertTrue(CidrListLoader.load(" ", "test").cidrs().isEmpty()); + assertEquals(0, CidrListLoader.load("", "test").invalidCount()); + } + + @Test + public void testFileWithCommentsBlanksAndOneBadLine() throws Exception { + var file = write("allow.txt", "# leading comment\n\n10.0.0.0/8 # trailing comment\n192.168.1.0/24\nnot-a-cidr\n \n2001:db8::/32\n"); + var list = CidrListLoader.load(uri(file), "test"); + assertEquals(3, list.cidrs().size()); + assertEquals(1, list.invalidCount()); + } + + @Test + public void testFileUriWithTripleSlash() throws Exception { + var file = write("deny.txt", "10.0.0.0/8\n"); + var list = CidrListLoader.load("file://" + file.toAbsolutePath(), "test"); + assertEquals(1, list.cidrs().size()); + } + + @Test + public void testMacroExpansion() throws Exception { + System.setProperty("activemq.conf", dir.toAbsolutePath().toString()); + write("allow.txt", "10.0.0.0/8\n172.16.0.0/12\n"); + var list = CidrListLoader.load("file:${activemq.conf}/allow.txt", "test"); + assertEquals(2, list.cidrs().size()); + } + + @Test + public void testUnsupportedMacroRejected() { + var e = assertThrows(IllegalArgumentException.class, + () -> CidrListLoader.load("file:${user.home}/allow.txt", "test")); + assertTrue(e.getMessage(), e.getMessage().contains("Unsupported macro")); + } + + @Test + public void testUnsetMacroPropertyRejected() { + System.clearProperty("activemq.conf"); + assertThrows(IllegalArgumentException.class, + () -> CidrListLoader.load("file:${activemq.conf}/allow.txt", "test")); + } + + @Test + public void testOnlyFileSchemeAccepted() throws Exception { + assertThrows(IllegalArgumentException.class, () -> CidrListLoader.load("http://example.com/allow.txt", "test")); + // an unknown scheme without a slash is treated as a CIDR list and simply yields invalid entries + var list = CidrListLoader.load("classpath:allow.txt", "test"); + assertEquals(1, list.invalidCount()); + } + + @Test + public void testAuthorityQueryAndFragmentRejected() throws Exception { + var file = write("allow.txt", "10.0.0.0/8\n"); + assertThrows(IllegalArgumentException.class, () -> CidrListLoader.load("file://remotehost" + file.toAbsolutePath(), "test")); + assertThrows(IllegalArgumentException.class, () -> CidrListLoader.load(uri(file) + "?x=1", "test")); + assertThrows(IllegalArgumentException.class, () -> CidrListLoader.load(uri(file) + "#frag", "test")); + } + + @Test + public void testParentSegmentRejected() throws Exception { + write("allow.txt", "10.0.0.0/8\n"); + var inner = Files.createDirectories(dir.resolve("inner")); + System.setProperty("activemq.conf", inner.toAbsolutePath().toString()); + assertThrows(IllegalArgumentException.class, + () -> CidrListLoader.load("file:${activemq.conf}/../allow.txt", "test")); + assertThrows(IllegalArgumentException.class, + () -> CidrListLoader.load(uri(inner) + "/../allow.txt", "test")); + } + + @Test + public void testMissingFileRejected() { + var e = assertThrows(IllegalArgumentException.class, + () -> CidrListLoader.load(uri(dir.resolve("nope.txt")), "test")); + assertTrue(e.getMessage(), e.getMessage().contains("not a readable regular file")); + } + + @Test + public void testTooManyEntriesRejected() throws Exception { + var content = IntStream.range(0, CidrListLoader.MAX_ENTRIES + 1) + .mapToObj(i -> "10.0.0.1/32\n").collect(Collectors.joining()); + var file = write("huge.txt", content); + var e = assertThrows(IllegalArgumentException.class, () -> CidrListLoader.load(uri(file), "test")); + assertTrue(e.getMessage(), e.getMessage().contains("more than " + CidrListLoader.MAX_ENTRIES)); + } + + @Test + public void testFileOverSizeLimitRejected() throws Exception { + var file = dir.resolve("big.txt"); + try (var raf = new RandomAccessFile(file.toFile(), "rw")) { + raf.setLength(CidrListLoader.MAX_FILE_BYTES + 1); + } + var e = assertThrows(IllegalArgumentException.class, () -> CidrListLoader.load(uri(file), "test")); + assertTrue(e.getMessage(), e.getMessage().contains("larger than")); + } +} diff --git a/activemq-client/src/test/java/org/apache/activemq/util/RemoteAddressValidatorTest.java b/activemq-client/src/test/java/org/apache/activemq/util/RemoteAddressValidatorTest.java new file mode 100644 index 00000000000..8cf0109129e --- /dev/null +++ b/activemq-client/src/test/java/org/apache/activemq/util/RemoteAddressValidatorTest.java @@ -0,0 +1,141 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 + * + * http://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.apache.activemq.util; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.net.Inet6Address; +import java.net.InetAddress; +import java.net.Socket; +import java.util.List; + +import org.junit.Test; + +public class RemoteAddressValidatorTest { + + private static List cidrs(String... blocks) { + return CidrListLoader.parse(List.of(blocks), "test").cidrs(); + } + + private static InetAddress ip(String address) throws Exception { + return InetAddress.getByName(address); + } + + @Test + public void testDenyIsCheckedBeforeAllow() throws Exception { + var validator = new RemoteAddressValidator(cidrs("10.0.0.0/8"), cidrs("10.1.0.0/16")); + assertFalse("inside the deny block", validator.isAllowed(ip("10.1.2.3"))); + assertTrue("inside allow, outside deny", validator.isAllowed(ip("10.2.0.1"))); + } + + @Test + public void testEmptyAllowListPermitsEverythingNotDenied() throws Exception { + var validator = new RemoteAddressValidator(cidrs(), cidrs("192.168.0.0/16")); + assertTrue(validator.isAllowed(ip("8.8.8.8"))); + assertFalse(validator.isAllowed(ip("192.168.1.1"))); + } + + @Test + public void testAddressOutsideAllowListIsDenied() throws Exception { + var validator = new RemoteAddressValidator(cidrs("10.0.0.0/8"), cidrs()); + assertTrue(validator.isAllowed(ip("10.255.255.254"))); + assertFalse(validator.isAllowed(ip("172.16.0.1"))); + } + + @Test + public void testIpv4MappedIpv6MatchesIpv4Rule() throws Exception { + var validator = new RemoteAddressValidator(cidrs("192.168.1.0/24"), cidrs()); + // build the mapped form explicitly: InetAddress.getByName would already collapse it to IPv4 + var mapped = new byte[16]; + mapped[10] = (byte) 0xFF; + mapped[11] = (byte) 0xFF; + mapped[12] = (byte) 192; + mapped[13] = (byte) 168; + mapped[14] = 1; + mapped[15] = 5; + var address = Inet6Address.getByAddress(null, mapped, -1); + assertTrue("::ffff:192.168.1.5 must match 192.168.1.0/24", validator.isAllowed(address)); + } + + @Test + public void testIpv6RuleDoesNotMatchIpv4Address() throws Exception { + var validator = new RemoteAddressValidator(cidrs("2001:db8::/32"), cidrs()); + assertTrue(validator.isAllowed(ip("2001:db8::1"))); + assertFalse(validator.isAllowed(ip("10.0.0.1"))); + } + + @Test + public void testSocketWithoutRemoteAddressIsDenied() throws Exception { + var validator = new RemoteAddressValidator(cidrs(), cidrs()); + assertFalse(validator.isAllowed((Socket) null)); + try (var unconnected = new Socket()) { + assertFalse(validator.isAllowed(unconnected)); + } + } + + @Test + public void testStringQueryRunsFullDecisionForSingleAddress() { + var validator = new RemoteAddressValidator(cidrs("10.0.0.0/8", "2001:db8::/32"), cidrs("10.1.0.0/16")); + assertTrue(validator.isAllowed("10.2.3.4")); + assertTrue(validator.isAllowed(" 2001:db8::1 ")); + assertFalse("deny wins over the allow entry that also matches", validator.isAllowed("10.1.2.3")); + assertFalse("outside the allow list", validator.isAllowed("172.16.0.1")); + assertTrue("mapped form is normalised before the decision", validator.isAllowed("::ffff:10.2.3.4")); + } + + @Test + public void testStringQueryIgnoresEnabledFlag() { + var validator = new RemoteAddressValidator(cidrs(), cidrs("10.0.0.0/8")); + validator.setEnabled(false); + assertFalse(validator.isAllowed("10.0.0.1")); + assertTrue(validator.isAllowed("192.168.0.1")); + } + + @Test + public void testBlockJudgedAsAWholeDespiteCarveOuts() { + // a class B is allowed while its core router and DNS server are denied + var validator = new RemoteAddressValidator(cidrs("10.20.0.0/16"), cidrs("10.20.0.1/32", "10.20.0.53/32")); + assertTrue("the network is allowed even though two hosts inside it are refused", validator.isAllowed("10.20.0.0/16")); + assertTrue("a sub block clear of the carve outs", validator.isAllowed("10.20.5.0/24")); + assertFalse("the denied host itself", validator.isAllowed("10.20.0.53")); + assertFalse("a block entirely inside a deny entry", validator.isAllowed("10.20.0.53/32")); + assertTrue("a sub block that contains a denied host is still allowed as a whole", validator.isAllowed("10.20.0.0/24")); + assertFalse("wider than any allow entry", validator.isAllowed("10.0.0.0/8")); + var noAllowList = new RemoteAddressValidator(cidrs(), cidrs("192.168.0.0/16")); + assertTrue("empty allow list permits any block not wholly denied", noAllowList.isAllowed("192.0.0.0/8")); + assertFalse(noAllowList.isAllowed("192.168.4.0/24")); + } + + @Test + public void testStringQueryRejectsNonLiterals() { + var validator = new RemoteAddressValidator(cidrs("10.0.0.0/8"), cidrs()); + assertThrows(IllegalArgumentException.class, () -> validator.isAllowed("localhost")); + assertThrows(IllegalArgumentException.class, () -> validator.isAllowed("example.com/24")); + assertThrows(IllegalArgumentException.class, () -> validator.isAllowed("")); + assertThrows(IllegalArgumentException.class, () -> validator.isAllowed((String) null)); + } + + @Test + public void testEnabledToggle() { + var validator = new RemoteAddressValidator(cidrs(), cidrs()); + assertTrue(validator.isEnabled()); + validator.setEnabled(false); + assertFalse(validator.isEnabled()); + } +} diff --git a/activemq-unit-tests/src/test/java/org/apache/activemq/broker/TransportConnectorCidrTest.java b/activemq-unit-tests/src/test/java/org/apache/activemq/broker/TransportConnectorCidrTest.java new file mode 100644 index 00000000000..8b73e22d55a --- /dev/null +++ b/activemq-unit-tests/src/test/java/org/apache/activemq/broker/TransportConnectorCidrTest.java @@ -0,0 +1,215 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You 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 + * + * http://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.apache.activemq.broker; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import jakarta.jms.JMSException; + +import org.apache.activemq.ActiveMQConnectionFactory; +import org.apache.activemq.broker.jmx.ConnectorView; +import org.apache.activemq.test.annotations.ParallelTest; +import org.apache.activemq.util.Wait; +import org.junit.After; +import org.junit.Test; +import org.junit.experimental.categories.Category; + +/** + * Remote address allow/deny lists on a transport connector: configuration reaches + * the bound server through the managed connector, sockets are admitted or refused, + * and the counters and list metrics report it. + */ +@Category(ParallelTest.class) +public class TransportConnectorCidrTest { + + private static final String LOOPBACK = "127.0.0.1/32"; + + private BrokerService broker; + + @After + public void tearDown() throws Exception { + if (broker != null) { + broker.stop(); + broker.waitUntilStopped(); + } + } + + /** JMX on so the connector that runs is the managed copy, which must carry the settings over. */ + private TransportConnector startBroker(String allowList, String denyList, boolean enabled) throws Exception { + broker = new BrokerService(); + broker.setPersistent(false); + broker.setUseJmx(true); + broker.getManagementContext().setCreateConnector(false); + var connector = new TransportConnector(); + connector.setName("cidr"); + connector.setUri(new URI("tcp://127.0.0.1:0")); + connector.setAllowList(allowList); + connector.setDenyList(denyList); + connector.setAllowDenyValidationEnabled(enabled); + broker.addConnector(connector); + broker.start(); + broker.waitUntilStarted(); + return broker.getTransportConnectors().get(0); + } + + private static String clientUri(TransportConnector connector) throws Exception { + return "tcp://127.0.0.1:" + connector.getServer().getSocketAddress().getPort(); + } + + private static void connectAndClose(TransportConnector connector) throws Exception { + try (var connection = new ActiveMQConnectionFactory(clientUri(connector)).createConnection()) { + connection.start(); + } + } + + private static void assertRefused(TransportConnector connector) throws Exception { + assertThrows(JMSException.class, () -> connectAndClose(connector)); + } + + @Test(timeout = 60000) + public void testAllowedAddressConnectsAndIsCounted() throws Exception { + var connector = startBroker(LOOPBACK, null, true); + connectAndClose(connector); + assertTrue(Wait.waitFor(() -> connector.getAllowedCount() == 1, 5000, 10)); + assertEquals(0, connector.getDeniedCount()); + } + + @Test(timeout = 60000) + public void testDeniedAddressIsRefusedAndCounted() throws Exception { + var connector = startBroker(null, LOOPBACK, true); + assertRefused(connector); + assertTrue(Wait.waitFor(() -> connector.getDeniedCount() == 1, 5000, 10)); + assertEquals(0, connector.getAllowedCount()); + assertEquals("refusal must not count as a connection", 0, connector.getConnections().size()); + } + + @Test(timeout = 60000) + public void testAddressOutsideAllowListIsRefused() throws Exception { + var connector = startBroker("10.0.0.0/8", null, true); + assertRefused(connector); + assertTrue(Wait.waitFor(() -> connector.getDeniedCount() == 1, 5000, 10)); + } + + /** Validation is off unless enabled, so lists alone change nothing. The default may move in a future major release. */ + @Test(timeout = 60000) + public void testValidationIsDisabledByDefault() throws Exception { + assertFalse(new TransportConnector().isAllowDenyValidationEnabled()); + + broker = new BrokerService(); + broker.setPersistent(false); + broker.setUseJmx(true); + broker.getManagementContext().setCreateConnector(false); + var configured = new TransportConnector(); + configured.setName("cidr"); + configured.setUri(new URI("tcp://127.0.0.1:0")); + configured.setDenyList(LOOPBACK); + broker.addConnector(configured); + broker.start(); + broker.waitUntilStarted(); + var connector = broker.getTransportConnectors().get(0); + + assertFalse(connector.isAllowDenyValidationEnabled()); + assertEquals("list is still loaded", 1, connector.getDenyListCount()); + connectAndClose(connector); + assertEquals(0, connector.getDeniedCount()); + assertEquals(0, connector.getAllowedCount()); + } + + @Test(timeout = 60000) + public void testDisabledValidationKeepsListsButDoesNotCheck() throws Exception { + var connector = startBroker(null, LOOPBACK, false); + connectAndClose(connector); + assertEquals(LOOPBACK, connector.getDenyList()); + assertEquals(1, connector.getDenyListCount()); + assertEquals(0, connector.getAllowedCount()); + assertEquals(0, connector.getDeniedCount()); + } + + @Test(timeout = 60000) + public void testValidationCanBeToggledAtRuntimeThroughJmxView() throws Exception { + var connector = startBroker(null, LOOPBACK, false); + var view = new ConnectorView(connector); + assertFalse(view.isAllowDenyValidationEnabled()); + connectAndClose(connector); + + view.setAllowDenyValidationEnabled(true); + assertTrue(connector.isAllowDenyValidationEnabled()); + assertRefused(connector); + assertTrue(Wait.waitFor(() -> view.getDeniedCount() == 1, 5000, 10)); + + view.setAllowDenyValidationEnabled(false); + connectAndClose(connector); + assertEquals(1, view.getDeniedCount()); + } + + @Test(timeout = 60000) + public void testDenyListLoadedFromFile() throws Exception { + var dir = Files.createDirectories(Path.of("target", "transport-connector-cidr-test")); + var file = Files.writeString(dir.resolve("deny.txt"), "# refuse loopback\n" + LOOPBACK + "\n", StandardCharsets.UTF_8); + var connector = startBroker(null, "file:" + file.toAbsolutePath(), true); + assertEquals(1, connector.getDenyListCount()); + assertRefused(connector); + assertTrue(Wait.waitFor(() -> connector.getDeniedCount() == 1, 5000, 10)); + } + + @Test(timeout = 60000) + public void testListMetricsReportValidAndInvalidEntries() throws Exception { + var connector = startBroker("10.0.0.0/8,not-a-cidr," + LOOPBACK, null, true); + var view = new ConnectorView(connector); + assertEquals(2, view.getAllowListCount()); + assertEquals(1, view.getAllowListInvalidCount()); + assertEquals(0, view.getDenyListCount()); + assertEquals(0, view.getDenyListInvalidCount()); + assertEquals("10.0.0.0/8,not-a-cidr," + LOOPBACK, view.getAllowList()); + // the two valid entries still work + connectAndClose(connector); + assertTrue(Wait.waitFor(() -> view.getAllowedCount() == 1, 5000, 10)); + } + + /** allowed() runs the full decision even while enforcement is off, so lists can be checked first. */ + @Test(timeout = 60000) + public void testAllowedOperationThroughJmxView() throws Exception { + // class B allowed, core router and DNS server carved out + var connector = startBroker("10.20.0.0/16," + LOOPBACK, "10.20.0.1/32,10.20.0.53/32", false); + var view = new ConnectorView(connector); + assertTrue(view.allowed("127.0.0.1")); + assertTrue(view.allowed("10.20.7.8")); + assertTrue("the network as a whole is allowed despite the carve outs", view.allowed("10.20.0.0/16")); + assertFalse("deny entry wins over the allow entry", view.allowed("10.20.0.53")); + assertFalse("a block entirely inside a deny entry", view.allowed("10.20.0.1/32")); + assertFalse("outside the allow list", view.allowed("192.168.1.1")); + assertThrows(IllegalArgumentException.class, () -> view.allowed("not-an-address")); + } + + @Test(timeout = 60000) + public void testResetStatisticsClearsConnectionCountersNotListMetrics() throws Exception { + var connector = startBroker(LOOPBACK, null, true); + connectAndClose(connector); + assertTrue(Wait.waitFor(() -> connector.getAllowedCount() == 1, 5000, 10)); + connector.resetStatistics(); + assertEquals(0, connector.getAllowedCount()); + assertEquals(1, connector.getAllowListCount()); + } +}