Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions third-party/crowdsec/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<CrowdsecDecision> decisions, String ip) {
private HookResult apply(List<CrowdsecDecision> decisions) {
return decisions.stream()
.filter(decision -> isBanned(decision, ip))
.findFirst()
.map(banned -> HookResult.DENY)
.orElse(HookResult.DECLINED);
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}