This is an automated email from the ASF dual-hosted git repository. quantranhong1999 pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/james-project.git
commit 01d33d1bc535636c79b636493fcf7d5cd7f04047 Author: Quan Tran <[email protected]> AuthorDate: Fri Jul 17 11:50:33 2026 +0700 JAMES-4210 Add POP3 SASL protocol bridge Translate POP3 Base64 challenges and responses to shared SASL exchanges, including cancellation, final server data, and cleanup. --- .../james/protocols/pop3/sasl/Pop3SaslBridge.java | 107 +++++++++++++ .../protocols/pop3/sasl/Pop3SaslBridgeTest.java | 166 +++++++++++++++++++++ 2 files changed, 273 insertions(+) diff --git a/protocols/pop3/src/main/java/org/apache/james/protocols/pop3/sasl/Pop3SaslBridge.java b/protocols/pop3/src/main/java/org/apache/james/protocols/pop3/sasl/Pop3SaslBridge.java new file mode 100644 index 0000000000..ad9d1fffcf --- /dev/null +++ b/protocols/pop3/src/main/java/org/apache/james/protocols/pop3/sasl/Pop3SaslBridge.java @@ -0,0 +1,107 @@ +/**************************************************************** + * 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.protocols.pop3.sasl; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Optional; +import java.util.regex.Pattern; + +import org.apache.james.protocols.api.Response; +import org.apache.james.protocols.api.sasl.SaslExchange; +import org.apache.james.protocols.api.sasl.SaslInitialRequest; +import org.apache.james.protocols.api.sasl.SaslStep; +import org.apache.james.protocols.pop3.POP3Response; + +public class Pop3SaslBridge { + private static final Pattern CANONICAL_BASE64 = Pattern.compile("(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?"); + + /** Converts a POP3 AUTH command into a protocol-neutral SASL initial request. */ + public SaslInitialRequest initialRequest(String mechanismName, Optional<String> initialClientResponse) { + return new SaslInitialRequest(mechanismName, initialClientResponse.map(this::decodeInitialClientResponse)); + } + + /** Encodes a SASL challenge as a POP3 continuation response. */ + public Response challenge(SaslStep.Challenge challenge) { + return continuation(challenge.payload()); + } + + /** Encodes final SASL server data as a POP3 continuation response. */ + public Response successData(SaslStep.Success success) { + return continuation(success.serverData()); + } + + /** Decodes a POP3 client continuation and forwards it to the SASL exchange. */ + public SaslStep onClientResponse(SaslExchange exchange, byte[] line) { + return exchange.onResponse(decodeBase64(stripTrailingCrlf(line))); + } + + /** Detects the RFC 5034 client cancellation marker. */ + public boolean isAbort(byte[] line) { + return "*".equals(stripTrailingCrlf(line)); + } + + /** Detects the empty acknowledgement required after final SASL server data. */ + public boolean isEmptyClientResponse(byte[] line) { + return stripTrailingCrlf(line).isEmpty(); + } + + /** Aborts and releases an active SASL exchange. */ + public void abort(SaslExchange exchange) { + exchange.abort(); + } + + /** Releases an active SASL exchange. */ + public void close(SaslExchange exchange) { + exchange.close(); + } + + private Response continuation(Optional<byte[]> payload) { + String encodedPayload = payload + .map(Base64.getEncoder()::encodeToString) + .orElse(""); + return new POP3Response("+", encodedPayload).immutable(); + } + + private byte[] decodeInitialClientResponse(String value) { + if (value.equals("=")) { + return new byte[0]; + } + return decodeBase64(value); + } + + private byte[] decodeBase64(String value) { + if (!CANONICAL_BASE64.matcher(value).matches()) { + throw new IllegalArgumentException("Invalid Base64 value"); + } + return Base64.getDecoder().decode(value); + } + + private String stripTrailingCrlf(byte[] line) { + String value = new String(line, StandardCharsets.US_ASCII); + if (value.endsWith("\r\n")) { + return value.substring(0, value.length() - 2); + } + if (value.endsWith("\n")) { + return value.substring(0, value.length() - 1); + } + return value; + } +} diff --git a/protocols/pop3/src/test/java/org/apache/james/protocols/pop3/sasl/Pop3SaslBridgeTest.java b/protocols/pop3/src/test/java/org/apache/james/protocols/pop3/sasl/Pop3SaslBridgeTest.java new file mode 100644 index 0000000000..d56c78ce77 --- /dev/null +++ b/protocols/pop3/src/test/java/org/apache/james/protocols/pop3/sasl/Pop3SaslBridgeTest.java @@ -0,0 +1,166 @@ +/**************************************************************** + * 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.protocols.pop3.sasl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Optional; + +import org.apache.james.core.Username; +import org.apache.james.protocols.api.Response; +import org.apache.james.protocols.api.sasl.SaslExchange; +import org.apache.james.protocols.api.sasl.SaslIdentity; +import org.apache.james.protocols.api.sasl.SaslInitialRequest; +import org.apache.james.protocols.api.sasl.SaslStep; +import org.junit.jupiter.api.Test; + +class Pop3SaslBridgeTest { + private static final Username USER = Username.of("[email protected]"); + private static final SaslIdentity IDENTITY = new SaslIdentity(USER, USER); + + private final Pop3SaslBridge testee = new Pop3SaslBridge(); + + private static class RecordingExchange implements SaslExchange { + private byte[] lastClientResponse; + private boolean closed; + + @Override + public SaslStep firstStep() { + return new SaslStep.Challenge(Optional.empty()); + } + + @Override + public SaslStep onResponse(byte[] clientResponse) { + lastClientResponse = clientResponse.clone(); + return new SaslStep.Success(IDENTITY, Optional.empty()); + } + + @Override + public void close() { + closed = true; + } + } + + @Test + void initialRequestShouldDecodeCanonicalBase64() { + SaslInitialRequest request = testee.initialRequest("PLAIN", Optional.of(encoded("initial"))); + + assertThat(request.initialResponse()).hasValueSatisfying(value -> assertThat(value).containsExactly(bytes("initial"))); + } + + @Test + void initialRequestShouldDecodeEqualSignAsEmptyResponse() { + SaslInitialRequest request = testee.initialRequest("PLAIN", Optional.of("=")); + + assertThat(request.initialResponse()).hasValueSatisfying(value -> assertThat(value).isEmpty()); + } + + @Test + void initialRequestShouldRejectMissingPadding() { + assertThatThrownBy(() -> testee.initialRequest("PLAIN", Optional.of("YQ"))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void initialRequestShouldRejectMisplacedPadding() { + assertThatThrownBy(() -> testee.initialRequest("PLAIN", Optional.of("Y=Q="))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void challengeShouldEncodePop3Continuation() { + Response response = testee.challenge(new SaslStep.Challenge(Optional.of(bytes("challenge")))); + + assertThat(response.getLines()).containsExactly("+ " + encoded("challenge")); + } + + @Test + void challengeShouldPreserveSpaceForEmptyPayload() { + Response response = testee.challenge(new SaslStep.Challenge(Optional.empty())); + + assertThat(response.getLines()).containsExactly("+ "); + } + + @Test + void successDataShouldEncodePop3Continuation() { + Response response = testee.successData(new SaslStep.Success(IDENTITY, Optional.of(bytes("server-data")))); + + assertThat(response.getLines()).containsExactly("+ " + encoded("server-data")); + } + + @Test + void onClientResponseShouldDecodeContinuation() { + RecordingExchange exchange = new RecordingExchange(); + + SaslStep result = testee.onClientResponse(exchange, (encoded("response") + "\r\n").getBytes(StandardCharsets.US_ASCII)); + + assertThat(result).isInstanceOf(SaslStep.Success.class); + assertThat(exchange.lastClientResponse).containsExactly(bytes("response")); + } + + @Test + void onClientResponseShouldAcceptEmptyResponse() { + RecordingExchange exchange = new RecordingExchange(); + + testee.onClientResponse(exchange, "\r\n".getBytes(StandardCharsets.US_ASCII)); + + assertThat(exchange.lastClientResponse).isEmpty(); + } + + @Test + void onClientResponseShouldRejectMissingPadding() { + RecordingExchange exchange = new RecordingExchange(); + + assertThatThrownBy(() -> testee.onClientResponse(exchange, "YQ\r\n".getBytes(StandardCharsets.US_ASCII))) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void isAbortShouldOnlyAcceptSingleAsterisk() { + assertThat(testee.isAbort("*\r\n".getBytes(StandardCharsets.US_ASCII))).isTrue(); + assertThat(testee.isAbort("**\r\n".getBytes(StandardCharsets.US_ASCII))).isFalse(); + } + + @Test + void isEmptyClientResponseShouldAcceptEmptyLine() { + assertThat(testee.isEmptyClientResponse("\r\n".getBytes(StandardCharsets.US_ASCII))).isTrue(); + assertThat(testee.isEmptyClientResponse("=\r\n".getBytes(StandardCharsets.US_ASCII))).isFalse(); + } + + @Test + void abortShouldUseCombinedExchangeLifecycleContract() { + RecordingExchange exchange = new RecordingExchange(); + + testee.abort(exchange); + + assertThat(exchange.closed).isTrue(); + } + + private static String encoded(String value) { + return Base64.getEncoder().encodeToString(bytes(value)); + } + + private static byte[] bytes(String value) { + return value.getBytes(StandardCharsets.UTF_8); + } +} --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
