This is an automated email from the ASF dual-hosted git repository.

jhelou pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/james-project.git

commit 07c3a4ba5a316bf6f1230e7097b7afc562ed9b53
Author: Jean Helou <[email protected]>
AuthorDate: Wed Jan 17 18:31:51 2024 +0100

    [JAMES-3897] implements a crowdsec based SMTP connect handler
    
    EHLO is not required before sending AUTH, therefore blocking on EHLO does't 
work very well against bruteforce attempts
    
    This commit introduces a hard connection close when a banned ip attempts to 
connect again to james.
---
 third-party/crowdsec/README.md                     | 10 +++
 .../james/crowdsec/CrowdsecSMTPConnectHandler.java | 88 ++++++++++++++++++++++
 .../org/apache/james/crowdsec/CrowdsecService.java | 69 +++++++++++++++++
 .../apache/james/crowdsec/CrowdsecExtension.java   |  4 +
 .../crowdsec/CrowdsecSMTPConnectHandlerTest.java   | 46 +++++++++++
 .../apache/james/crowdsec/CrowdsecServiceTest.java | 82 ++++++++++++++++++++
 6 files changed, 299 insertions(+)

diff --git a/third-party/crowdsec/README.md b/third-party/crowdsec/README.md
index 8371413403..db23dc62c9 100644
--- a/third-party/crowdsec/README.md
+++ b/third-party/crowdsec/README.md
@@ -27,6 +27,16 @@ 
guice.extension.module=org.apache.james.crowdsec.module.CrowdsecModule
     <handler class="org.apache.james.crowdsec.CrowdsecEhloHook"/>
 </handlerchain>
 ```
+or 
+```
+<handlerchain>
+    <handler class="org.apache.james.smtpserver.fastfail.ValidRcptHandler"/>
+    <handler class="org.apache.james.smtpserver.CoreCmdHandlerLoader"/>
+    <handler class="org.apache.james.crowdsec.CrowdsecSMTPConnectHandler"/>
+</handlerchain>
+```
+
+The EHLO hook will block banned clients with `554 Email rejected` whereas the 
connect handler will terminate the connection even before the SMTP greeting. 
 
 ### CrowdSec support for IMAP
 - Declare the `CrowdsecImapConnectionCheck` in `imapserver.xml`. Eg:
diff --git 
a/third-party/crowdsec/src/main/java/org/apache/james/crowdsec/CrowdsecSMTPConnectHandler.java
 
b/third-party/crowdsec/src/main/java/org/apache/james/crowdsec/CrowdsecSMTPConnectHandler.java
new file mode 100644
index 0000000000..0cc3436ee1
--- /dev/null
+++ 
b/third-party/crowdsec/src/main/java/org/apache/james/crowdsec/CrowdsecSMTPConnectHandler.java
@@ -0,0 +1,88 @@
+/****************************************************************
+ * 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.crowdsec;
+
+import java.util.Collections;
+import java.util.List;
+
+import javax.inject.Inject;
+
+import org.apache.james.crowdsec.model.CrowdsecDecision;
+import org.apache.james.protocols.api.Response;
+import org.apache.james.protocols.api.handler.ConnectHandler;
+import org.apache.james.protocols.smtp.SMTPSession;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+public class CrowdsecSMTPConnectHandler implements ConnectHandler<SMTPSession> 
{
+    private static final Logger LOGGER = 
LoggerFactory.getLogger(CrowdsecSMTPConnectHandler.class);
+
+    public static final Response NOOP = new Response() {
+
+        @Override
+        public String getRetCode() {
+            return "";
+        }
+
+        @Override
+        public List<CharSequence> getLines() {
+            return Collections.emptyList();
+        }
+
+        @Override
+        public boolean isEndSession() {
+            return false;
+        }
+
+    };
+
+    private final CrowdsecService crowdsecService;
+
+    @Inject
+    public CrowdsecSMTPConnectHandler(CrowdsecService service) {
+        this.crowdsecService = service;
+    }
+
+    @Override
+    public Response onConnect(SMTPSession session) {
+        String ip = session.getRemoteAddress().getAddress().getHostAddress();
+        return crowdsecService.findBanDecisions(session.getRemoteAddress())
+                .map(decisions -> {
+                    if (!decisions.isEmpty()) {
+                        decisions.forEach(d -> logBanned(d, ip));
+                        return Response.DISCONNECT;
+                    } else {
+                        return NOOP;
+                    }
+                }).block();
+    }
+
+    private boolean logBanned(CrowdsecDecision decision, String ip) {
+        if (decision.getScope().equals("Ip")) {
+            LOGGER.info("Ip {} is banned by crowdsec for {}. Full decision was 
{} ", decision.getValue(), decision.getDuration(), decision);
+            return true;
+        }
+        if (decision.getScope().equals("Range")) {
+            LOGGER.info("Ip {} belongs to range {} banned by crowdsec for {}. 
Full decision was {} ", ip, decision.getValue(), decision.getDuration(), 
decision);
+            return true;
+        }
+        return false;
+    }
+}
diff --git 
a/third-party/crowdsec/src/main/java/org/apache/james/crowdsec/CrowdsecService.java
 
b/third-party/crowdsec/src/main/java/org/apache/james/crowdsec/CrowdsecService.java
new file mode 100644
index 0000000000..a3b5c950ef
--- /dev/null
+++ 
b/third-party/crowdsec/src/main/java/org/apache/james/crowdsec/CrowdsecService.java
@@ -0,0 +1,69 @@
+/****************************************************************
+ * 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.crowdsec;
+
+import java.net.InetSocketAddress;
+import java.util.List;
+import java.util.stream.Collectors;
+
+import javax.inject.Inject;
+
+import org.apache.commons.net.util.SubnetUtils;
+import org.apache.james.crowdsec.client.CrowdsecClientConfiguration;
+import org.apache.james.crowdsec.client.CrowdsecHttpClient;
+import org.apache.james.crowdsec.model.CrowdsecDecision;
+
+import reactor.core.publisher.Mono;
+
+class CrowdsecService {
+    private final CrowdsecHttpClient crowdsecHttpClient;
+
+    @Inject
+    public CrowdsecService(CrowdsecClientConfiguration configuration) {
+        this.crowdsecHttpClient = new CrowdsecHttpClient(configuration);
+    }
+
+    public Mono<List<CrowdsecDecision>> findBanDecisions(InetSocketAddress 
remoteAddress) {
+        return crowdsecHttpClient.getCrowdsecDecisions()
+                .map(decisions ->
+                        decisions.stream().filter(
+                                decision -> isBanned(decision, 
remoteAddress.getAddress().getHostAddress())
+                        ).collect(Collectors.toList())
+                );
+    }
+
+    private boolean isBanned(CrowdsecDecision decision, String ip) {
+        if (decision.getScope().equals("Ip") && 
ip.contains(decision.getValue())) {
+            return true;
+        }
+        if (decision.getScope().equals("Range") && 
belongsToNetwork(decision.getValue(), ip)) {
+            return true;
+        }
+        return false;
+    }
+
+    private boolean belongsToNetwork(String bannedRange, String ip) {
+        SubnetUtils subnetUtils = new SubnetUtils(bannedRange);
+        subnetUtils.setInclusiveHostCount(true);
+
+        return subnetUtils.getInfo().isInRange(ip);
+    }
+
+}
diff --git 
a/third-party/crowdsec/src/test/java/org/apache/james/crowdsec/CrowdsecExtension.java
 
b/third-party/crowdsec/src/test/java/org/apache/james/crowdsec/CrowdsecExtension.java
index 14464aca3e..aabcf676bc 100644
--- 
a/third-party/crowdsec/src/test/java/org/apache/james/crowdsec/CrowdsecExtension.java
+++ 
b/third-party/crowdsec/src/test/java/org/apache/james/crowdsec/CrowdsecExtension.java
@@ -126,4 +126,8 @@ public class CrowdsecExtension implements 
GuiceModuleTestExtension {
     public GenericContainer<?> getCrowdsecContainer() {
         return crowdsecContainer;
     }
+
+    public void banIP(String type, String value) throws IOException, 
InterruptedException {
+        this.getCrowdsecContainer().execInContainer("cscli", "decision", 
"add", type, value);
+    }
 }
diff --git 
a/third-party/crowdsec/src/test/java/org/apache/james/crowdsec/CrowdsecSMTPConnectHandlerTest.java
 
b/third-party/crowdsec/src/test/java/org/apache/james/crowdsec/CrowdsecSMTPConnectHandlerTest.java
new file mode 100644
index 0000000000..d1be4f7be1
--- /dev/null
+++ 
b/third-party/crowdsec/src/test/java/org/apache/james/crowdsec/CrowdsecSMTPConnectHandlerTest.java
@@ -0,0 +1,46 @@
+package org.apache.james.crowdsec;
+
+import java.io.IOException;
+import java.net.URL;
+
+import org.apache.james.crowdsec.client.CrowdsecClientConfiguration;
+import org.apache.james.protocols.api.Response;
+import org.apache.james.protocols.smtp.SMTPSession;
+import org.apache.james.protocols.smtp.utils.BaseFakeSMTPSession;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+import static org.apache.james.crowdsec.CrowdsecExtension.CROWDSEC_PORT;
+import static org.assertj.core.api.Assertions.assertThat;
+
+class CrowdsecSMTPConnectHandlerTest {
+    @RegisterExtension
+    static CrowdsecExtension crowdsecExtension = new CrowdsecExtension();
+
+    private CrowdsecSMTPConnectHandler connectHandler;
+
+    @BeforeEach
+    void setUpEach() throws IOException {
+        int port = 
crowdsecExtension.getCrowdsecContainer().getMappedPort(CROWDSEC_PORT);
+        var crowdsecClientConfiguration = new CrowdsecClientConfiguration(new 
URL("http://localhost:"; + port + "/v1"), 
CrowdsecClientConfiguration.DEFAULT_API_KEY);
+        connectHandler = new CrowdsecSMTPConnectHandler(new 
CrowdsecService(crowdsecClientConfiguration));
+    }
+
+    @Test
+    void givenIPBannedByCrowdsecDecision() throws IOException, 
InterruptedException {
+        crowdsecExtension.banIP("--ip", "127.0.0.1");
+        SMTPSession session = new BaseFakeSMTPSession() {};
+
+        
assertThat(connectHandler.onConnect(session)).isEqualTo(Response.DISCONNECT);
+    }
+
+    @Test
+    void givenIPNotBannedByCrowdsecDecision() throws IOException, 
InterruptedException {
+        crowdsecExtension.banIP("--range", "192.182.39.2/24");
+
+        SMTPSession session = new BaseFakeSMTPSession() {};
+
+        
assertThat(connectHandler.onConnect(session)).isEqualTo(CrowdsecSMTPConnectHandler.NOOP);
+    }
+}
\ No newline at end of file
diff --git 
a/third-party/crowdsec/src/test/java/org/apache/james/crowdsec/CrowdsecServiceTest.java
 
b/third-party/crowdsec/src/test/java/org/apache/james/crowdsec/CrowdsecServiceTest.java
new file mode 100644
index 0000000000..384ebb5ddd
--- /dev/null
+++ 
b/third-party/crowdsec/src/test/java/org/apache/james/crowdsec/CrowdsecServiceTest.java
@@ -0,0 +1,82 @@
+/****************************************************************
+ * 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.crowdsec;
+
+import java.io.IOException;
+import java.net.InetSocketAddress;
+import java.net.URL;
+
+import org.apache.james.crowdsec.client.CrowdsecClientConfiguration;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+import static org.apache.james.crowdsec.CrowdsecExtension.CROWDSEC_PORT;
+import static org.assertj.core.api.Assertions.assertThat;
+
+class CrowdsecServiceTest {
+    @RegisterExtension
+    static CrowdsecExtension crowdsecExtension = new CrowdsecExtension();
+
+    private final InetSocketAddress remoteAddress = new 
InetSocketAddress("localhost", 22);
+
+    private CrowdsecService service;
+
+    @BeforeEach
+    void setUpEach() throws IOException {
+        int port = 
crowdsecExtension.getCrowdsecContainer().getMappedPort(CROWDSEC_PORT);
+        service = new CrowdsecService(new CrowdsecClientConfiguration(new 
URL("http://localhost:"; + port + "/v1"), 
CrowdsecClientConfiguration.DEFAULT_API_KEY));
+    }
+
+    @Test
+    void givenIPBannedByCrowdsecDecisionIp() throws IOException, 
InterruptedException {
+        banIP("--ip", "127.0.0.1");
+        var banDecisions = service.findBanDecisions(remoteAddress).block();
+        assertThat(banDecisions).hasSize(1);
+        assertThat(banDecisions.get(0).getScope()).isEqualTo("Ip");
+        assertThat(banDecisions.get(0).getValue()).isEqualTo("127.0.0.1");
+    }
+
+    @Test
+    void givenIPBannedByCrowdsecDecisionIpRange() throws IOException, 
InterruptedException {
+        banIP("--range", "127.0.0.1/24");
+        var banDecisions = service.findBanDecisions(remoteAddress).block();
+        assertThat(banDecisions).hasSize(1);
+        assertThat(banDecisions.get(0).getScope()).isEqualTo("Range");
+        assertThat(banDecisions.get(0).getValue()).isEqualTo("127.0.0.1/24");
+    }
+
+    @Test
+    void givenIPNotBannedByCrowdsecDecisionIp() throws IOException, 
InterruptedException {
+        banIP("--ip", "192.182.39.2");
+        var banDecisions = service.findBanDecisions(remoteAddress).block();
+        assertThat(banDecisions).isEmpty();
+    }
+    @Test
+    void givenIPNotBannedByCrowdsecDecisionIpRange() throws IOException, 
InterruptedException {
+        banIP("--range", "192.182.39.2/24");
+        var banDecisions = service.findBanDecisions(remoteAddress).block();
+        assertThat(banDecisions).isEmpty();
+    }
+
+    private static void banIP(String type, String value) throws IOException, 
InterruptedException {
+        crowdsecExtension.getCrowdsecContainer().execInContainer("cscli", 
"decision", "add", type, value);
+    }
+}
\ No newline at end of file


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to