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