This is an automated email from the ASF dual-hosted git repository. rcordier pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/james-project.git
commit d99592f9a6a4bbdd6d102c64a34bb11e370831c7 Author: Thanhbv <[email protected]> AuthorDate: Mon Nov 6 10:34:51 2023 +0700 JAMES 3897: CrowdsecImapConnectionCheck --- .../org/apache/james/imap/api/ConnectionCheck.java | 29 ++++++ .../james/imap/api/ConnectionCheckFactory.java | 27 +++++ .../apache/james/imap/api/ImapConfiguration.java | 24 ++++- .../docs/modules/ROOT/pages/configure/imap.adoc | 2 + .../docs/modules/ROOT/pages/extending/imap.adoc | 23 ++++- .../protocols/ConnectionCheckFactoryImpl.java | 54 ++++++++++ .../james/modules/protocols/IMAPServerModule.java | 7 +- .../imapserver/netty/HAProxyMessageHandler.java | 13 ++- .../apache/james/imapserver/netty/IMAPServer.java | 14 ++- .../james/imapserver/netty/IMAPServerFactory.java | 19 +++- .../netty/ImapChannelUpstreamHandler.java | 27 ++++- .../james/imapserver/netty/IMAPServerTest.java | 113 ++++++++++++++++++++- .../james/imapserver/netty/IpConnectionCheck.java | 47 +++++++++ .../test/resources/imapServerImapConnectCheck.xml | 16 +++ third-party/crowdsec/docker-compose.yml | 5 +- third-party/crowdsec/pom.xml | 27 +++++ .../crowdsec/sample-configuration/imapserver.xml | 41 ++++++++ .../apache/james/CrowdsecImapConnectionCheck.java | 67 ++++++++++++ .../apache/james/exception/CrowdsecException.java | 26 +++++ .../java/org/apache/james/CrowdsecContract.java | 38 +++++++ .../james/CrowdsecImapConnectionCheckTest.java | 97 ++++++++++++++++++ .../java/org/apache/james/MemoryCrowdsecTest.java | 58 +++++++++++ .../crowdsec/src/test/resources/logback.xml | 48 +++++++++ 23 files changed, 800 insertions(+), 22 deletions(-) diff --git a/protocols/imap/src/main/java/org/apache/james/imap/api/ConnectionCheck.java b/protocols/imap/src/main/java/org/apache/james/imap/api/ConnectionCheck.java new file mode 100644 index 0000000000..dbc2700106 --- /dev/null +++ b/protocols/imap/src/main/java/org/apache/james/imap/api/ConnectionCheck.java @@ -0,0 +1,29 @@ +/**************************************************************** + * 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.james.imap.api; + +import java.net.InetSocketAddress; + +import org.reactivestreams.Publisher; + +@FunctionalInterface +public interface ConnectionCheck { + Publisher<Void> validate(InetSocketAddress remoteAddress); +} diff --git a/protocols/imap/src/main/java/org/apache/james/imap/api/ConnectionCheckFactory.java b/protocols/imap/src/main/java/org/apache/james/imap/api/ConnectionCheckFactory.java new file mode 100644 index 0000000000..aa5dcded5c --- /dev/null +++ b/protocols/imap/src/main/java/org/apache/james/imap/api/ConnectionCheckFactory.java @@ -0,0 +1,27 @@ +/**************************************************************** + * 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.james.imap.api; + +import java.util.Set; + +@FunctionalInterface +public interface ConnectionCheckFactory { + Set<ConnectionCheck> create(ImapConfiguration imapConfiguration); +} diff --git a/protocols/imap/src/main/java/org/apache/james/imap/api/ImapConfiguration.java b/protocols/imap/src/main/java/org/apache/james/imap/api/ImapConfiguration.java index 41fc4c26b9..7522eaa6e2 100644 --- a/protocols/imap/src/main/java/org/apache/james/imap/api/ImapConfiguration.java +++ b/protocols/imap/src/main/java/org/apache/james/imap/api/ImapConfiguration.java @@ -62,6 +62,7 @@ public class ImapConfiguration { private Optional<Boolean> isCondstoreEnable; private Optional<Boolean> provisionDefaultMailboxes; private Optional<Properties> customProperties; + private ImmutableSet<String> additionalConnectionChecks; private Builder() { this.appendLimit = Optional.empty(); @@ -74,6 +75,7 @@ public class ImapConfiguration { this.isCondstoreEnable = Optional.empty(); this.provisionDefaultMailboxes = Optional.empty(); this.customProperties = Optional.empty(); + this.additionalConnectionChecks = ImmutableSet.of(); } public Builder idleTimeInterval(long idleTimeInterval) { @@ -143,6 +145,11 @@ public class ImapConfiguration { return this; } + public Builder connectionChecks(ImmutableSet<String> additionalConnectionChecks) { + this.additionalConnectionChecks = additionalConnectionChecks; + return this; + } + public ImapConfiguration build() { ImmutableSet<Capability> normalizeDisableCaps = disabledCaps.stream() .filter(Builder::noBlankString) @@ -159,7 +166,8 @@ public class ImapConfiguration { normalizeDisableCaps, isCondstoreEnable.orElse(DEFAULT_CONDSTORE_DISABLE), provisionDefaultMailboxes.orElse(DEFAULT_PROVISION_DEFAULT_MAILBOXES), - customProperties.orElseGet(Properties::new)); + customProperties.orElseGet(Properties::new), + additionalConnectionChecks); } } @@ -173,8 +181,9 @@ public class ImapConfiguration { private final boolean isCondstoreEnable; private final boolean provisionDefaultMailboxes; private final Properties customProperties; + private final ImmutableSet<String> additionalConnectionChecks; - private ImapConfiguration(Optional<Long> appendLimit, boolean enableIdle, long idleTimeInterval, int concurrentRequests, int maxQueueSize, TimeUnit idleTimeIntervalUnit, ImmutableSet<Capability> disabledCaps, boolean isCondstoreEnable, boolean provisionDefaultMailboxes, Properties customProperties) { + private ImapConfiguration(Optional<Long> appendLimit, boolean enableIdle, long idleTimeInterval, int concurrentRequests, int maxQueueSize, TimeUnit idleTimeIntervalUnit, ImmutableSet<Capability> disabledCaps, boolean isCondstoreEnable, boolean provisionDefaultMailboxes, Properties customProperties, ImmutableSet<String> additionalConnectionChecks) { this.appendLimit = appendLimit; this.enableIdle = enableIdle; this.idleTimeInterval = idleTimeInterval; @@ -185,6 +194,7 @@ public class ImapConfiguration { this.isCondstoreEnable = isCondstoreEnable; this.provisionDefaultMailboxes = provisionDefaultMailboxes; this.customProperties = customProperties; + this.additionalConnectionChecks = additionalConnectionChecks; } public Optional<Long> getAppendLimit() { @@ -231,6 +241,10 @@ public class ImapConfiguration { return customProperties; } + public ImmutableSet<String> getAdditionalConnectionChecks() { + return additionalConnectionChecks; + } + @Override public final boolean equals(Object obj) { if (obj instanceof ImapConfiguration) { @@ -244,7 +258,8 @@ public class ImapConfiguration { && Objects.equal(that.getDisabledCaps(), disabledCaps) && Objects.equal(that.isProvisionDefaultMailboxes(), provisionDefaultMailboxes) && Objects.equal(that.getCustomProperties(), customProperties) - && Objects.equal(that.isCondstoreEnable(), isCondstoreEnable); + && Objects.equal(that.isCondstoreEnable(), isCondstoreEnable) + && Objects.equal(that.getAdditionalConnectionChecks(), additionalConnectionChecks); } return false; } @@ -252,7 +267,7 @@ public class ImapConfiguration { @Override public final int hashCode() { return Objects.hashCode(enableIdle, idleTimeInterval, idleTimeIntervalUnit, disabledCaps, isCondstoreEnable, - concurrentRequests, maxQueueSize, appendLimit, provisionDefaultMailboxes, customProperties); + concurrentRequests, maxQueueSize, appendLimit, provisionDefaultMailboxes, customProperties, additionalConnectionChecks); } @Override @@ -268,6 +283,7 @@ public class ImapConfiguration { .add("maxQueueSize", maxQueueSize) .add("provisionDefaultMailboxes", provisionDefaultMailboxes) .add("customProperties", customProperties) + .add("additionalConnectionChecks", additionalConnectionChecks) .toString(); } } diff --git a/server/apps/distributed-app/docs/modules/ROOT/pages/configure/imap.adoc b/server/apps/distributed-app/docs/modules/ROOT/pages/configure/imap.adoc index 6b8f6d68a1..f99b27ef74 100644 --- a/server/apps/distributed-app/docs/modules/ROOT/pages/configure/imap.adoc +++ b/server/apps/distributed-app/docs/modules/ROOT/pages/configure/imap.adoc @@ -159,6 +159,8 @@ The following configuration properties are available for extensions: thus enable implementing new IMAP commands or replace existing IMAP processors. List of FQDNs, which can be located in James extensions. +| additionalConnectionChecks +| Configure (union) of additional connection checks. ConnectionCheck will check if the connection IP is secure or not. | customProperties | Properties for custom extension. Each tag is a property entry, and holds a string under the form key=value. |=== diff --git a/server/apps/distributed-app/docs/modules/ROOT/pages/extending/imap.adoc b/server/apps/distributed-app/docs/modules/ROOT/pages/extending/imap.adoc index 76c9a5fef9..c0c89808ac 100644 --- a/server/apps/distributed-app/docs/modules/ROOT/pages/extending/imap.adoc +++ b/server/apps/distributed-app/docs/modules/ROOT/pages/extending/imap.adoc @@ -22,4 +22,25 @@ Custom configuration can be obtained through `ImapConfiguration` class via the ` A full working example is available link:https://github.com/apache/james-project/tree/master/examples/custom-imap[here]. -See this page for xref:configure/imap.adoc#_extending_imap[more details on configuring IMAP extensions]. \ No newline at end of file +See this page for xref:configure/imap.adoc#_extending_imap[more details on configuring IMAP extensions]. + +== IMAP additional Connection Checks + +James allows defining your own additional connection checks to guarantee that the connecting IP is secured. + +A custom connection check should implement the following functional interface: +``` +@FunctionalInterface +public interface ConnectionCheck { + Publisher<Void> validate(InetSocketAddress remoteAddress); +} +``` + +- `validate` method is used to check the connecting IP is secured. + +Then the custom defined ConnectionCheck can be added in `imapserver.xml` file: +``` +<additionalConnectionChecks>org.apache.james.CrowdsecImapConnectionCheck</additionalConnectionChecks> +``` + +An example for configuration is available link:https://github.com/apache/james-project/blob/master/third-party/crowdsec/sample-configuration/imapserver.xml[here]. \ No newline at end of file diff --git a/server/container/guice/protocols/imap/src/main/java/org/apache/james/modules/protocols/ConnectionCheckFactoryImpl.java b/server/container/guice/protocols/imap/src/main/java/org/apache/james/modules/protocols/ConnectionCheckFactoryImpl.java new file mode 100644 index 0000000000..99cecbd97c --- /dev/null +++ b/server/container/guice/protocols/imap/src/main/java/org/apache/james/modules/protocols/ConnectionCheckFactoryImpl.java @@ -0,0 +1,54 @@ +/**************************************************************** + * 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.james.modules.protocols; + +import java.util.Optional; +import java.util.Set; + +import javax.inject.Inject; + +import org.apache.james.imap.api.ConnectionCheck; +import org.apache.james.imap.api.ConnectionCheckFactory; +import org.apache.james.imap.api.ImapConfiguration; +import org.apache.james.utils.ClassName; +import org.apache.james.utils.GuiceGenericLoader; + +import com.github.fge.lambdas.Throwing; +import com.google.common.collect.ImmutableSet; + +public class ConnectionCheckFactoryImpl implements ConnectionCheckFactory { + private final GuiceGenericLoader loader; + + @Inject + public ConnectionCheckFactoryImpl(GuiceGenericLoader loader) { + this.loader = loader; + } + + @Override + public Set<ConnectionCheck> create(ImapConfiguration imapConfiguration) { + return Optional.ofNullable(imapConfiguration.getAdditionalConnectionChecks()) + .orElse(ImmutableSet.of()) + .stream() + .map(ClassName::new) + .map(Throwing.function(loader::instantiate)) + .map(ConnectionCheck.class::cast) + .collect(ImmutableSet.toImmutableSet()); + } +} diff --git a/server/container/guice/protocols/imap/src/main/java/org/apache/james/modules/protocols/IMAPServerModule.java b/server/container/guice/protocols/imap/src/main/java/org/apache/james/modules/protocols/IMAPServerModule.java index 75a2fa690e..52e17e7c0e 100644 --- a/server/container/guice/protocols/imap/src/main/java/org/apache/james/modules/protocols/IMAPServerModule.java +++ b/server/container/guice/protocols/imap/src/main/java/org/apache/james/modules/protocols/IMAPServerModule.java @@ -30,6 +30,7 @@ import org.apache.james.ProtocolConfigurationSanitizer; import org.apache.james.RunArguments; import org.apache.james.filesystem.api.FileSystem; import org.apache.james.imap.ImapSuite; +import org.apache.james.imap.api.ConnectionCheckFactory; import org.apache.james.imap.api.display.Localizer; import org.apache.james.imap.api.message.response.StatusResponseFactory; import org.apache.james.imap.api.process.DefaultMailboxTyper; @@ -103,6 +104,7 @@ public class IMAPServerModule extends AbstractModule { Multibinder.newSetBinder(binder(), GuiceProbe.class).addBinding().to(ImapGuiceProbe.class); Multibinder.newSetBinder(binder(), CertificateReloadable.Factory.class).addBinding().to(IMAPServerFactory.class); + bind(ConnectionCheckFactory.class).to(ConnectionCheckFactoryImpl.class); } @Provides @@ -111,8 +113,9 @@ public class IMAPServerModule extends AbstractModule { GuiceGenericLoader loader, StatusResponseFactory statusResponseFactory, MetricFactory metricFactory, - GaugeRegistry gaugeRegistry) { - return new IMAPServerFactory(fileSystem, imapSuiteLoader(loader, statusResponseFactory), metricFactory, gaugeRegistry); + GaugeRegistry gaugeRegistry, + ConnectionCheckFactory connectionCheckFactory) { + return new IMAPServerFactory(fileSystem, imapSuiteLoader(loader, statusResponseFactory), metricFactory, gaugeRegistry, connectionCheckFactory); } DefaultProcessor provideClassImapProcessors(ImapPackage imapPackage, GuiceGenericLoader loader, StatusResponseFactory statusResponseFactory) { diff --git a/server/protocols/protocols-imap4/src/main/java/org/apache/james/imapserver/netty/HAProxyMessageHandler.java b/server/protocols/protocols-imap4/src/main/java/org/apache/james/imapserver/netty/HAProxyMessageHandler.java index 7de5436dfe..3308f6caf0 100644 --- a/server/protocols/protocols-imap4/src/main/java/org/apache/james/imapserver/netty/HAProxyMessageHandler.java +++ b/server/protocols/protocols-imap4/src/main/java/org/apache/james/imapserver/netty/HAProxyMessageHandler.java @@ -22,7 +22,9 @@ package org.apache.james.imapserver.netty; import static org.apache.james.imapserver.netty.ImapChannelUpstreamHandler.MDC_KEY; import java.net.InetSocketAddress; +import java.util.Set; +import org.apache.james.imap.api.ConnectionCheck; import org.apache.james.imap.api.process.ImapSession; import org.apache.james.protocols.api.CommandDetectionSession; import org.apache.james.protocols.api.ProxyInformation; @@ -36,12 +38,19 @@ import io.netty.channel.ChannelPipeline; import io.netty.handler.codec.haproxy.HAProxyMessage; import io.netty.handler.codec.haproxy.HAProxyProxiedProtocol; import io.netty.util.AttributeKey; +import reactor.core.publisher.Flux; public class HAProxyMessageHandler extends ChannelInboundHandlerAdapter { private static final Logger LOGGER = LoggerFactory.getLogger(HAProxyMessageHandler.class); private static final AttributeKey<CommandDetectionSession> SESSION_ATTRIBUTE_KEY = AttributeKey.valueOf("ImapSession"); public static final AttributeKey<ProxyInformation> PROXY_INFO = AttributeKey.valueOf("proxyInfo"); + private final Set<ConnectionCheck> connectionChecks; + + public HAProxyMessageHandler(Set<ConnectionCheck> connectionChecks) { + this.connectionChecks = connectionChecks; + } + @Override public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { if (msg instanceof HAProxyMessage) { @@ -51,14 +60,16 @@ public class HAProxyMessageHandler extends ChannelInboundHandlerAdapter { ImapSession imapSession = (ImapSession) pipeline.channel().attr(SESSION_ATTRIBUTE_KEY).get(); if (haproxyMsg.proxiedProtocol().equals(HAProxyProxiedProtocol.TCP4) || haproxyMsg.proxiedProtocol().equals(HAProxyProxiedProtocol.TCP6)) { + InetSocketAddress sourceIP = new InetSocketAddress(haproxyMsg.sourceAddress(), haproxyMsg.sourcePort()); ctx.channel().attr(PROXY_INFO).set( new ProxyInformation( - new InetSocketAddress(haproxyMsg.sourceAddress(), haproxyMsg.sourcePort()), + sourceIP, new InetSocketAddress(haproxyMsg.destinationAddress(), haproxyMsg.destinationPort()))); LOGGER.info("Connection from {} runs through {} proxy", haproxyMsg.sourceAddress(), haproxyMsg.destinationAddress()); // Refresh MDC info to account for proxying MDCBuilder boundMDC = IMAPMDCContext.boundMDC(ctx); + Flux.fromIterable(connectionChecks).concatMap(connectionCheck -> connectionCheck.validate(sourceIP)).then().block(); if (imapSession != null) { imapSession.setAttribute(MDC_KEY, boundMDC); diff --git a/server/protocols/protocols-imap4/src/main/java/org/apache/james/imapserver/netty/IMAPServer.java b/server/protocols/protocols-imap4/src/main/java/org/apache/james/imapserver/netty/IMAPServer.java index 5ded55125b..f4fa0c5412 100644 --- a/server/protocols/protocols-imap4/src/main/java/org/apache/james/imapserver/netty/IMAPServer.java +++ b/server/protocols/protocols-imap4/src/main/java/org/apache/james/imapserver/netty/IMAPServer.java @@ -21,11 +21,13 @@ package org.apache.james.imapserver.netty; import java.net.MalformedURLException; import java.time.Duration; import java.util.Optional; +import java.util.Set; import java.util.concurrent.TimeUnit; import org.apache.commons.configuration2.HierarchicalConfiguration; import org.apache.commons.configuration2.ex.ConfigurationException; import org.apache.commons.configuration2.tree.ImmutableNode; +import org.apache.james.imap.api.ConnectionCheck; import org.apache.james.imap.api.ImapConfiguration; import org.apache.james.imap.api.ImapConstants; import org.apache.james.imap.api.process.ImapProcessor; @@ -132,6 +134,7 @@ public class IMAPServer extends AbstractConfigurableAsyncServer implements ImapC private final ImapDecoder decoder; private final ImapMetrics imapMetrics; private final GaugeRegistry gaugeRegistry; + private final Set<ConnectionCheck> connectionChecks; private String hello; private boolean compress; @@ -146,13 +149,13 @@ public class IMAPServer extends AbstractConfigurableAsyncServer implements ImapC private Duration heartbeatInterval; private ReactiveThrottler reactiveThrottler; - - public IMAPServer(ImapDecoder decoder, ImapEncoder encoder, ImapProcessor processor, ImapMetrics imapMetrics, GaugeRegistry gaugeRegistry) { + public IMAPServer(ImapDecoder decoder, ImapEncoder encoder, ImapProcessor processor, ImapMetrics imapMetrics, GaugeRegistry gaugeRegistry, Set<ConnectionCheck> connectionChecks) { this.processor = processor; this.encoder = encoder; this.decoder = decoder; this.imapMetrics = imapMetrics; this.gaugeRegistry = gaugeRegistry; + this.connectionChecks = connectionChecks; } @Override @@ -247,7 +250,7 @@ public class IMAPServer extends AbstractConfigurableAsyncServer implements ImapC if (proxyRequired) { pipeline.addLast(HandlerConstants.PROXY_HANDLER, new HAProxyMessageDecoder()); - pipeline.addLast("proxyInformationHandler", new HAProxyMessageHandler()); + pipeline.addLast("proxyInformationHandler", new HAProxyMessageHandler(connectionChecks)); } // Add the text line decoder which limit the max line length, @@ -290,10 +293,12 @@ public class IMAPServer extends AbstractConfigurableAsyncServer implements ImapC .encoder(encoder) .compress(compress) .authenticationConfiguration(authenticationConfiguration) + .connectionChecks(connectionChecks) .secure(secure) .imapMetrics(imapMetrics) .heartbeatInterval(heartbeatInterval) .ignoreIDLEUponProcessing(ignoreIDLEUponProcessing) + .proxyRequired(proxyRequired) .build(); } @@ -302,4 +307,7 @@ public class IMAPServer extends AbstractConfigurableAsyncServer implements ImapC return new SwitchableLineBasedFrameDecoderFactory(maxLineLength); } + public Set<ConnectionCheck> getConnectionChecks() { + return this.connectionChecks; + } } diff --git a/server/protocols/protocols-imap4/src/main/java/org/apache/james/imapserver/netty/IMAPServerFactory.java b/server/protocols/protocols-imap4/src/main/java/org/apache/james/imapserver/netty/IMAPServerFactory.java index b0830f7986..3d0a2ad488 100644 --- a/server/protocols/protocols-imap4/src/main/java/org/apache/james/imapserver/netty/IMAPServerFactory.java +++ b/server/protocols/protocols-imap4/src/main/java/org/apache/james/imapserver/netty/IMAPServerFactory.java @@ -19,6 +19,7 @@ package org.apache.james.imapserver.netty; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import javax.inject.Inject; @@ -27,6 +28,8 @@ import org.apache.commons.configuration2.HierarchicalConfiguration; import org.apache.commons.configuration2.tree.ImmutableNode; import org.apache.james.filesystem.api.FileSystem; import org.apache.james.imap.ImapSuite; +import org.apache.james.imap.api.ConnectionCheckFactory; +import org.apache.james.imap.api.ImapConfiguration; import org.apache.james.imap.api.process.ImapProcessor; import org.apache.james.imap.decode.ImapDecoder; import org.apache.james.imap.encode.ImapEncoder; @@ -36,6 +39,7 @@ import org.apache.james.protocols.lib.netty.AbstractConfigurableAsyncServer; import org.apache.james.protocols.lib.netty.AbstractServerFactory; import com.github.fge.lambdas.functions.ThrowingFunction; +import com.google.common.collect.ImmutableSet; public class IMAPServerFactory extends AbstractServerFactory { @@ -43,30 +47,37 @@ public class IMAPServerFactory extends AbstractServerFactory { protected final ThrowingFunction<HierarchicalConfiguration<ImmutableNode>, ImapSuite> imapSuiteProvider; protected final ImapMetrics imapMetrics; protected final GaugeRegistry gaugeRegistry; + protected final ConnectionCheckFactory connectionCheckFactory; @Inject @Deprecated public IMAPServerFactory(FileSystem fileSystem, ImapDecoder decoder, ImapEncoder encoder, ImapProcessor processor, - MetricFactory metricFactory, GaugeRegistry gaugeRegistry) { + MetricFactory metricFactory, GaugeRegistry gaugeRegistry, ConnectionCheckFactory connectionCheckFactory) { this.fileSystem = fileSystem; + this.connectionCheckFactory = connectionCheckFactory; this.imapSuiteProvider = any -> new ImapSuite(decoder, encoder, processor); this.imapMetrics = new ImapMetrics(metricFactory); this.gaugeRegistry = gaugeRegistry; } public IMAPServerFactory(FileSystem fileSystem, ThrowingFunction<HierarchicalConfiguration<ImmutableNode>, ImapSuite> imapSuiteProvider, - MetricFactory metricFactory, GaugeRegistry gaugeRegistry) { + MetricFactory metricFactory, GaugeRegistry gaugeRegistry, ConnectionCheckFactory connectionCheckFactory) { this.fileSystem = fileSystem; this.imapSuiteProvider = imapSuiteProvider; this.imapMetrics = new ImapMetrics(metricFactory); this.gaugeRegistry = gaugeRegistry; + this.connectionCheckFactory = connectionCheckFactory; } protected IMAPServer createServer(HierarchicalConfiguration<ImmutableNode> config) { ImapSuite imapSuite = imapSuiteProvider.apply(config); - return new IMAPServer(imapSuite.getDecoder(), imapSuite.getEncoder(), imapSuite.getProcessor(), imapMetrics, gaugeRegistry); + ImmutableSet<String> connectionChecks = Arrays.stream(config.getStringArray("additionalConnectionChecks")).collect(ImmutableSet.toImmutableSet()); + + return new IMAPServer(imapSuite.getDecoder(), imapSuite.getEncoder(), imapSuite.getProcessor(), imapMetrics, gaugeRegistry, connectionCheckFactory.create(ImapConfiguration.builder() + .connectionChecks(connectionChecks) + .build())); } - + @Override protected List<AbstractConfigurableAsyncServer> createServers(HierarchicalConfiguration<ImmutableNode> config) throws Exception { List<AbstractConfigurableAsyncServer> servers = new ArrayList<>(); diff --git a/server/protocols/protocols-imap4/src/main/java/org/apache/james/imapserver/netty/ImapChannelUpstreamHandler.java b/server/protocols/protocols-imap4/src/main/java/org/apache/james/imapserver/netty/ImapChannelUpstreamHandler.java index ff487c8fb1..32e66a7f6b 100644 --- a/server/protocols/protocols-imap4/src/main/java/org/apache/james/imapserver/netty/ImapChannelUpstreamHandler.java +++ b/server/protocols/protocols-imap4/src/main/java/org/apache/james/imapserver/netty/ImapChannelUpstreamHandler.java @@ -26,7 +26,9 @@ import java.net.InetSocketAddress; import java.time.Duration; import java.util.NoSuchElementException; import java.util.Optional; +import java.util.Set; +import org.apache.james.imap.api.ConnectionCheck; import org.apache.james.imap.api.ImapConstants; import org.apache.james.imap.api.ImapMessage; import org.apache.james.imap.api.ImapSessionState; @@ -58,6 +60,7 @@ import io.netty.channel.ChannelInboundHandlerAdapter; import io.netty.handler.codec.TooLongFrameException; import io.netty.util.Attribute; import reactor.core.Disposable; +import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; /** @@ -79,6 +82,8 @@ public class ImapChannelUpstreamHandler extends ChannelInboundHandlerAdapter imp private boolean ignoreIDLEUponProcessing; private Duration heartbeatInterval; private ReactiveThrottler reactiveThrottler; + private Set<ConnectionCheck> connectionChecks; + private boolean proxyRequired; public ImapChannelUpstreamHandlerBuilder reactiveThrottler(ReactiveThrottler reactiveThrottler) { this.reactiveThrottler = reactiveThrottler; @@ -115,6 +120,11 @@ public class ImapChannelUpstreamHandler extends ChannelInboundHandlerAdapter imp return this; } + public ImapChannelUpstreamHandlerBuilder connectionChecks(Set<ConnectionCheck> connectionChecks) { + this.connectionChecks = connectionChecks; + return this; + } + public ImapChannelUpstreamHandlerBuilder imapMetrics(ImapMetrics imapMetrics) { this.imapMetrics = imapMetrics; return this; @@ -130,8 +140,13 @@ public class ImapChannelUpstreamHandler extends ChannelInboundHandlerAdapter imp return this; } + public ImapChannelUpstreamHandlerBuilder proxyRequired(boolean proxyRequired) { + this.proxyRequired = proxyRequired; + return this; + } + public ImapChannelUpstreamHandler build() { - return new ImapChannelUpstreamHandler(hello, processor, encoder, compress, secure, imapMetrics, authenticationConfiguration, ignoreIDLEUponProcessing, (int) heartbeatInterval.toSeconds(), reactiveThrottler); + return new ImapChannelUpstreamHandler(hello, processor, encoder, compress, secure, imapMetrics, authenticationConfiguration, ignoreIDLEUponProcessing, (int) heartbeatInterval.toSeconds(), reactiveThrottler, connectionChecks, proxyRequired); } } @@ -150,10 +165,13 @@ public class ImapChannelUpstreamHandler extends ChannelInboundHandlerAdapter imp private final Metric imapCommandsMetric; private final boolean ignoreIDLEUponProcessing; private final ReactiveThrottler reactiveThrottler; + private final Set<ConnectionCheck> connectionChecks; + private final boolean proxyRequired; public ImapChannelUpstreamHandler(String hello, ImapProcessor processor, ImapEncoder encoder, boolean compress, Encryption secure, ImapMetrics imapMetrics, AuthenticationConfiguration authenticationConfiguration, - boolean ignoreIDLEUponProcessing, int heartbeatIntervalSeconds, ReactiveThrottler reactiveThrottler) { + boolean ignoreIDLEUponProcessing, int heartbeatIntervalSeconds, ReactiveThrottler reactiveThrottler, + Set<ConnectionCheck> connectionChecks, boolean proxyRequired) { this.hello = hello; this.processor = processor; this.encoder = encoder; @@ -165,6 +183,8 @@ public class ImapChannelUpstreamHandler extends ChannelInboundHandlerAdapter imp this.ignoreIDLEUponProcessing = ignoreIDLEUponProcessing; this.heartbeatHandler = new ImapHeartbeatHandler(heartbeatIntervalSeconds, heartbeatIntervalSeconds, heartbeatIntervalSeconds); this.reactiveThrottler = reactiveThrottler; + this.connectionChecks = connectionChecks; + this.proxyRequired = proxyRequired; } @Override @@ -177,6 +197,9 @@ public class ImapChannelUpstreamHandler extends ChannelInboundHandlerAdapter imp ctx.channel().attr(LINEARALIZER_ATTRIBUTE_KEY).set(new Linearalizer()); MDCBuilder boundMDC = IMAPMDCContext.boundMDC(ctx) .addToContext(MDCBuilder.SESSION_ID, sessionId.asString()); + if (!proxyRequired) { + Flux.fromIterable(connectionChecks).concatMap(connectionCheck -> connectionCheck.validate(imapsession.getRemoteAddress())).then().block(); + } imapsession.setAttribute(MDC_KEY, boundMDC); try (Closeable closeable = mdc(imapsession).build()) { InetSocketAddress address = (InetSocketAddress) ctx.channel().remoteAddress(); diff --git a/server/protocols/protocols-imap4/src/test/java/org/apache/james/imapserver/netty/IMAPServerTest.java b/server/protocols/protocols-imap4/src/test/java/org/apache/james/imapserver/netty/IMAPServerTest.java index 5380d28dce..d7871a0f35 100644 --- a/server/protocols/protocols-imap4/src/test/java/org/apache/james/imapserver/netty/IMAPServerTest.java +++ b/server/protocols/protocols-imap4/src/test/java/org/apache/james/imapserver/netty/IMAPServerTest.java @@ -42,6 +42,7 @@ import java.security.cert.X509Certificate; import java.time.Duration; import java.util.List; import java.util.Properties; +import java.util.Set; import java.util.concurrent.ConcurrentLinkedDeque; import java.util.function.Predicate; import java.util.stream.IntStream; @@ -69,6 +70,7 @@ import org.apache.commons.net.imap.AuthenticatingIMAPClient; import org.apache.commons.net.imap.IMAPReply; import org.apache.commons.net.imap.IMAPSClient; import org.apache.james.core.Username; +import org.apache.james.imap.api.ConnectionCheck; import org.apache.james.imap.encode.main.DefaultImapEncoderFactory; import org.apache.james.imap.main.DefaultImapDecoderFactory; import org.apache.james.imap.processor.base.AbstractProcessor; @@ -111,6 +113,7 @@ import org.slf4j.LoggerFactory; import com.github.fge.lambdas.Throwing; import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; import com.sun.mail.imap.IMAPFolder; import ch.qos.logback.classic.Logger; @@ -148,6 +151,7 @@ class IMAPServerTest { memoryIntegrationResources = inMemoryIntegrationResources; RecordingMetricFactory metricFactory = new RecordingMetricFactory(); + Set<ConnectionCheck> connectionChecks = defaultConnectionChecks(); IMAPServer imapServer = new IMAPServer( DefaultImapDecoderFactory.createDecoder(), new DefaultImapEncoderFactory().buildImapEncoder(), @@ -162,7 +166,7 @@ class IMAPServerTest { memoryIntegrationResources.getQuotaRootResolver(), metricFactory), new ImapMetrics(metricFactory), - new NoopGaugeRegistry()); + new NoopGaugeRegistry(), connectionChecks); FileSystemImpl fileSystem = FileSystemImpl.forTestingWithConfigurationFromClasspath(); imapServer.setFileSystem(fileSystem); @@ -172,7 +176,6 @@ class IMAPServerTest { return imapServer; } - private IMAPServer createImapServer(HierarchicalConfiguration<ImmutableNode> config) throws Exception { authenticator = new FakeAuthenticator(); authenticator.addUser(USER, USER_PASS); @@ -197,6 +200,59 @@ class IMAPServerTest { return createImapServer(ConfigLoader.getConfig(ClassLoaderUtils.getSystemResourceAsSharedStream(configurationFile))); } + private Set<ConnectionCheck> defaultConnectionChecks() { + return ImmutableSet.of(new IpConnectionCheck()); + } + + @Nested + class ConnectionCheckTest { + + IMAPServer imapServer; + private final IpConnectionCheck ipConnectionCheck = new IpConnectionCheck(); + private int port; + + @BeforeEach + void beforeEach() throws Exception { + HierarchicalConfiguration<ImmutableNode> config = ConfigLoader.getConfig(ClassLoaderUtils.getSystemResourceAsSharedStream("imapServerImapConnectCheck.xml")); + imapServer = createImapServer(config); + port = imapServer.getListenAddresses().get(0).getPort(); + } + + @AfterEach + void tearDown() { + imapServer.destroy(); + } + + @Test + void banIpWhenBannedIpConnect() { + imapServer.getConnectionChecks().stream() + .filter(check -> check instanceof IpConnectionCheck) + .map(check -> (IpConnectionCheck) check) + .forEach(ipCheck -> ipCheck.setBannedIps(Set.of("127.0.0.1"))); + + assertThatThrownBy(() -> testIMAPClient.connect("127.0.0.1", port) + .login(USER.asString(), USER_PASS) + .append("INBOX", SMALL_MESSAGE)); + } + + @Test + void allowConnectWithUnbannedIp() throws IOException { + imapServer.getConnectionChecks().stream() + .filter(check -> check instanceof IpConnectionCheck) + .map(check -> (IpConnectionCheck) check) + .forEach(ipCheck -> ipCheck.setBannedIps(Set.of("127.0.0.2"))); + + testIMAPClient.connect("127.0.0.1", port) + .login(USER.asString(), USER_PASS) + .append("INBOX", SMALL_MESSAGE); + + assertThat(testIMAPClient + .select("INBOX") + .readFirstMessage()) + .contains("* 1 FETCH (FLAGS (\\Recent \\Seen) BODY[] {21}\r\nheader: value\r\n\r\nBODY)\r\n"); + } + } + @Nested class PartialFetch { IMAPServer imapServer; @@ -492,6 +548,10 @@ class IMAPServerTest { @Nested class Proxy { + private static final String CLIENT_IP = "255.255.255.254"; + private static final String PROXY_IP = "255.255.255.255"; + private static final String RANDOM_IP = "127.0.0.2"; + IMAPServer imapServer; private SocketChannel clientConnection; @@ -512,10 +572,57 @@ class IMAPServerTest { imapServer.destroy(); } + private void addBannedIps(String clientIp) { + imapServer.getConnectionChecks().stream() + .filter(check -> check instanceof IpConnectionCheck) + .map(check -> (IpConnectionCheck) check) + .forEach(ipCheck -> ipCheck.setBannedIps(Set.of(clientIp))); + } + @Test void shouldNotFailOnProxyInformation() throws Exception { clientConnection.write(ByteBuffer.wrap(String.format("PROXY %s %s %s %d %d\r\na0 LOGIN %s %s\r\n", - "TCP4", "255.255.255.254", "255.255.255.255", 65535, 65535, + "TCP4", CLIENT_IP, PROXY_IP, 65535, 65535, + USER.asString(), USER_PASS).getBytes(StandardCharsets.UTF_8))); + + assertThat(new String(readBytes(clientConnection), StandardCharsets.US_ASCII)) + .startsWith("a0 OK"); + } + + @Test + void shouldDetectAndBanByClientIP() throws IOException { + addBannedIps(CLIENT_IP); + + // WHEN connect as CLIENT_IP to PROXY_DESTINATION via PROXY_IP + clientConnection.write(ByteBuffer.wrap(String.format("PROXY %s %s %s %d %d\r\na0 LOGIN %s %s\r\n", + "TCP4", CLIENT_IP, PROXY_IP, 65535, 65535, + USER.asString(), USER_PASS).getBytes(StandardCharsets.UTF_8))); + + // THEN LOGIN should be rejected + assertThat(new String(readBytes(clientConnection), StandardCharsets.US_ASCII)) + .doesNotStartWith("a0 OK"); + } + + @Test + void shouldNotBanByProxyIP() throws IOException { + // GIVEN somehow PROXY_IP has been banned by mistake + addBannedIps(PROXY_IP); + + clientConnection.write(ByteBuffer.wrap(String.format("PROXY %s %s %s %d %d\r\na0 LOGIN %s %s\r\n", + "TCP4", CLIENT_IP, PROXY_IP, 65535, 65535, + USER.asString(), USER_PASS).getBytes(StandardCharsets.UTF_8))); + + // THEN CLIENT_IP still can connect + assertThat(new String(readBytes(clientConnection), StandardCharsets.US_ASCII)) + .startsWith("a0 OK"); + } + + @Test + void clientUsageShouldBeNormalWhenClientIPIsNotBanned() throws IOException { + addBannedIps(RANDOM_IP); + + clientConnection.write(ByteBuffer.wrap(String.format("PROXY %s %s %s %d %d\r\na0 LOGIN %s %s\r\n", + "TCP4", CLIENT_IP, PROXY_IP, 65535, 65535, USER.asString(), USER_PASS).getBytes(StandardCharsets.UTF_8))); assertThat(new String(readBytes(clientConnection), StandardCharsets.US_ASCII)) diff --git a/server/protocols/protocols-imap4/src/test/java/org/apache/james/imapserver/netty/IpConnectionCheck.java b/server/protocols/protocols-imap4/src/test/java/org/apache/james/imapserver/netty/IpConnectionCheck.java new file mode 100644 index 0000000000..23e61a0432 --- /dev/null +++ b/server/protocols/protocols-imap4/src/test/java/org/apache/james/imapserver/netty/IpConnectionCheck.java @@ -0,0 +1,47 @@ +/**************************************************************** + * 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.james.imapserver.netty; + +import java.net.InetSocketAddress; +import java.util.Set; + +import org.apache.james.imap.api.ConnectionCheck; +import org.reactivestreams.Publisher; + +import reactor.core.publisher.Mono; + +public class IpConnectionCheck implements ConnectionCheck { + private Set<String> bannedIps = Set.of(); + + @Override + public Publisher<Void> validate(InetSocketAddress remoteAddress) { + String ip = remoteAddress.getHostName(); + // check against bannedIps + if (bannedIps.stream().anyMatch(bannedIp -> bannedIp.equals(ip))) { + return Mono.error(() -> new RuntimeException("Banned")); + } else { + return Mono.empty(); + } + } + + public void setBannedIps(Set<String> bannedIps) { + this.bannedIps = bannedIps; + } +} diff --git a/server/protocols/protocols-imap4/src/test/resources/imapServerImapConnectCheck.xml b/server/protocols/protocols-imap4/src/test/resources/imapServerImapConnectCheck.xml new file mode 100644 index 0000000000..4a8f945041 --- /dev/null +++ b/server/protocols/protocols-imap4/src/test/resources/imapServerImapConnectCheck.xml @@ -0,0 +1,16 @@ + +<imapserver enabled="true"> + <jmxName>imapserver</jmxName> + <bind>0.0.0.0:0</bind> + <connectionBacklog>200</connectionBacklog> + <connectionLimit>0</connectionLimit> + <connectionLimitPerIP>0</connectionLimitPerIP> + <idleTimeInterval>120</idleTimeInterval> + <idleTimeIntervalUnit>SECONDS</idleTimeIntervalUnit> + <enableIdle>true</enableIdle> + <inMemorySizeLimit>64K</inMemorySizeLimit> + <literalSizeLimit>128K</literalSizeLimit> + <plainAuthDisallowed>false</plainAuthDisallowed> + <gracefulShutdown>false</gracefulShutdown> + <additionalConnectionChecks>org.apache.james.imapserver.netty.IpConnectionCheck</additionalConnectionChecks> +</imapserver> \ No newline at end of file diff --git a/third-party/crowdsec/docker-compose.yml b/third-party/crowdsec/docker-compose.yml index 8a6bf728ac..53e3e938c3 100644 --- a/third-party/crowdsec/docker-compose.yml +++ b/third-party/crowdsec/docker-compose.yml @@ -14,6 +14,7 @@ services: - ./sample-configuration/extensions.properties:/root/conf/extensions.properties - ./sample-configuration/smtpserver.xml:/root/conf/smtpserver.xml - ./sample-configuration/crowdsec.properties:/root/conf/crowdsec.properties + - ./sample-configuration/imapserver.xml:/root/conf/imapserver.xml networks: - james ports: @@ -37,8 +38,8 @@ services: - ./sample-configuration/collections:/etc/crowdsec/collections - /var/run/docker.sock:/var/run/docker.sock ports: - - "8082:8080" - - "6061:6060" + - "8080:8080" + - "6060:6060" networks: - james networks: diff --git a/third-party/crowdsec/pom.xml b/third-party/crowdsec/pom.xml index b552553773..e0415847a5 100644 --- a/third-party/crowdsec/pom.xml +++ b/third-party/crowdsec/pom.xml @@ -52,6 +52,11 @@ <artifactId>testing-base</artifactId> <scope>test</scope> </dependency> + <dependency> + <groupId>${james.protocols.groupId}</groupId> + <artifactId>protocols-imap</artifactId> + <scope>provided</scope> + </dependency> <dependency> <groupId>${james.protocols.groupId}</groupId> <artifactId>protocols-smtp</artifactId> @@ -92,6 +97,7 @@ <dependency> <groupId>commons-net</groupId> <artifactId>commons-net</artifactId> + <scope>provided</scope> </dependency> <dependency> <groupId>io.projectreactor.netty</groupId> @@ -129,4 +135,25 @@ <scope>test</scope> </dependency> </dependencies> + <build> + <plugins> + <plugin> + <artifactId>maven-assembly-plugin</artifactId> + <configuration> + <descriptorRefs> + <descriptorRef>jar-with-dependencies</descriptorRef> + </descriptorRefs> + <finalName>apache-james-crowdsec</finalName> + </configuration> + <executions> + <execution> + <goals> + <goal>single</goal> + </goals> + <phase>package</phase> + </execution> + </executions> + </plugin> + </plugins> + </build> </project> diff --git a/third-party/crowdsec/sample-configuration/imapserver.xml b/third-party/crowdsec/sample-configuration/imapserver.xml new file mode 100644 index 0000000000..8af6179bdb --- /dev/null +++ b/third-party/crowdsec/sample-configuration/imapserver.xml @@ -0,0 +1,41 @@ +<?xml version="1.0"?> + +<!-- +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. +--> + +<imapservers> + <imapserver enabled="true"> + <jmxName>imapserver</jmxName> + <bind>0.0.0.0:143</bind> + <connectionBacklog>200</connectionBacklog> + <tls socketTLS="false" startTLS="false"> + <keystore>classpath://keystore</keystore> + <secret>james72laBalle</secret> + <provider>org.bouncycastle.jce.provider.BouncyCastleProvider</provider> + </tls> + <connectionLimit>0</connectionLimit> + <connectionLimitPerIP>0</connectionLimitPerIP> + <plainAuthDisallowed>false</plainAuthDisallowed> + <gracefulShutdown>false</gracefulShutdown> + <customProperties>pong.response=customImapParameter</customProperties> + <customProperties>prop.b=anotherValue</customProperties> + <gracefulShutdown>false</gracefulShutdown> + <additionalConnectionChecks>org.apache.james.CrowdsecImapConnectionCheck</additionalConnectionChecks> + </imapserver> +</imapservers> \ No newline at end of file diff --git a/third-party/crowdsec/src/main/java/org/apache/james/CrowdsecImapConnectionCheck.java b/third-party/crowdsec/src/main/java/org/apache/james/CrowdsecImapConnectionCheck.java new file mode 100644 index 0000000000..270445645e --- /dev/null +++ b/third-party/crowdsec/src/main/java/org/apache/james/CrowdsecImapConnectionCheck.java @@ -0,0 +1,67 @@ +/**************************************************************** + * 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.james; + +import java.net.InetSocketAddress; + +import javax.inject.Inject; + +import org.apache.commons.net.util.SubnetUtils; +import org.apache.james.exception.CrowdsecException; +import org.apache.james.imap.api.ConnectionCheck; +import org.apache.james.model.CrowdsecClientConfiguration; +import org.apache.james.model.CrowdsecDecision; +import org.apache.james.model.CrowdsecHttpClient; +import org.reactivestreams.Publisher; + +public class CrowdsecImapConnectionCheck implements ConnectionCheck { + private final CrowdsecClientConfiguration crowdsecClientConfiguration; + + @Inject + public CrowdsecImapConnectionCheck(CrowdsecClientConfiguration crowdsecClientConfiguration) { + this.crowdsecClientConfiguration = crowdsecClientConfiguration; + } + + @Override + public Publisher<Void> validate(InetSocketAddress remoteAddress) { + String ip = remoteAddress.getHostName(); + CrowdsecHttpClient client = new CrowdsecHttpClient(crowdsecClientConfiguration); + return client.getCrowdsecDecisions() + .filter(decisions -> decisions.stream().anyMatch(decision -> isBanned(decision, ip))) + .handle((crowdsecDecisions, synchronousSink) -> synchronousSink.error(new CrowdsecException("Ip " + ip + " is not allowed to connect to IMAP server by Crowdsec"))); + } + + private boolean isBanned(CrowdsecDecision decision, String ip) { + if (decision.getScope().equals("Ip") && ip.contains(decision.getValue())) { + return true; + } + if (decision.getScope().equals("Range") && belongToNetwork(decision.getValue(), ip)) { + return true; + } + return false; + } + + private boolean belongToNetwork(String value, String ip) { + SubnetUtils subnetUtils = new SubnetUtils(value); + subnetUtils.setInclusiveHostCount(true); + + return subnetUtils.getInfo().isInRange(ip); + } +} diff --git a/third-party/crowdsec/src/main/java/org/apache/james/exception/CrowdsecException.java b/third-party/crowdsec/src/main/java/org/apache/james/exception/CrowdsecException.java new file mode 100644 index 0000000000..38688be3c1 --- /dev/null +++ b/third-party/crowdsec/src/main/java/org/apache/james/exception/CrowdsecException.java @@ -0,0 +1,26 @@ +/**************************************************************** + * 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.james.exception; + +public class CrowdsecException extends RuntimeException { + public CrowdsecException(String message) { + super(message); + } +} diff --git a/third-party/crowdsec/src/test/java/org/apache/james/CrowdsecContract.java b/third-party/crowdsec/src/test/java/org/apache/james/CrowdsecContract.java new file mode 100644 index 0000000000..2ca5d6a2ae --- /dev/null +++ b/third-party/crowdsec/src/test/java/org/apache/james/CrowdsecContract.java @@ -0,0 +1,38 @@ +/**************************************************************** + * 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.james; + + +import org.apache.james.utils.DataProbeImpl; +import org.junit.jupiter.api.BeforeEach; + +public interface CrowdsecContract { + + String DOMAIN = "domain.tld"; + String BOB = "bob@" + DOMAIN; + String BOB_PASSWORD = "bobPassword"; + + @BeforeEach + default void setup(GuiceJamesServer server) throws Exception { + server.getProbe(DataProbeImpl.class).fluent() + .addDomain(DOMAIN) + .addUser(BOB, BOB_PASSWORD); + } +} diff --git a/third-party/crowdsec/src/test/java/org/apache/james/CrowdsecImapConnectionCheckTest.java b/third-party/crowdsec/src/test/java/org/apache/james/CrowdsecImapConnectionCheckTest.java new file mode 100644 index 0000000000..df8287cb94 --- /dev/null +++ b/third-party/crowdsec/src/test/java/org/apache/james/CrowdsecImapConnectionCheckTest.java @@ -0,0 +1,97 @@ +/**************************************************************** + * 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.james; + +import static org.apache.james.CrowdsecExtension.CROWDSEC_PORT; +import static org.apache.james.model.CrowdsecClientConfiguration.DEFAULT_API_KEY; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.URL; + +import org.apache.james.model.CrowdsecClientConfiguration; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import reactor.core.publisher.Mono; + +public class CrowdsecImapConnectionCheckTest { + @RegisterExtension + static CrowdsecExtension crowdsecExtension = new CrowdsecExtension(); + + @BeforeAll + static void setUp() throws IOException, InterruptedException { + crowdsecExtension.getCrowdsecContainer().execInContainer("cscli", "bouncer", "add", "bouncer", "-k", DEFAULT_API_KEY); + } + + @Test + void givenIPBannedByCrowdsecDecisionIp() throws IOException, InterruptedException { + banIP("--ip", "127.0.0.3"); + int port = crowdsecExtension.getCrowdsecContainer().getMappedPort(CROWDSEC_PORT); + CrowdsecClientConfiguration crowdsecClientConfiguration = new CrowdsecClientConfiguration(new URL("http://localhost:" + port + "/v1"), DEFAULT_API_KEY); + + CrowdsecImapConnectionCheck connectionCheck = new CrowdsecImapConnectionCheck(crowdsecClientConfiguration); + connectionCheck.validate(new InetSocketAddress("127.0.0.3", 8800)); + assertThatThrownBy(() -> Mono.from(connectionCheck.validate(new InetSocketAddress("127.0.0.3", 8800))).block()) + .hasMessage("Ip 127.0.0.3 is not allowed to connect to IMAP server by Crowdsec"); + } + + @Test + void givenIPBannedByCrowdsecDecisionIpRange() throws IOException, InterruptedException { + banIP("--range", "127.0.0.1/24"); + int port = crowdsecExtension.getCrowdsecContainer().getMappedPort(CROWDSEC_PORT); + CrowdsecClientConfiguration crowdsecClientConfiguration = new CrowdsecClientConfiguration(new URL("http://localhost:" + port + "/v1"), DEFAULT_API_KEY); + + CrowdsecImapConnectionCheck connectionCheck = new CrowdsecImapConnectionCheck(crowdsecClientConfiguration); + connectionCheck.validate(new InetSocketAddress("127.0.0.3", 8800)); + assertThatThrownBy(() -> Mono.from(connectionCheck.validate(new InetSocketAddress("127.0.0.3", 8800))).block()) + .hasMessage("Ip 127.0.0.3 is not allowed to connect to IMAP server by Crowdsec"); + } + + @Test + void givenIPNotBannedByCrowdsecDecisionIp() throws IOException, InterruptedException { + banIP("--ip", "192.182.39.2"); + + int port = crowdsecExtension.getCrowdsecContainer().getMappedPort(CROWDSEC_PORT); + CrowdsecClientConfiguration crowdsecClientConfiguration = new CrowdsecClientConfiguration(new URL("http://localhost:" + port + "/v1"), DEFAULT_API_KEY); + + CrowdsecImapConnectionCheck connectionCheck = new CrowdsecImapConnectionCheck(crowdsecClientConfiguration); + connectionCheck.validate(new InetSocketAddress("127.0.0.3", 8800)); + Mono.from(connectionCheck.validate(new InetSocketAddress("127.0.0.3", 8800))).block(); + } + + @Test + void givenIPNotBannedByCrowdsecDecisionIpRange() throws IOException, InterruptedException { + banIP("--range", "192.182.39.2/24"); + + int port = crowdsecExtension.getCrowdsecContainer().getMappedPort(CROWDSEC_PORT); + CrowdsecClientConfiguration crowdsecClientConfiguration = new CrowdsecClientConfiguration(new URL("http://localhost:" + port + "/v1"), DEFAULT_API_KEY); + + CrowdsecImapConnectionCheck connectionCheck = new CrowdsecImapConnectionCheck(crowdsecClientConfiguration); + connectionCheck.validate(new InetSocketAddress("127.0.0.3", 8800)); + Mono.from(connectionCheck.validate(new InetSocketAddress("127.0.0.3", 8800))).block(); + } + + private static void banIP(String type, String value) throws IOException, InterruptedException { + crowdsecExtension.getCrowdsecContainer().execInContainer("cscli", "decision", "add", type, value); + } +} diff --git a/third-party/crowdsec/src/test/java/org/apache/james/MemoryCrowdsecTest.java b/third-party/crowdsec/src/test/java/org/apache/james/MemoryCrowdsecTest.java new file mode 100644 index 0000000000..3155bbbf07 --- /dev/null +++ b/third-party/crowdsec/src/test/java/org/apache/james/MemoryCrowdsecTest.java @@ -0,0 +1,58 @@ +/**************************************************************** + * 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.james; + +import static org.apache.james.data.UsersRepositoryModuleChooser.Implementation.DEFAULT; +import static org.apache.james.model.CrowdsecClientConfiguration.DEFAULT_API_KEY; + +import java.io.IOException; + +import org.apache.james.utils.TestIMAPClient; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +public class MemoryCrowdsecTest implements CrowdsecContract { + @RegisterExtension + static JamesServerExtension testExtension = new JamesServerBuilder<MemoryJamesConfiguration>(tmpDir -> + MemoryJamesConfiguration.builder() + .workingDirectory(tmpDir) + .configurationFromClasspath() + .usersRepository(DEFAULT) + .build()) + .server(MemoryJamesServerMain::createServer) + .build(); + + @RegisterExtension + static CrowdsecExtension crowdsecExtension = new CrowdsecExtension(); + + @RegisterExtension + public TestIMAPClient testIMAPClient = new TestIMAPClient(); + + @BeforeAll + static void setUp() throws IOException, InterruptedException { + crowdsecExtension.getCrowdsecContainer().execInContainer("cscli", "bouncer", "add", "bouncer", "-k", DEFAULT_API_KEY); + } + + @Test + void IPShouldBeBannedByCrowdsecWhenFailingToLoginThreeTimes() { + // TODO client login failed 3 times in short period, James will ban this IP based on Crowdsec decision + } +} diff --git a/third-party/crowdsec/src/test/resources/logback.xml b/third-party/crowdsec/src/test/resources/logback.xml new file mode 100644 index 0000000000..3d56ea2e15 --- /dev/null +++ b/third-party/crowdsec/src/test/resources/logback.xml @@ -0,0 +1,48 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + 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. + --> + +<configuration> + + <contextListener class="ch.qos.logback.classic.jul.LevelChangePropagator"> + <resetJUL>true</resetJUL> + </contextListener> + + <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender"> + <encoder class="ch.qos.logback.core.encoder.LayoutWrappingEncoder"> + <layout class="ch.qos.logback.contrib.json.classic.JsonLayout"> + <timestampFormat>yyyy-MM-dd'T'HH:mm:ss.SSSX</timestampFormat> + <timestampFormatTimezoneId>Etc/UTC</timestampFormatTimezoneId> + + <!-- Importance for handling multiple lines log --> + <appendLineSeparator>true</appendLineSeparator> + + <jsonFormatter class="ch.qos.logback.contrib.jackson.JacksonJsonFormatter"> + <prettyPrint>false</prettyPrint> + </jsonFormatter> + </layout> + </encoder> + <immediateFlush>false</immediateFlush> + </appender> + <root level="WARN"> + <appender-ref ref="CONSOLE" /> + </root> + + <logger name="org.apache.james" level="INFO" /> +</configuration> \ No newline at end of file --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
