You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@james.apache.org by jh...@apache.org on 2024/02/12 18:42:57 UTC
(james-project) 01/02: [JAMES-3897] implements a crowdsec based SMTP connect handler
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 <jh...@codamens.fr>
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: notifications-unsubscribe@james.apache.org
For additional commands, e-mail: notifications-help@james.apache.org