This is an automated email from the ASF dual-hosted git repository. btellier pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/james-project.git
commit dfc340a2f13ad9d7e77ed5b17f91a0c7922ca416 Author: Benoit TELLIER <[email protected]> AuthorDate: Mon Mar 2 15:35:58 2026 +0100 JAMES-4183 AllowedUnauthenticatedSender Mail hook --- .../servers/partials/configure/smtp-hooks.adoc | 53 ++++++ .../fastfail/AllowedUnauthenticatedSender.java | 179 +++++++++++++++++++++ .../AllowedUnauthenticatedSenderTest.java | 162 +++++++++++++++++++ ...r-allowed-unauthenticated-sender-allow-null.xml | 55 +++++++ .../smtpserver-allowed-unauthenticated-sender.xml | 56 +++++++ 5 files changed, 505 insertions(+) diff --git a/docs/modules/servers/partials/configure/smtp-hooks.adoc b/docs/modules/servers/partials/configure/smtp-hooks.adoc index 1cee3568b4..65ab5254fb 100644 --- a/docs/modules/servers/partials/configure/smtp-hooks.adoc +++ b/docs/modules/servers/partials/configure/smtp-hooks.adoc @@ -2,6 +2,59 @@ This documentation page lists and documents SMTP hooks that can be used within t {server-name} SMTP protocol stack in order to customize the way your SMTP server behaves without of the box components. +== AllowedUnauthenticatedSender + +Some third-party devices and applications — printers, scanners, legacy apps — cannot easily support SMTP +authentication. A common workaround is to whitelist their IP addresses via `<authorizedAddresses>` so they +can relay without credentials. The downside is that any sender address is then accepted from those IPs. + +`AllowedUnauthenticatedSender` closes this gap: it restricts which `MAIL FROM` addresses may be used by +unauthenticated connections that benefit from the IP whitelist. It has no effect on authenticated sessions. + +The hook activates only when both conditions are true: + +* relaying is allowed for the remote IP (i.e. the IP is in `<authorizedAddresses>`) +* no user is authenticated on the session + +Configuration options: + +* *allowNullSender* (optional, default `false`): whether to accept `MAIL FROM: <>` (null sender, used for +bounce messages). Set to `true` if the whitelisted devices need to send delivery status notifications. +* *allowedSenders*: list of `<allowedSender>` elements. +** The element value is the email address that is permitted. +** The optional *fromIps* attribute further restricts which source IPs may use that sender address. + It accepts a comma-separated list of CIDR ranges (e.g. `192.168.1.0/24,10.0.0.5/32`). + When omitted, the sender is allowed from any whitelisted IP. + +Example configuration: + +[source,xml] +.... +<smtpserver enabled="true"> + <...> <!-- The rest of your SMTP configuration --> + <authorizedAddresses>127.0.0.0/8,192.168.1.0/24</authorizedAddresses> + <handlerchain> + <handler class="org.apache.james.smtpserver.fastfail.AllowedUnauthenticatedSender"> + <allowNullSender>false</allowNullSender> + <allowedSenders> + <!-- allowed from any whitelisted IP --> + <allowedSender>[email protected]</allowedSender> + <!-- allowed only from a specific subnet --> + <allowedSender fromIps="192.168.1.0/24">[email protected]</allowedSender> + <!-- allowed from several specific ranges --> + <allowedSender fromIps="172.34.56.0/24,172.34.53.123/32">[email protected]</allowedSender> + </allowedSenders> + </handler> + <handler class="org.apache.james.smtpserver.CoreCmdHandlerLoader"/> + </handlerchain> +</smtpserver> +.... + +Connections from a non-whitelisted IP are not affected by this hook and follow the normal relay rules. + +Unauthenticated connections from a whitelisted IP that present a sender address not covered by any +`<allowedSender>` entry receive a permanent `550` rejection. + == DNSRBLHandler This command handler check against https://www.wikiwand.com/en/Domain_Name_System-based_Blackhole_List[RBL-Lists] diff --git a/server/protocols/protocols-smtp/src/main/java/org/apache/james/smtpserver/fastfail/AllowedUnauthenticatedSender.java b/server/protocols/protocols-smtp/src/main/java/org/apache/james/smtpserver/fastfail/AllowedUnauthenticatedSender.java new file mode 100644 index 0000000000..f4867c29cd --- /dev/null +++ b/server/protocols/protocols-smtp/src/main/java/org/apache/james/smtpserver/fastfail/AllowedUnauthenticatedSender.java @@ -0,0 +1,179 @@ +/**************************************************************** + * 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.smtpserver.fastfail; + +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +import jakarta.inject.Inject; +import jakarta.mail.internet.AddressException; + +import org.apache.commons.configuration2.Configuration; +import org.apache.commons.configuration2.HierarchicalConfiguration; +import org.apache.commons.configuration2.ex.ConfigurationException; +import org.apache.commons.configuration2.tree.ImmutableNode; +import org.apache.james.core.MailAddress; +import org.apache.james.core.MaybeSender; +import org.apache.james.dnsservice.api.DNSService; +import org.apache.james.dnsservice.library.netmatcher.NetMatcher; +import org.apache.james.protocols.api.handler.ProtocolHandler; +import org.apache.james.protocols.smtp.SMTPRetCode; +import org.apache.james.protocols.smtp.SMTPSession; +import org.apache.james.protocols.smtp.dsn.DSNStatus; +import org.apache.james.protocols.smtp.hook.HookResult; +import org.apache.james.protocols.smtp.hook.HookReturnCode; +import org.apache.james.protocols.smtp.hook.MailHook; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.github.fge.lambdas.Throwing; +import com.google.common.base.Splitter; +import com.google.common.collect.ImmutableList; + +/** + * MailHook that restricts which sender addresses can be used by unauthenticated + * connections originating from the IP whitelist (authorizedAddresses). + * + * Useful for printers and 3rd-party apps that rely on IP-based relay whitelisting + * rather than SMTP authentication. + * + * Configuration example: + * <pre>{@code + * <handler class="org.apache.james.smtpserver.fastfail.AllowedUnauthenticatedSender"> + * <allowNullSender>false</allowNullSender> + * <allowedSenders> + * <allowedSender>[email protected]</allowedSender> + * <allowedSender fromIps="192.168.1.0/24,10.0.0.1/32">[email protected]</allowedSender> + * </allowedSenders> + * </handler> + * }</pre> + * + * Entries without {@code fromIps} are allowed from any whitelisted IP. + * Entries with {@code fromIps} are only allowed from the specified subnets. + * + * This hook only activates when relaying is allowed and the session has no + * authenticated user. Authenticated sessions and non-relay connections are + * not affected. + */ +public class AllowedUnauthenticatedSender implements MailHook, ProtocolHandler { + private static final Logger LOGGER = LoggerFactory.getLogger(AllowedUnauthenticatedSender.class); + + private record AllowedSenderEntry(MailAddress sender, Optional<NetMatcher> ipMatcher) { + boolean matches(MailAddress remoteSender, String remoteIp) { + if (!sender.equals(remoteSender)) { + return false; + } + return ipMatcher.map(matcher -> matcher.matchInetNetwork(remoteIp)) + .orElse(true); + } + } + + private final DNSService dnsService; + private List<AllowedSenderEntry> allowedSenders = ImmutableList.of(); + private boolean allowNullSender = false; + + @Inject + public AllowedUnauthenticatedSender(DNSService dnsService) { + this.dnsService = dnsService; + } + + @Override + public void init(Configuration config) throws ConfigurationException { + HierarchicalConfiguration<ImmutableNode> hierarchicalConfig = (HierarchicalConfiguration<ImmutableNode>) config; + + allowedSenders = hierarchicalConfig.configurationsAt("allowedSenders.allowedSender") + .stream() + .map(Throwing.function(this::parseAllowedSenderEntry)) + .collect(Collectors.toList()); + + allowNullSender = config.getBoolean("allowNullSender", false); + + if (allowedSenders.isEmpty()) { + throw new ConfigurationException("AllowedUnauthenticatedSender requires at least one <allowedSender> entry"); + } + } + + private AllowedSenderEntry parseAllowedSenderEntry(HierarchicalConfiguration<ImmutableNode> senderNode) throws ConfigurationException { + return new AllowedSenderEntry(parseMailAddress(senderNode.getString("").trim()), + Optional.ofNullable(senderNode.getString("[@fromIps]", null)) + .map(ips -> Splitter.on(',') + .omitEmptyStrings() + .trimResults() + .splitToList(ips)) + .map(ips -> new NetMatcher(ips, dnsService))); + } + + private static MailAddress parseMailAddress(String emailStr) throws ConfigurationException { + try { + return new MailAddress(emailStr); + } catch (AddressException e) { + throw new ConfigurationException("Invalid email address in allowedSender: " + emailStr, e); + } + } + + @Override + public HookResult doMail(SMTPSession session, MaybeSender sender) { + if (!session.isRelayingAllowed() || session.getUsername() != null) { + return HookResult.DECLINED; + } + String remoteIp = session.getRemoteAddress().getAddress().getHostAddress(); + + if (sender.isNullSender()) { + return validateNullSender(remoteIp); + } + + MailAddress mailSender = sender.asOptional().get(); + + return validateSender(mailSender, remoteIp); + } + + private HookResult validateSender(MailAddress mailSender, String remoteIp) { + boolean isAllowed = allowedSenders.stream() + .anyMatch(entry -> entry.matches(mailSender, remoteIp)); + + if (isAllowed) { + LOGGER.debug("Unauthenticated sender {} from {} is allowed", mailSender.asString(), remoteIp); + return HookResult.DECLINED; + } + + LOGGER.info("Unauthenticated sender {} from {} rejected: not in allowed senders list", mailSender.asString(), remoteIp); + return HookResult.builder() + .hookReturnCode(HookReturnCode.deny()) + .smtpReturnCode(SMTPRetCode.MAILBOX_PERM_UNAVAILABLE) + .smtpDescription(DSNStatus.getStatus(DSNStatus.PERMANENT, DSNStatus.SECURITY_AUTH) + + " Sender <" + mailSender.asString() + "> is not allowed for unauthenticated connection from " + remoteIp) + .build(); + } + + private HookResult validateNullSender(String remoteIp) { + if (allowNullSender) { + LOGGER.debug("Unauthenticated sender <> from {} is allowed", remoteIp); + return HookResult.DECLINED; + } else { + LOGGER.info("Unauthenticated sender <> from {} rejected: not in allowed senders list", remoteIp); + return HookResult.builder() + .hookReturnCode(HookReturnCode.deny()) + .smtpReturnCode(SMTPRetCode.MAILBOX_PERM_UNAVAILABLE) + .smtpDescription(DSNStatus.getStatus(DSNStatus.PERMANENT, DSNStatus.SECURITY_AUTH) + + " Sender <> is not allowed for unauthenticated connection from " + remoteIp) + .build(); + } + } +} diff --git a/server/protocols/protocols-smtp/src/test/java/org/apache/james/smtpserver/AllowedUnauthenticatedSenderTest.java b/server/protocols/protocols-smtp/src/test/java/org/apache/james/smtpserver/AllowedUnauthenticatedSenderTest.java new file mode 100644 index 0000000000..348b160f83 --- /dev/null +++ b/server/protocols/protocols-smtp/src/test/java/org/apache/james/smtpserver/AllowedUnauthenticatedSenderTest.java @@ -0,0 +1,162 @@ +/**************************************************************** + * 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.smtpserver; + +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.apache.james.smtpserver.SMTPServerTestSystem.BOB; +import static org.apache.james.smtpserver.SMTPServerTestSystem.PASSWORD; +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.util.Base64; + +import org.apache.commons.net.smtp.SMTPClient; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class AllowedUnauthenticatedSenderTest { + + @Nested + class WithNullSenderForbidden { + private final SMTPServerTestSystem testSystem = new SMTPServerTestSystem(); + + @BeforeEach + void setUp() throws Exception { + testSystem.setUp("smtpserver-allowed-unauthenticated-sender.xml"); + } + + @AfterEach + void tearDown() { + testSystem.smtpServer.destroy(); + } + + @Test + void unauthenticatedSenderShouldBeAcceptedWhenInAllowedList() throws Exception { + SMTPClient smtpProtocol = new SMTPClient(); + InetSocketAddress bindedAddress = testSystem.getBindedAddress(); + smtpProtocol.connect(bindedAddress.getAddress().getHostAddress(), bindedAddress.getPort()); + + smtpProtocol.sendCommand("EHLO localhost"); + smtpProtocol.sendCommand("MAIL FROM: <[email protected]>"); + + assertThat(smtpProtocol.getReplyCode()).isEqualTo(250); + } + + @Test + void unauthenticatedSenderShouldBeRejectedWhenNotInAllowedList() throws Exception { + SMTPClient smtpProtocol = new SMTPClient(); + InetSocketAddress bindedAddress = testSystem.getBindedAddress(); + smtpProtocol.connect(bindedAddress.getAddress().getHostAddress(), bindedAddress.getPort()); + + smtpProtocol.sendCommand("EHLO localhost"); + smtpProtocol.sendCommand("MAIL FROM: <[email protected]>"); + + assertThat(smtpProtocol.getReplyCode()).isEqualTo(550); + } + + @Test + void unauthenticatedSenderWithIpRestrictionShouldBeRejectedFromWrongIp() throws Exception { + // [email protected] is only allowed from 172.34.56.0/24 + // but we are connecting from 127.0.0.1 + SMTPClient smtpProtocol = new SMTPClient(); + InetSocketAddress bindedAddress = testSystem.getBindedAddress(); + smtpProtocol.connect(bindedAddress.getAddress().getHostAddress(), bindedAddress.getPort()); + + smtpProtocol.sendCommand("EHLO localhost"); + smtpProtocol.sendCommand("MAIL FROM: <[email protected]>"); + + assertThat(smtpProtocol.getReplyCode()).isEqualTo(550); + } + + @Test + void nullSenderShouldBeRejectedWhenAllowNullSenderIsFalse() throws Exception { + SMTPClient smtpProtocol = new SMTPClient(); + InetSocketAddress bindedAddress = testSystem.getBindedAddress(); + smtpProtocol.connect(bindedAddress.getAddress().getHostAddress(), bindedAddress.getPort()); + + smtpProtocol.sendCommand("EHLO localhost"); + smtpProtocol.sendCommand("MAIL FROM: <>"); + + assertThat(smtpProtocol.getReplyCode()).isEqualTo(550); + } + + @Test + void authenticatedUserShouldBypassAllowedSenderRestriction() throws Exception { + SMTPClient smtpProtocol = new SMTPClient(); + InetSocketAddress bindedAddress = testSystem.getBindedAddress(); + smtpProtocol.connect(bindedAddress.getAddress().getHostAddress(), bindedAddress.getPort()); + authenticate(smtpProtocol); + + smtpProtocol.sendCommand("EHLO localhost"); + smtpProtocol.sendCommand("MAIL FROM: <[email protected]>"); + + assertThat(smtpProtocol.getReplyCode()).isEqualTo(250); + } + + private void authenticate(SMTPClient smtpProtocol) throws IOException { + smtpProtocol.sendCommand("AUTH PLAIN"); + smtpProtocol.sendCommand(Base64.getEncoder().encodeToString(("\0" + BOB.asString() + "\0" + PASSWORD + "\0").getBytes(UTF_8))); + assertThat(smtpProtocol.getReplyCode()) + .as("authenticated") + .isEqualTo(235); + } + } + + @Nested + class WithNullSenderAllowed { + private final SMTPServerTestSystem testSystem = new SMTPServerTestSystem(); + + @BeforeEach + void setUp() throws Exception { + testSystem.setUp("smtpserver-allowed-unauthenticated-sender-allow-null.xml"); + } + + @AfterEach + void tearDown() { + testSystem.smtpServer.destroy(); + } + + @Test + void nullSenderShouldBeAcceptedWhenAllowNullSenderIsTrue() throws Exception { + SMTPClient smtpProtocol = new SMTPClient(); + InetSocketAddress bindedAddress = testSystem.getBindedAddress(); + smtpProtocol.connect(bindedAddress.getAddress().getHostAddress(), bindedAddress.getPort()); + + smtpProtocol.sendCommand("EHLO localhost"); + smtpProtocol.sendCommand("MAIL FROM: <>"); + + assertThat(smtpProtocol.getReplyCode()).isEqualTo(250); + } + + @Test + void nonNullSenderShouldStillBeCheckedAgainstAllowedList() throws Exception { + SMTPClient smtpProtocol = new SMTPClient(); + InetSocketAddress bindedAddress = testSystem.getBindedAddress(); + smtpProtocol.connect(bindedAddress.getAddress().getHostAddress(), bindedAddress.getPort()); + + smtpProtocol.sendCommand("EHLO localhost"); + smtpProtocol.sendCommand("MAIL FROM: <[email protected]>"); + + assertThat(smtpProtocol.getReplyCode()).isEqualTo(550); + } + } +} diff --git a/server/protocols/protocols-smtp/src/test/resources/smtpserver-allowed-unauthenticated-sender-allow-null.xml b/server/protocols/protocols-smtp/src/test/resources/smtpserver-allowed-unauthenticated-sender-allow-null.xml new file mode 100644 index 0000000000..bb3b5ce0cc --- /dev/null +++ b/server/protocols/protocols-smtp/src/test/resources/smtpserver-allowed-unauthenticated-sender-allow-null.xml @@ -0,0 +1,55 @@ +<?xml version="1.0"?> + +<!-- + 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. + --> + +<!-- Read https://james.apache.org/server/config-smtp-lmtp.html#SMTP_Configuration for further details --> + +<smtpserver enabled="true"> + <bind>0.0.0.0:0</bind> + <connectionBacklog>200</connectionBacklog> + <tls socketTLS="false" startTLS="false"> + <keystore>file://conf/keystore</keystore> + <secret>james72laBalle</secret> + <provider>org.bouncycastle.jce.provider.BouncyCastleProvider</provider> + <algorithm>SunX509</algorithm> + </tls> + <connectiontimeout>360</connectiontimeout> + <connectionLimit>0</connectionLimit> + <connectionLimitPerIP>0</connectionLimitPerIP> + <authorizedAddresses>127.0.0.0/8</authorizedAddresses> + <auth> + <announce>forUnauthorizedAddresses</announce> + <requireSSL>false</requireSSL> + </auth> + <verifyIdentity>false</verifyIdentity> + <maxmessagesize>0</maxmessagesize> + <addressBracketsEnforcement>true</addressBracketsEnforcement> + <smtpGreeting>Apache JAMES awesome SMTP Server</smtpGreeting> + <handlerchain> + <handler class="org.apache.james.smtpserver.fastfail.AllowedUnauthenticatedSender"> + <allowNullSender>true</allowNullSender> + <allowedSenders> + <allowedSender>[email protected]</allowedSender> + </allowedSenders> + </handler> + <handler class="org.apache.james.smtpserver.CoreCmdHandlerLoader"/> + </handlerchain> + <gracefulShutdown>false</gracefulShutdown> +</smtpserver> diff --git a/server/protocols/protocols-smtp/src/test/resources/smtpserver-allowed-unauthenticated-sender.xml b/server/protocols/protocols-smtp/src/test/resources/smtpserver-allowed-unauthenticated-sender.xml new file mode 100644 index 0000000000..d90d20e8ef --- /dev/null +++ b/server/protocols/protocols-smtp/src/test/resources/smtpserver-allowed-unauthenticated-sender.xml @@ -0,0 +1,56 @@ +<?xml version="1.0"?> + +<!-- + 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. + --> + +<!-- Read https://james.apache.org/server/config-smtp-lmtp.html#SMTP_Configuration for further details --> + +<smtpserver enabled="true"> + <bind>0.0.0.0:0</bind> + <connectionBacklog>200</connectionBacklog> + <tls socketTLS="false" startTLS="false"> + <keystore>file://conf/keystore</keystore> + <secret>james72laBalle</secret> + <provider>org.bouncycastle.jce.provider.BouncyCastleProvider</provider> + <algorithm>SunX509</algorithm> + </tls> + <connectiontimeout>360</connectiontimeout> + <connectionLimit>0</connectionLimit> + <connectionLimitPerIP>0</connectionLimitPerIP> + <authorizedAddresses>127.0.0.0/8</authorizedAddresses> + <auth> + <announce>forUnauthorizedAddresses</announce> + <requireSSL>false</requireSSL> + </auth> + <verifyIdentity>false</verifyIdentity> + <maxmessagesize>0</maxmessagesize> + <addressBracketsEnforcement>true</addressBracketsEnforcement> + <smtpGreeting>Apache JAMES awesome SMTP Server</smtpGreeting> + <handlerchain> + <handler class="org.apache.james.smtpserver.fastfail.AllowedUnauthenticatedSender"> + <allowNullSender>false</allowNullSender> + <allowedSenders> + <allowedSender>[email protected]</allowedSender> + <allowedSender fromIps="172.34.56.0/24">[email protected]</allowedSender> + </allowedSenders> + </handler> + <handler class="org.apache.james.smtpserver.CoreCmdHandlerLoader"/> + </handlerchain> + <gracefulShutdown>false</gracefulShutdown> +</smtpserver> --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
