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]
