diff --git a/third-party/crowdsec/README.md b/third-party/crowdsec/README.md
index 83714134031..db23dc62c97 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
```
+or
+```
+
+
+
+
+
+```
+
+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/CrowdsecEhloHook.java b/third-party/crowdsec/src/main/java/org/apache/james/crowdsec/CrowdsecEhloHook.java
index a04e35b14a1..d8c856d2ec3 100644
--- a/third-party/crowdsec/src/main/java/org/apache/james/crowdsec/CrowdsecEhloHook.java
+++ b/third-party/crowdsec/src/main/java/org/apache/james/crowdsec/CrowdsecEhloHook.java
@@ -19,37 +19,33 @@
package org.apache.james.crowdsec;
-import static org.apache.james.crowdsec.CrowdsecUtils.isBanned;
-
import java.util.List;
import javax.inject.Inject;
import org.apache.james.crowdsec.client.CrowdsecClientConfiguration;
-import org.apache.james.crowdsec.client.CrowdsecHttpClient;
import org.apache.james.crowdsec.model.CrowdsecDecision;
import org.apache.james.protocols.smtp.SMTPSession;
import org.apache.james.protocols.smtp.hook.HeloHook;
import org.apache.james.protocols.smtp.hook.HookResult;
public class CrowdsecEhloHook implements HeloHook {
- private final CrowdsecHttpClient crowdsecHttpClient;
+ private final CrowdsecService crowdsecService;
@Inject
public CrowdsecEhloHook(CrowdsecClientConfiguration configuration) {
- this.crowdsecHttpClient = new CrowdsecHttpClient(configuration);
+ this.crowdsecService = new CrowdsecService(configuration);
}
@Override
public HookResult doHelo(SMTPSession session, String helo) {
- String ip = session.getRemoteAddress().getAddress().getHostAddress();
- return crowdsecHttpClient.getCrowdsecDecisions()
- .map(decisions -> apply(decisions, ip)).block();
+ return crowdsecService.findBanDecisions(session.getRemoteAddress())
+ .map(this::apply)
+ .block();
}
- private HookResult apply(List decisions, String ip) {
+ private HookResult apply(List decisions) {
return decisions.stream()
- .filter(decision -> isBanned(decision, ip))
.findFirst()
.map(banned -> HookResult.DENY)
.orElse(HookResult.DECLINED);
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 00000000000..0cc3436ee17
--- /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 {
+ private static final Logger LOGGER = LoggerFactory.getLogger(CrowdsecSMTPConnectHandler.class);
+
+ public static final Response NOOP = new Response() {
+
+ @Override
+ public String getRetCode() {
+ return "";
+ }
+
+ @Override
+ public List 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 00000000000..a3b5c950ef3
--- /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> 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/CrowdsecEhloHookTest.java b/third-party/crowdsec/src/test/java/org/apache/james/crowdsec/CrowdsecEhloHookTest.java
index 4033d240a07..d0041d0e9fd 100644
--- a/third-party/crowdsec/src/test/java/org/apache/james/crowdsec/CrowdsecEhloHookTest.java
+++ b/third-party/crowdsec/src/test/java/org/apache/james/crowdsec/CrowdsecEhloHookTest.java
@@ -78,6 +78,6 @@ void givenIPNotBannedByCrowdsecDecisionIpRange() throws IOException, Interrupted
}
private static void banIP(String type, String value) throws IOException, InterruptedException {
- crowdsecExtension.getCrowdsecContainer().execInContainer("cscli", "decision", "add", type, value);
+ crowdsecExtension.banIP(type, value);
}
}
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 14464aca3ee..aabcf676bc5 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 URL getCrowdSecUrl() {
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 00000000000..d1be4f7be17
--- /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 00000000000..384ebb5ddd7
--- /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