From 32df0d13d1a0abf0c552fc8b3f130d4fe0d05047 Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Fri, 23 Oct 2026 08:45:37 -0500 Subject: [PATCH 01/39] Build warning-free with -warnings-as-errors SMTPConnection reaches NIOAsyncChannel's deprecated unscoped inbound/ outbound accessors through a protocol requirement, so the deliberate use (a pooled connection outlives any executeThenClose scope) no longer breaks a -warnings-as-errors build. Behaviour is unchanged. Tests: bind the held closes in the pool test to their event loop with NIOLoopBound, give a Void? binding an explicit type, and keep the capturing LogHandler building warning-free against both swift-log 1.5 (no LogEvent) and 1.11+ (flat log(...) requirement deprecated). Co-Authored-By: Claude Opus 5.5 --- Sources/PerfectSMTP/SMTPConnection.swift | 48 +++++++++++++++++-- .../DirectMX/DirectMXRetryQueueTests.swift | 2 +- .../SMTPConnectionPoolTests.swift | 10 ++-- .../SMTPMailerDKIMIntegrationTests.swift | 31 +++++++++++- 4 files changed, 79 insertions(+), 12 deletions(-) diff --git a/Sources/PerfectSMTP/SMTPConnection.swift b/Sources/PerfectSMTP/SMTPConnection.swift index 87ad47c..65eba70 100644 --- a/Sources/PerfectSMTP/SMTPConnection.swift +++ b/Sources/PerfectSMTP/SMTPConnection.swift @@ -104,11 +104,12 @@ public final class SMTPConnection: @unchecked Sendable { // Intentional use of the unscoped, deprecated `.inbound`/`.outbound` // accessors -- see this type's doc comment for why // `executeThenClose`'s request-scoped lifetime doesn't fit a - // pooled, reused connection. The two deprecation warnings below are - // deliberately left visible rather than suppressed, as an honest - // signal of this judgment call to anyone reading build output. - self.iterator = asyncChannel.inbound.makeAsyncIterator() - self.outbound = asyncChannel.outbound + // pooled, reused connection. They are reached through + // `UnscopedAsyncChannelParts` (below) so the package builds with + // `-warnings-as-errors`; the judgment call is documented there. + let (inbound, outbound) = unscopedParts(of: asyncChannel) + self.iterator = inbound.makeAsyncIterator() + self.outbound = outbound self.capabilities = Capabilities() self.ehloHostname = ehloHostname self.replyTimeout = replyTimeout @@ -438,3 +439,40 @@ public final class SMTPConnection: @unchecked Sendable { backoffPolicy.classify(reply, attempt: 1) } } + +// MARK: - Unscoped NIOAsyncChannel access + +/// Reaches `NIOAsyncChannel`'s deprecated, unscoped `inbound`/`outbound` +/// accessors without a deprecation warning, so the package builds with +/// `-warnings-as-errors`. `SMTPConnection` needs them because a pooled +/// connection outlives any single `executeThenClose` scope (see its doc +/// comment). The accessors are deprecated in favour of the scoped API, not +/// removed or changed in behaviour; if a future NIO removes them, this is +/// the one place to change. +/// +/// Calling through a protocol requirement rather than the deprecated member +/// itself is what keeps the call site warning-free; the witnesses carry the +/// deprecation so the intent stays visible in the source. +protocol UnscopedAsyncChannelParts { + associatedtype InboundElement: Sendable + associatedtype OutboundElement: Sendable + var unscopedInbound: NIOAsyncChannelInboundStream { get } + var unscopedOutbound: NIOAsyncChannelOutboundWriter { get } +} + +extension NIOAsyncChannel: UnscopedAsyncChannelParts { + typealias InboundElement = Inbound + typealias OutboundElement = Outbound + + @available(*, deprecated, message: "Unscoped access for long-lived pooled connections only.") + var unscopedInbound: NIOAsyncChannelInboundStream { inbound } + + @available(*, deprecated, message: "Unscoped access for long-lived pooled connections only.") + var unscopedOutbound: NIOAsyncChannelOutboundWriter { outbound } +} + +private func unscopedParts( + of channel: C +) -> (NIOAsyncChannelInboundStream, NIOAsyncChannelOutboundWriter) { + (channel.unscopedInbound, channel.unscopedOutbound) +} diff --git a/Tests/PerfectSMTPTests/DirectMX/DirectMXRetryQueueTests.swift b/Tests/PerfectSMTPTests/DirectMX/DirectMXRetryQueueTests.swift index b2d16ee..a173c2e 100644 --- a/Tests/PerfectSMTPTests/DirectMX/DirectMXRetryQueueTests.swift +++ b/Tests/PerfectSMTPTests/DirectMX/DirectMXRetryQueueTests.swift @@ -452,7 +452,7 @@ struct DirectMXRetryQueueTests { // processed entries. Counting live loops also catches a superseded // loop that was never cancelled at all, which would sleep on // entry 1's 30 s target without calling anything. - let oneLoopLeft = try? await waitUntil(timeoutSeconds: 2) { await queue.liveLoopCountForTesting == 1 } + let oneLoopLeft: Void? = try? await waitUntil(timeoutSeconds: 2) { await queue.liveLoopCountForTesting == 1 } let liveLoops = await queue.liveLoopCountForTesting #expect(oneLoopLeft != nil, "\(liveLoops) loop tasks still running after the restart") #expect(await queue.processDueEntriesFromSupersededLoopCountForTesting == 0) diff --git a/Tests/PerfectSMTPTests/SMTPConnectionPoolTests.swift b/Tests/PerfectSMTPTests/SMTPConnectionPoolTests.swift index 1e2ff9f..f315422 100644 --- a/Tests/PerfectSMTPTests/SMTPConnectionPoolTests.swift +++ b/Tests/PerfectSMTPTests/SMTPConnectionPoolTests.swift @@ -625,14 +625,16 @@ private final class OpenSocketTracker: @unchecked Sendable { /// TLS close waiting on the peer's close_notify. private final class CloseHolder: @unchecked Sendable { private let lock = NSLock() - private var held: [(any EventLoop, () -> Void)] = [] + // Each close is bound to its channel's loop: `hold` runs there (it is + // called from `HoldCloseHandler`), and `releaseAll` hops back to it. + private var held: [(any EventLoop, NIOLoopBound<() -> Void>)] = [] private var heldChannels: Set = [] private var holding = true func hold(_ channel: any Channel, _ close: @escaping () -> Void) { let runNow: Bool = lock.withLock { guard holding else { return true } - held.append((channel.eventLoop, close)) + held.append((channel.eventLoop, NIOLoopBound(close, eventLoop: channel.eventLoop))) heldChannels.insert(ObjectIdentifier(channel)) return false } @@ -644,12 +646,12 @@ private final class CloseHolder: @unchecked Sendable { var heldCount: Int { lock.withLock { heldChannels.count } } func releaseAll() { - let released: [(any EventLoop, () -> Void)] = lock.withLock { + let released: [(any EventLoop, NIOLoopBound<() -> Void>)] = lock.withLock { holding = false defer { held = [] } return held } - for (eventLoop, close) in released { eventLoop.execute(close) } + for (eventLoop, close) in released { eventLoop.execute { close.value() } } } } diff --git a/Tests/PerfectSMTPTests/SMTPMailerDKIMIntegrationTests.swift b/Tests/PerfectSMTPTests/SMTPMailerDKIMIntegrationTests.swift index 95cf82b..19ad558 100644 --- a/Tests/PerfectSMTPTests/SMTPMailerDKIMIntegrationTests.swift +++ b/Tests/PerfectSMTPTests/SMTPMailerDKIMIntegrationTests.swift @@ -141,8 +141,8 @@ struct SMTPMailerDKIMIntegrationTests { @Test func misalignedDKIMDomainLogsAWarningButStillSendsSuccessfully() async throws { let capture = CapturingTransport() - let logHandler = CapturingLogHandler() - var logger = Logger(label: "test.dkim.alignment") { _ in logHandler } + let (logHandler, logHandlerFactory) = makeCapturingLogHandler(CapturingLogHandlerMaker.self) + var logger = Logger(label: "test.dkim.alignment", factory: logHandlerFactory) logger.logLevel = .trace let signer = try DKIMSigner( domain: "third-party-esp.example", @@ -203,6 +203,15 @@ private final class CapturingLogHandlerStorage: @unchecked Sendable { } } +/// +/// swift-log 1.11 added `LogHandler.log(event:)` and deprecated the flat +/// `log(level:message:...)` requirement this handler implements, but the +/// package supports swift-log back to 1.5.0, which has no `LogEvent` type. +/// To build warning-free against both, the handler is marked deprecated +/// (which silences the "deprecated default implementation" diagnostic on +/// its conformance) and is only ever constructed through a protocol +/// requirement, so no call site names the deprecated type directly. +@available(*, deprecated, message: "Test-only; see the comment above.") private struct CapturingLogHandler: LogHandler { let storage = CapturingLogHandlerStorage() var metadata: Logger.Metadata = [:] @@ -223,3 +232,21 @@ private struct CapturingLogHandler: LogHandler { storage.record(message.description) } } + +private protocol CapturingLogHandlerMaking { + static func make() -> (storage: CapturingLogHandlerStorage, factory: @Sendable (String) -> any LogHandler) +} + +private enum CapturingLogHandlerMaker: CapturingLogHandlerMaking { + @available(*, deprecated, message: "Test-only; see CapturingLogHandler.") + static func make() -> (storage: CapturingLogHandlerStorage, factory: @Sendable (String) -> any LogHandler) { + let handler = CapturingLogHandler() + return (handler.storage, { _ in handler }) + } +} + +private func makeCapturingLogHandler( + _ maker: Maker.Type +) -> (storage: CapturingLogHandlerStorage, factory: @Sendable (String) -> any LogHandler) { + maker.make() +} From d9ca5adc127f69cc23ed7631bac25350d5daa29d Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Fri, 23 Oct 2026 08:45:37 -0500 Subject: [PATCH 02/39] Widen swift-crypto to 3.4.0..<5.0.0 and raise the swift-nio floor to 2.86.0 The exact swift-crypto pin made resolution fail for any package that also needs another swift-crypto (e.g. root exact 4.5.2 + this package's exact 4.5.1). The _CryptoExtras surface used here (_RSA.Signing PEM/DER init, keySizeInBits, PKCS#1 v1.5 sign/verify) builds and passes the full suite at 3.4.0, 3.15.1, 4.0.0 and 4.5.2. 3.0.0-3.1.0 lack Sendable RSA keys and 3.2.0-3.3.0 lack the unsafe*Representation initializers the tests use. swift-nio 2.65.0 never built: the library needs 2.75.0 (syncOperations.removeHandler(context:)) and the tests 2.86.0 (EmbeddedChannel accepting autoRead). swift-nio-ssl 2.27.0 and swift-log 1.5.0 build and test as declared. Co-Authored-By: Claude Opus 5.5 --- Package.swift | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/Package.swift b/Package.swift index 5c09d24..497725d 100644 --- a/Package.swift +++ b/Package.swift @@ -11,14 +11,21 @@ let package = Package( .library(name: "PerfectSMTP", targets: ["PerfectSMTP"]), ], dependencies: [ - .package(url: "https://github.com/apple/swift-nio.git", from: "2.65.0"), + // 2.86.0: the library itself needs 2.75.0 (`syncOperations + // .removeHandler(context:)`); the tests need 2.86.0, the first + // release whose `EmbeddedChannel` accepts the `autoRead` option. + .package(url: "https://github.com/apple/swift-nio.git", from: "2.86.0"), .package(url: "https://github.com/apple/swift-nio-ssl.git", from: "2.27.0"), .package(url: "https://github.com/apple/swift-log.git", from: "1.5.0"), - // Pinned to an exact version, not `from:` — `_CryptoExtras` (needed - // for RSA-SHA256 DKIM signing) is an underscore-prefixed, semi- - // stable SPI module per plan §4.6, so an unpinned range risks a - // silent behavior/API change under this SPI on a routine update. - .package(url: "https://github.com/apple/swift-crypto.git", exact: "4.5.1"), + // A range, not an exact pin: an exact pin in a library makes + // resolution fail for any app that also needs a different + // swift-crypto. `_CryptoExtras` is underscore-prefixed, but the only + // part used here (`_RSA.Signing` PEM/DER init, `keySizeInBits`, + // PKCS#1 v1.5 sign/verify) is unchanged from 3.4.0 (the first + // release with `Sendable` RSA keys and the `unsafe*Representation` + // initializers the tests use) through 4.5.2; both ends of the range + // are built and tested. Matches Perfect-NIO's own 3.x..<5 range. + .package(url: "https://github.com/apple/swift-crypto.git", "3.4.0"..<"5.0.0"), ], targets: [ // Foundation + Crypto/_CryptoExtras only. No NIO import — value From c45c183ef9922ad44827ed0f69f8e37565f3b50b Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Fri, 23 Oct 2026 08:49:25 -0500 Subject: [PATCH 03/39] Reject CR/LF in InlineResource.contentID The content ID was written into the inline part's Content-ID header verbatim, so "img1\r\nX-Injected: yes" added a header to the part and a blank line could inject part body content. Route it through the same requireNoInjection check as the inline Content-Type. Co-Authored-By: Claude Opus 5.5 --- Sources/PerfectSMTPCore/MIMEComposer.swift | 4 ++++ .../BugRegressionTests.swift | 17 +++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/Sources/PerfectSMTPCore/MIMEComposer.swift b/Sources/PerfectSMTPCore/MIMEComposer.swift index 06283ec..eced799 100644 --- a/Sources/PerfectSMTPCore/MIMEComposer.swift +++ b/Sources/PerfectSMTPCore/MIMEComposer.swift @@ -650,6 +650,10 @@ public struct MIMEComposer: Sendable { private func inlineLeaf(_ resource: InlineResource) throws -> MIMEPart { try Self.requireNoInjection(resource.contentType, field: "inlineResource.contentType") + // `contentID` goes into the part's `Content-ID` header verbatim, so + // a CR/LF in it would inject headers (or, with a blank line, body + // content) into the inline part. + try Self.requireNoInjection(resource.contentID, field: "inlineResource.contentID") var headers: [(name: String, value: String)] = [] if let filename = resource.filename { let name = sanitizedFilename(filename) diff --git a/Tests/PerfectSMTPCoreTests/BugRegressionTests.swift b/Tests/PerfectSMTPCoreTests/BugRegressionTests.swift index e0fc6ed..59940a5 100644 --- a/Tests/PerfectSMTPCoreTests/BugRegressionTests.swift +++ b/Tests/PerfectSMTPCoreTests/BugRegressionTests.swift @@ -244,6 +244,23 @@ struct BugRegressionTests { } } + // `InlineResource.contentID` is embedded raw into the inline part's + // `Content-ID` header, the same shape as `inlineResource.contentType` + // above. Every CR/LF form must be rejected, with or without a filename. + @Test(arguments: ["img1\r\nX-Injected-CID: yes", "img1\nX-Injected-CID: yes", "img1\rX-Injected-CID: yes", "img1\r\n\r\nbody"]) + func inlineResourceContentIDCRLFInjectionAttemptIsRejected(contentID: String) { + for filename in [nil, "logo.png"] { + var message = EmailMessage(from: EmailAddress(address: "ops@example.com")) + message.htmlBody = "

" + message.inlineImages = [ + InlineResource(contentID: contentID, filename: filename, contentType: "image/png", data: Data([0, 1, 2])), + ] + #expect(throws: MIMEComposer.ComposerError.invalidHeaderValue("inlineResource.contentID")) { + _ = try MIMEComposer(message).compose() + } + } + } + // MARK: - Bug #1 class, new vector: bodyContentTypeOverride CRLF injection // // `bodyContentTypeOverride` (added for the LassoPerfectSMTP From dd48d2367704513acd7bb614d1b2ceb31c2bce30 Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Fri, 23 Oct 2026 08:51:11 -0500 Subject: [PATCH 04/39] Refuse AUTH over plaintext relays, and CR/LF in the EHLO hostname RelayTransport sent PLAIN/LOGIN credentials or an XOAUTH2 token over a tls: .none connection without complaint. It now throws RelayTransportError.authenticationRequiresTLS before dialing unless RelayConfig.allowsAuthenticationWithoutTLS is set (for a loopback MTA or a test server). STARTTLS and implicit TLS are unaffected. SMTPConnection wrote the configured EHLO hostname into the command line verbatim; a CR/LF in it would inject commands. It is now checked like the MAIL FROM and RCPT TO addresses. Co-Authored-By: Claude Opus 5.5 --- Sources/PerfectSMTP/RelayTransport.swift | 24 ++++- Sources/PerfectSMTP/SMTPConnection.swift | 3 + .../RelayTransportAuthTLSTests.swift | 88 +++++++++++++++++++ Tests/PerfectSMTPTests/TestHelpers.swift | 5 +- 4 files changed, 116 insertions(+), 4 deletions(-) create mode 100644 Tests/PerfectSMTPTests/RelayTransportAuthTLSTests.swift diff --git a/Sources/PerfectSMTP/RelayTransport.swift b/Sources/PerfectSMTP/RelayTransport.swift index 49979f0..cffbcaa 100644 --- a/Sources/PerfectSMTP/RelayTransport.swift +++ b/Sources/PerfectSMTP/RelayTransport.swift @@ -26,6 +26,12 @@ public struct RelayConfig: Sendable { public var auth: Auth public var ehloHostname: String public var pool: SMTPConnectionPool.Configuration + /// Whether `auth` may be sent over a connection with `tls: .none`. + /// `false` (the default) makes `RelayTransport.send` throw + /// `RelayTransportError.authenticationRequiresTLS` instead of sending + /// credentials or tokens in cleartext. Set it only for a relay on a + /// network you trust (a loopback MTA, a test server). + public var allowsAuthenticationWithoutTLS: Bool public init( host: String, @@ -33,7 +39,8 @@ public struct RelayConfig: Sendable { tls: TLSMode, auth: Auth = .none, ehloHostname: String = "localhost", - pool: SMTPConnectionPool.Configuration = .init() + pool: SMTPConnectionPool.Configuration = .init(), + allowsAuthenticationWithoutTLS: Bool = false ) { self.host = host self.port = port @@ -41,6 +48,7 @@ public struct RelayConfig: Sendable { self.auth = auth self.ehloHostname = ehloHostname self.pool = pool + self.allowsAuthenticationWithoutTLS = allowsAuthenticationWithoutTLS } var mechanism: (any SASLMechanism)? { @@ -53,6 +61,15 @@ public struct RelayConfig: Sendable { } } +/// Configuration errors `RelayTransport.send(_:_:)` throws before opening +/// any connection. +public enum RelayTransportError: Error, Sendable, Equatable { + /// `RelayConfig.auth` is set but `tls` is `.none`, and + /// `allowsAuthenticationWithoutTLS` is `false`: authenticating would + /// send the credentials in cleartext. + case authenticationRequiresTLS +} + /// A `SMTPTransport` that submits to one configured SMTP host via a pooled, /// possibly-authenticated connection. `RelayTransport` owns its pool; each /// `send(_:_:)` call checks out a connection, authenticates it once (pool @@ -99,6 +116,9 @@ public final class RelayTransport: SMTPTransport, Sendable { /// `Documentation/user-guide.md`'s "Handling delivery results and /// retries" section for what that means for callers. public func send(_ envelope: SMTPEnvelope, _ message: SignedMessage) async throws -> [DeliveryResult] { + if config.tls == .none, config.mechanism != nil, !config.allowsAuthenticationWithoutTLS { + throw RelayTransportError.authenticationRequiresTLS + } // FIX (milestone architecture + SMTP-protocol reviews, originally // flagged against `DirectMXTransport.attemptOnHost` but confirmed // to apply equally here): `SMTPConnection.sendMessage` returns @@ -114,7 +134,7 @@ public final class RelayTransport: SMTPTransport, Sendable { // `RelayTransport` has no host-fallback to break, but it still // owns a pool and a circuit breaker whose bookkeeping deserves to // be correct. - try await pool.withConnection(to: key, isHealthy: SMTPConnectionPool.deliveryResultsIndicateHealthyConnection) { connection in + return try await pool.withConnection(to: key, isHealthy: SMTPConnectionPool.deliveryResultsIndicateHealthyConnection) { connection in // A pooled connection is reused across many checkouts; only // authenticate once per connection (most servers reject a // second AUTH on an already-authenticated session). diff --git a/Sources/PerfectSMTP/SMTPConnection.swift b/Sources/PerfectSMTP/SMTPConnection.swift index 65eba70..0766360 100644 --- a/Sources/PerfectSMTP/SMTPConnection.swift +++ b/Sources/PerfectSMTP/SMTPConnection.swift @@ -206,6 +206,9 @@ public final class SMTPConnection: @unchecked Sendable { /// fallback when the server doesn't understand EHLO at all. @discardableResult public func negotiateCapabilities() async throws -> Capabilities { + // The hostname comes from configuration, but it is written into a + // command line verbatim: a CR/LF in it would inject commands. + _ = try HeaderEncoder.rejectHeaderInjection(ehloHostname, field: "EHLO hostname") try await writeLine("EHLO \(ehloHostname)") let reply = try await nextReply() if reply.replyClass == .permanentNegative { diff --git a/Tests/PerfectSMTPTests/RelayTransportAuthTLSTests.swift b/Tests/PerfectSMTPTests/RelayTransportAuthTLSTests.swift new file mode 100644 index 0000000..4842929 --- /dev/null +++ b/Tests/PerfectSMTPTests/RelayTransportAuthTLSTests.swift @@ -0,0 +1,88 @@ +// +// RelayTransportAuthTLSTests.swift +// PerfectSMTPTests +// +// `RelayTransport` refuses to authenticate over a plaintext (`tls: .none`) +// connection unless `RelayConfig.allowsAuthenticationWithoutTLS` is set, +// and `SMTPConnection` refuses an EHLO hostname that would inject a +// command. +// + +import NIOCore +import NIOEmbedded +import Testing +@testable import PerfectSMTP + +struct RelayTransportAuthTLSTests { + private struct DialReached: Error {} + + private actor DialCounter { + private(set) var count = 0 + func increment() { count += 1 } + } + + private func send(_ config: RelayConfig, dials: DialCounter) async throws -> [DeliveryResult] { + let transport = RelayTransport(config: config, group: NIOAsyncTestingEventLoop()) { _ in + await dials.increment() + throw DialReached() + } + let envelope = try SMTPEnvelope(mailFrom: .address("from@sender.example"), recipients: ["rcpt@relay.example"]) + let message = SignedMessage(rfc5322: Array("Subject: hi\r\n\r\nbody".utf8)) + do { + let results = try await transport.send(envelope, message) + await transport.shutdown() + return results + } catch { + await transport.shutdown() + throw error + } + } + + @Test(arguments: [ + RelayConfig.Auth.plain(username: "u", password: "p"), + .login(username: "u", password: "p"), + .xoauth2(username: "u", tokenProvider: { "token" }), + ]) + func authOverPlaintextIsRefusedBeforeDialing(auth: RelayConfig.Auth) async throws { + let dials = DialCounter() + let config = RelayConfig(host: "relay.example", port: 25, tls: .none, auth: auth) + await #expect(throws: RelayTransportError.authenticationRequiresTLS) { + _ = try await send(config, dials: dials) + } + #expect(await dials.count == 0, "credentials must not be offered to a plaintext connection") + } + + @Test func authOverPlaintextIsAllowedWhenExplicitlyOptedIn() async throws { + let dials = DialCounter() + let config = RelayConfig( + host: "127.0.0.1", port: 1025, tls: .none, auth: .plain(username: "u", password: "p"), + allowsAuthenticationWithoutTLS: true + ) + await #expect(throws: (any Error).self) { _ = try await send(config, dials: dials) } + #expect(await dials.count >= 1) + } + + @Test(arguments: [TLSMode.startTLS, .implicit]) + func authOverTLSIsNotAffected(tls: TLSMode) async throws { + let dials = DialCounter() + let config = RelayConfig(host: "relay.example", port: 587, tls: tls, auth: .plain(username: "u", password: "p")) + await #expect(throws: (any Error).self) { _ = try await send(config, dials: dials) } + #expect(await dials.count >= 1) + } + + @Test func noAuthOverPlaintextIsNotAffected() async throws { + let dials = DialCounter() + let config = RelayConfig(host: "relay.example", port: 25, tls: .none) + await #expect(throws: (any Error).self) { _ = try await send(config, dials: dials) } + #expect(await dials.count >= 1) + } + + @Test(arguments: ["client.example.com\r\nRSET", "client\nQUIT", "client\r"]) + func ehloHostnameWithCRLFIsRejectedBeforeAnythingIsWritten(hostname: String) async throws { + let (connection, channel) = try await ConnectionHarness.make(ehloHostname: hostname) + await #expect(throws: HeaderEncoder.HeaderInjectionError.controlCharacterInField("EHLO hostname")) { + _ = try await connection.negotiateCapabilities() + } + #expect(try await channel.readOutbound(as: ByteBuffer.self) == nil) + } +} diff --git a/Tests/PerfectSMTPTests/TestHelpers.swift b/Tests/PerfectSMTPTests/TestHelpers.swift index 4973c82..cbd78bf 100644 --- a/Tests/PerfectSMTPTests/TestHelpers.swift +++ b/Tests/PerfectSMTPTests/TestHelpers.swift @@ -106,7 +106,8 @@ enum ConnectionHarness { /// clock, so no `advanceTime` dance is needed to observe it fire). static func make( replyTimeout: TimeInterval = 300, - dataTerminationTimeout: TimeInterval = 600 + dataTerminationTimeout: TimeInterval = 600, + ehloHostname: String = "client.example.com" ) async throws -> (SMTPConnection, NIOAsyncTestingChannel) { let testingChannel = NIOAsyncTestingChannel() try await testingChannel.testingEventLoop.executeInContext { @@ -118,7 +119,7 @@ enum ConnectionHarness { } let connection = SMTPConnection( asyncChannel: asyncChannel, - ehloHostname: "client.example.com", + ehloHostname: ehloHostname, replyTimeout: replyTimeout, dataTerminationTimeout: dataTerminationTimeout ) From e9a8423b6edd2f62852ac559a1315cb0b6793d84 Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Fri, 23 Oct 2026 08:51:54 -0500 Subject: [PATCH 05/39] Redact passwords from RelayConfig.Auth and SASL descriptions Interpolating a RelayConfig (or its auth, or a SASLPlain/SASLLogin) into a log line or error message printed the password through Swift's default reflection-based description. The library never does this itself, but a caller logging its configuration would leak credentials. Same redaction discipline as SigningKey. Co-Authored-By: Claude Opus 5.5 --- Sources/PerfectSMTP/SASLMechanism.swift | 28 +++++++++++++++++++ .../RelayTransportAuthTLSTests.swift | 17 +++++++++++ 2 files changed, 45 insertions(+) diff --git a/Sources/PerfectSMTP/SASLMechanism.swift b/Sources/PerfectSMTP/SASLMechanism.swift index 63b7fe9..6e1a941 100644 --- a/Sources/PerfectSMTP/SASLMechanism.swift +++ b/Sources/PerfectSMTP/SASLMechanism.swift @@ -146,3 +146,31 @@ public struct XOAuth2: SASLMechanism { public var isComplete: Bool { sentInitial } } + +// Redacted descriptions, so interpolating a mechanism or a relay +// configuration into a log line or error message (`"\(config)"`) never +// prints a password. Without these, Swift's default reflection-based +// description prints every stored property, password included. Same +// discipline as `SigningKey`'s redacted description. +extension SASLPlain: CustomStringConvertible, CustomDebugStringConvertible { + public var description: String { "SASLPlain(username: \(username), password: )" } + public var debugDescription: String { description } +} + +extension SASLLogin: CustomStringConvertible, CustomDebugStringConvertible { + public var description: String { "SASLLogin(username: \(username), password: )" } + public var debugDescription: String { description } +} + +extension RelayConfig.Auth: CustomStringConvertible, CustomDebugStringConvertible { + public var description: String { + switch self { + case .none: return "none" + case .plain(let username, _): return "plain(username: \(username), password: )" + case .login(let username, _): return "login(username: \(username), password: )" + case .xoauth2(let username, _): return "xoauth2(username: \(username), tokenProvider: )" + } + } + + public var debugDescription: String { description } +} diff --git a/Tests/PerfectSMTPTests/RelayTransportAuthTLSTests.swift b/Tests/PerfectSMTPTests/RelayTransportAuthTLSTests.swift index 4842929..c068227 100644 --- a/Tests/PerfectSMTPTests/RelayTransportAuthTLSTests.swift +++ b/Tests/PerfectSMTPTests/RelayTransportAuthTLSTests.swift @@ -86,3 +86,20 @@ struct RelayTransportAuthTLSTests { #expect(try await channel.readOutbound(as: ByteBuffer.self) == nil) } } + +struct CredentialRedactionTests { + @Test func relayConfigAndSASLDescriptionsNeverContainThePassword() { + let secret = "hunter2-SECRET" + let config = RelayConfig(host: "relay.example", port: 587, tls: .startTLS, auth: .plain(username: "u", password: secret)) + let login = RelayConfig(host: "relay.example", port: 587, tls: .startTLS, auth: .login(username: "u", password: secret)) + let rendered = [ + "\(config)", String(reflecting: config), "\(login)", String(reflecting: login), + "\(config.auth)", "\(SASLPlain(username: "u", password: secret))", + String(reflecting: SASLLogin(username: "u", password: secret)), + ] + for text in rendered { + #expect(!text.contains(secret), "\(text)") + } + #expect("\(config.auth)".contains("")) + } +} From 6aba31610e2bfe5d3f2222d1da549af0ae4073a3 Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Fri, 23 Oct 2026 08:53:19 -0500 Subject: [PATCH 06/39] Add CI: Linux Swift 6.2/6.4 and macOS Xcode 26.0.1/26.6, debug and release Same shape as Perfect-CRUD's workflow, without the iOS job: the package declares macOS only, and its transports (SwiftNIO sockets, LocalMTATransport's Process) are server-side. Builds with -warnings-as-errors and runs the hermetic test suite; SWIFT_BACKTRACE is disabled on Linux so a crash fails fast with its own output. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 71 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..546f716 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,71 @@ +name: CI + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + linux: + name: Linux (Swift ${{ matrix.swift }}, ${{ matrix.configuration }}) + runs-on: ubuntu-24.04 + container: swift:${{ matrix.swift }}-noble + timeout-minutes: 30 + env: + # A crashing test should fail fast with its own output, not spend minutes symbolicating. + SWIFT_BACKTRACE: enable=no + strategy: + fail-fast: false + matrix: + # The oldest supported Swift (swift-tools-version 6.2) and the newest. + swift: ["6.2", "6.4"] + configuration: [debug, release] + steps: + - uses: actions/checkout@v7 + + - name: Toolchain + run: swift --version + + - name: Build (warnings are errors) + run: | + flags="-c ${{ matrix.configuration }} -Xswiftc -warnings-as-errors" + # @testable imports need testing enabled in an optimized build. + [ "${{ matrix.configuration }}" = release ] && flags="$flags -Xswiftc -enable-testing" + echo "FLAGS=$flags" >> "$GITHUB_ENV" + swift build --build-tests $flags + + # Hermetic: every test runs against in-process fakes on 127.0.0.1, with no DNS, + # internet or external SMTP server. + - name: Test + run: swift test $FLAGS + + macos: + name: macOS (Xcode ${{ matrix.xcode }}, ${{ matrix.configuration }}) + runs-on: macos-26 + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + # The oldest Xcode with Swift 6.2, and the runner's default. + xcode: ["26.0.1", "26.6"] + configuration: [debug, release] + steps: + - uses: actions/checkout@v7 + + - name: Select Xcode + run: | + sudo xcode-select -s /Applications/Xcode_${{ matrix.xcode }}.app + swift --version + + - name: Build (warnings are errors) + run: | + flags="-c ${{ matrix.configuration }} -Xswiftc -warnings-as-errors" + # @testable imports need testing enabled in an optimized build. + [ "${{ matrix.configuration }}" = release ] && flags="$flags -Xswiftc -enable-testing" + echo "FLAGS=$flags" >> "$GITHUB_ENV" + swift build --build-tests $flags + + - name: Test + run: swift test $FLAGS From d6e9282e19962b8bf5e0875d6bbf1e4760926161 Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Fri, 23 Oct 2026 08:54:54 -0500 Subject: [PATCH 07/39] Bound the whole MTA-STS policy fetch with a deadline URLSessionMTASTSFetcher relied on URLSession's timeouts: the request timeout only bounds the gap between bytes and the resource timeout defaults to seven days. A policy host that sends headers and then trickles the body kept the fetch, and every DirectMX send awaiting that domain's policy, running indefinitely (repro: one byte per 100 ms was still being read when a 60 s test time limit cancelled it). The fetcher now takes a timeout (default 60 s) covering connect to last body byte, and throws MTASTSHTTPFetchError.timedOut when it passes. Co-Authored-By: Claude Opus 5.5 --- .../MTASTS/MTASTSHTTPFetching.swift | 44 +++++++++-- .../MTASTSHTTPFetcherTimeoutTests.swift | 74 +++++++++++++++++++ 2 files changed, 112 insertions(+), 6 deletions(-) create mode 100644 Tests/PerfectSMTPTests/MTASTS/MTASTSHTTPFetcherTimeoutTests.swift diff --git a/Sources/PerfectSMTP/MTASTS/MTASTSHTTPFetching.swift b/Sources/PerfectSMTP/MTASTS/MTASTSHTTPFetching.swift index d2da5e7..b06f911 100644 --- a/Sources/PerfectSMTP/MTASTS/MTASTSHTTPFetching.swift +++ b/Sources/PerfectSMTP/MTASTS/MTASTSHTTPFetching.swift @@ -71,6 +71,9 @@ public enum MTASTSHTTPFetchError: Error, Sendable, Equatable { /// expected for an `https://` URL in practice, handled defensively /// rather than force-cast. case notAnHTTPResponse + /// The whole fetch (connect, TLS, headers and body) did not finish + /// within the fetcher's `timeout`. + case timedOut } /// The production `MTASTSHTTPFetching` implementation: a plain `URLSession` @@ -120,6 +123,7 @@ public enum MTASTSHTTPFetchError: Error, Sendable, Equatable { /// used there, not the session itself. public struct URLSessionMTASTSFetcher: MTASTSHTTPFetching { private let session: URLSession + private let timeout: TimeInterval /// - Parameter session: On Darwin, the session every fetch runs on. On /// platforms that use `FoundationNetworking` (Linux), only its @@ -127,19 +131,30 @@ public struct URLSessionMTASTSFetcher: MTASTSHTTPFetching { /// session whose delegate refuses redirects, so a delegate set on /// `session` (e.g. one handling authentication challenges) is not /// consulted there. - public init(session: URLSession = .shared) { + /// - Parameter timeout: Seconds the whole fetch may take, from connect + /// to the last body byte; past it the fetch is cancelled and throws + /// `MTASTSHTTPFetchError.timedOut`. `URLSession`'s own request + /// timeout only bounds the gap between bytes, and its resource + /// timeout defaults to seven days, so without this a policy host + /// trickling bytes could hold up every direct-MX send to its domain. + public init(session: URLSession = .shared, timeout: TimeInterval = 60) { self.session = session + self.timeout = timeout } public func fetch(url: URL) async throws -> MTASTSHTTPResponse { var request = URLRequest(url: url) request.httpMethod = "GET" request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData - #if canImport(FoundationNetworking) - let (data, response) = try await RedirectRefusingFetchDelegate.fetch(request, configuration: session.configuration) - #else - let (data, response) = try await session.data(for: request, delegate: RedirectRefusingTaskDelegate()) - #endif + request.timeoutInterval = max(0, timeout) + let session = self.session + let (data, response) = try await Self.withDeadline(timeout) { [request] in + #if canImport(FoundationNetworking) + try await RedirectRefusingFetchDelegate.fetch(request, configuration: session.configuration) + #else + try await session.data(for: request, delegate: RedirectRefusingTaskDelegate()) + #endif + } guard let http = response as? HTTPURLResponse else { throw MTASTSHTTPFetchError.notAnHTTPResponse } @@ -149,6 +164,23 @@ public struct URLSessionMTASTSFetcher: MTASTSHTTPFetching { body: Array(data) ) } + + /// Runs `operation`, cancelling it and throwing `.timedOut` if it is + /// still running after `seconds`. + private static func withDeadline( + _ seconds: TimeInterval, _ operation: @escaping @Sendable () async throws -> T + ) async throws -> T { + try await withThrowingTaskGroup(of: T.self) { race in + race.addTask { try await operation() } + race.addTask { + try await Task.sleep(nanoseconds: UInt64(min(max(0, seconds), 86_400) * 1_000_000_000)) + throw MTASTSHTTPFetchError.timedOut + } + defer { race.cancelAll() } + guard let result = try await race.next() else { throw MTASTSHTTPFetchError.timedOut } + return result + } + } } /// FIX #3's mechanism: a per-request `URLSessionTaskDelegate` that refuses diff --git a/Tests/PerfectSMTPTests/MTASTS/MTASTSHTTPFetcherTimeoutTests.swift b/Tests/PerfectSMTPTests/MTASTS/MTASTSHTTPFetcherTimeoutTests.swift new file mode 100644 index 0000000..a24b3af --- /dev/null +++ b/Tests/PerfectSMTPTests/MTASTS/MTASTSHTTPFetcherTimeoutTests.swift @@ -0,0 +1,74 @@ +// +// MTASTSHTTPFetcherTimeoutTests.swift +// PerfectSMTPTests +// +// `URLSessionMTASTSFetcher`'s `timeout` bounds the whole fetch. A policy +// host that sends its headers and then trickles the body one byte at a +// time never trips `URLSession`'s between-bytes request timeout, so only +// the fetcher's own deadline ends the fetch. Runs against a local server +// on 127.0.0.1. +// + +import Foundation +import NIOCore +import NIOPosix +import Testing +@testable import PerfectSMTP + +struct MTASTSHTTPFetcherTimeoutTests { + @Test(.timeLimit(.minutes(1))) + func aTricklingPolicyHostIsCutOffAtTheFetchersDeadline() async throws { + let group = MultiThreadedEventLoopGroup(numberOfThreads: 1) + let channel = try await ServerBootstrap(group: group) + .childChannelInitializer { channel in + channel.eventLoop.makeCompletedFuture { + try channel.pipeline.syncOperations.addHandler(TricklingHTTPHandler()) + } + } + .bind(host: "127.0.0.1", port: 0).get() + let port = try #require(channel.localAddress?.port) + + let fetcher = URLSessionMTASTSFetcher(timeout: 1) + let url = try #require(URL(string: "http://127.0.0.1:\(port)/.well-known/mta-sts.txt")) + let started = Date() + await #expect(throws: MTASTSHTTPFetchError.timedOut) { + _ = try await fetcher.fetch(url: url) + } + let elapsed = Date().timeIntervalSince(started) + + try await channel.close() + try await group.shutdownGracefully() + #expect(elapsed < 10, "the fetch should end at its 1 s deadline, took \(elapsed) s") + } +} + +/// Answers any request with a 200 whose 100 kB body arrives one byte every +/// 100 ms, so bytes never stop for long enough to trip an idle timeout. +private final class TricklingHTTPHandler: ChannelInboundHandler, @unchecked Sendable { + typealias InboundIn = ByteBuffer + typealias OutboundOut = ByteBuffer + // Event-loop confined: every callback, and the repeated task, runs on + // the child channel's loop. + private var started = false + private var task: RepeatedTask? + + func channelRead(context: ChannelHandlerContext, data: NIOAny) { + guard !started else { return } + started = true + let head = "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 100000\r\n\r\n" + context.writeAndFlush(Self.wrapOutboundOut(ByteBuffer(string: head)), promise: nil) + let bound = NIOLoopBound(context, eventLoop: context.eventLoop) + task = context.eventLoop.scheduleRepeatedTask(initialDelay: .milliseconds(100), delay: .milliseconds(100)) { _ in + bound.value.writeAndFlush(Self.wrapOutboundOut(ByteBuffer(string: "x")), promise: nil) + } + } + + func channelInactive(context: ChannelHandlerContext) { + task?.cancel() + context.fireChannelInactive() + } + + func handlerRemoved(context: ChannelHandlerContext) { + task?.cancel() + } +} From 8ca1dfc3c1b9bca93dbd144a69186ec94c3b069d Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Fri, 23 Oct 2026 08:59:28 -0500 Subject: [PATCH 08/39] Update README, user guide and Chinese README for 6.0.0 README: install from 6.0.0, the two products, Swift 6.2+ on macOS 12+ and Linux, a quick start verified by building it as a scratch consumer, the security defaults, and a 5.x migration table. The user guide gains the plaintext-AUTH refusal, TLS verification and MTA-STS fetch timeout, and drops its stale test count and no-CI note. README.zh_CN.md still describes the current API, so it keeps its content with corrected platform, requirements and testing lines. The rewrite plan stays as design history (source comments cite its sections) with a status line pointing readers at the guide. Co-Authored-By: Claude Opus 5.5 --- Documentation/swift6-nio-rewrite-plan.md | 2 +- Documentation/user-guide.md | 38 ++++-- README.md | 155 +++++++++++++++++------ README.zh_CN.md | 38 ++++-- 4 files changed, 167 insertions(+), 66 deletions(-) diff --git a/Documentation/swift6-nio-rewrite-plan.md b/Documentation/swift6-nio-rewrite-plan.md index 58656f1..46354ad 100644 --- a/Documentation/swift6-nio-rewrite-plan.md +++ b/Documentation/swift6-nio-rewrite-plan.md @@ -1,7 +1,7 @@ # Perfect-SMTP Swift 6.2 / SwiftNIO Rewrite — Plan Date: 2026-07-18 -Status: **Planned, reviewed, not yet implemented.** This document is the actionable output of a design → architecture → parallel expert review cycle (application architect, Swift-concurrency expert, SMTP-protocol/adversarial reviewer). No code has been written yet. All work described here happens on feature branches, never directly on `master`. +Status: **Implemented; shipped in 6.0.0. Kept as design history.** Source comments cite this plan's sections ("plan §4.3") for the reasoning behind a choice, so it stays here unedited. Several review passes changed the final API after it was written: for how the shipped code actually behaves, read [the user guide](user-guide.md) and the README, not this plan. Original status, as written: *Planned, reviewed, not yet implemented.* This document is the actionable output of a design → architecture → parallel expert review cycle (application architect, Swift-concurrency expert, SMTP-protocol/adversarial reviewer). No code has been written yet. All work described here happens on feature branches, never directly on `master`. ## 1. Why this library needs a full rewrite, not a modernization pass diff --git a/Documentation/user-guide.md b/Documentation/user-guide.md index 47616d8..2dab408 100644 --- a/Documentation/user-guide.md +++ b/Documentation/user-guide.md @@ -141,7 +141,14 @@ freshly-dialed connection once and reusing it across sends. `tls` is a `.startTLS` (explicit upgrade, typically port 587 — mandatory once requested: if the server doesn't advertise STARTTLS, the connection fails rather than silently continuing in plaintext), or `.implicit` (TLS from the -first byte, typically port 465). +first byte, typically port 465). Both TLS modes verify the server's +certificate chain and hostname. + +`tls: .none` together with `auth` is refused: `send` throws +`RelayTransportError.authenticationRequiresTLS` before opening a +connection, so credentials are never sent in cleartext by accident. For a +relay you trust (a loopback MTA, a MailHog/smtp4dev test server that wants +AUTH), opt in with `allowsAuthenticationWithoutTLS: true`. ### `LocalMTATransport` @@ -229,6 +236,12 @@ just-expired token. `SASLCramMD5`/`SASLScramSHA256` are not implemented (deliberately deferred, per the rewrite plan). +AUTH is only sent over TLS unless you set +`RelayConfig.allowsAuthenticationWithoutTLS` (see +[`RelayTransport`](#relaytransport)). `RelayConfig.Auth`, `SASLPlain` and +`SASLLogin` print redacted descriptions, so logging a configuration never +prints the password. + ## DKIM signing ```swift @@ -351,6 +364,12 @@ restarts), and `DirectMXTransport` applies it automatically: configured a provider at all: plain opportunistic delivery per `tlsPolicy`. +The policy fetch is bounded as a whole: `URLSessionMTASTSFetcher` (the +default `httpFetcher`) gives up after `timeout` seconds (default 60) from +connect to the last body byte and throws `MTASTSHTTPFetchError.timedOut`, +so a slow policy host can't hold up delivery to its domain indefinitely. +Redirects are never followed (RFC 8461 §3.3). + MTA-STS's SSRF-safety story mirrors `allowPrivateAddresses` above: the HTTPS policy-file fetch target is address-checked the same way when you pass `addressResolver` to `MTASTSPolicyManager.init` (as in the example @@ -504,21 +523,22 @@ inspect what's still pending via `pendingRetryEntries()`. ## Testing your integration -`swift test` runs the full suite (323 tests) with zero external +`swift test` runs the full suite (about 400 tests) with zero external dependencies and zero environment variables — including tests that -exercise a real STARTTLS handshake and a full `DirectMXTransport` delivery -against an in-process fake SMTP server bound to `127.0.0.1`. None of this -touches the real network. +exercise a real STARTTLS handshake, a full `DirectMXTransport` delivery, +the DNS resolver and the MTA-STS fetcher against in-process fake servers +bound to `127.0.0.1`. None of it needs the internet or DNS: the 6.0.0 +release checks ran it in Linux containers with no network beyond loopback. **There is no built-in live-integration test tier against a real SMTP server**, despite one being described in the original rewrite plan (`Documentation/swift6-nio-rewrite-plan.md` §4.1/§5: an `SMTP_TESTS=1`- gated CI job against a MailHog/smtp4dev service container). That tier was never implemented — there's no `SMTP_TESTS` (or similarly-named) reference -anywhere in `Tests/`, and this repository has no `.github/workflows` -directory at all. If you want to verify your own integration against a -real (or realistic fake) SMTP server, the straightforward approach is to -run [MailHog](https://github.com/mailhog/MailHog) or +anywhere in `Tests/`, and no test is opt-in. If you want to verify your own +integration against a real (or realistic fake) SMTP server, the +straightforward approach is to run +[MailHog](https://github.com/mailhog/MailHog) or [smtp4dev](https://github.com/rnwood/smtp4dev) locally and point a `RelayTransport` at it: diff --git a/README.md b/README.md index 218d679..ad39ffe 100644 --- a/README.md +++ b/README.md @@ -2,17 +2,17 @@

- Swift 6.2 + Swift 6.2+ - - Platforms macOS 12+ + + Platforms macOS 12+ | Linux License Apache 2.0

-Perfect-SMTP is a from-scratch Swift 6.2 / SwiftNIO SMTP client. It is not a +Perfect-SMTP is a from-scratch Swift 6 / SwiftNIO SMTP client. It is not a wrapper around libcurl or any other mail library — it drives the SMTP wire protocol itself, including its own STARTTLS state machine, connection pooling, DKIM signing, and MTA-STS policy enforcement. @@ -22,23 +22,16 @@ hand off to a local MTA like Postfix/sendmail, or resolve MX records and deliver directly), so you can pick the one that matches how you already operate mail, or let Perfect-SMTP be the terminal MTA itself. -> This is a complete rewrite of the pre-2026 libcurl-based Perfect-SMTP. If -> you used the old `EMail`/`SMTPClient`/`Recipient` API, see -> [Migrating from the old Perfect-SMTP](Documentation/user-guide.md#migrating-from-the-old-perfect-smtp) -> in the user guide — this is not a drop-in upgrade. The old version is -> preserved on the [`legacy`](../../tree/legacy) branch. +> 6.0.0 is a complete rewrite of the libcurl-based Perfect-SMTP 5.x. It is +> not a drop-in upgrade; see [Migrating from 5.x](#migrating-from-5x). The +> old code is preserved on the [`legacy`](../../tree/legacy) branch and the +> `5.0.0` tag. This package is domain-agnostic by design — it has no Lasso-specific code -and no Lasso dependency. It is, however, a **core dependency**: +and no Lasso dependency. It is, however, a **core dependency** of [Perfect-Lasso](https://github.com/taplin/Perfect-Lasso) — a Swift -reimplementation of the Lasso language, still in active development and -not yet production-ready, though validated against real code from -multiple production e-commerce sites — depends on this package directly -to implement its `email_send` tag, which sends real outbound email during -that validation testing. (There is a separate, unrelated -in-progress target called `LassoPerfectSMTP` being built *inside* the -Perfect-Lasso repo on another branch — that is not this package and does not -depend on it.) +reimplementation of the Lasso language, still in active development — +which uses it to implement its `email_send` tag. ## Features @@ -71,21 +64,31 @@ For anything beyond the basics below, see the ## Requirements -- Swift 6.2 toolchain (`swift-tools-version: 6.2`, `.swiftLanguageMode(.v6)`) -- macOS 12 or later (`Package.swift` declares `platforms: [.macOS(.v12)]`) +- Swift 6.2 or later (`swift-tools-version: 6.2`, Swift 6 language mode) +- macOS 12 or later, or Linux (CI covers Ubuntu 24.04 with Swift 6.2 and 6.4) + +iOS, tvOS and watchOS are not supported: this is a server-side package +(SwiftNIO sockets, and `LocalMTATransport` launches a local `sendmail`). + +Dependencies are all Apple/SSWG packages: `swift-nio`, `swift-nio-ssl`, +`swift-log` and `swift-crypto` (the last for DKIM; its version range is +`3.4.0..<5.0.0`, so it resolves alongside packages on either swift-crypto +major). ## Installation -Add the package to your `Package.swift`. No tagged releases exist yet, so pin a branch or a -specific commit rather than a version range: +Add the package to your `Package.swift`: ```swift -.package(url: "https://github.com/PerfectlySoft/Perfect-SMTP.git", branch: "main") +.package(url: "https://github.com/PerfectlySoft/Perfect-SMTP.git", from: "6.0.0") ``` -and depend on the `PerfectSMTP` product (it re-exports `PerfectSMTPCore`, -which you only need directly if you want to compose/sign messages without -sending them): +It has two products: + +| Product | Contents | +|---|---| +| `PerfectSMTP` | Everything: the transports (`RelayTransport`, `LocalMTATransport`, `DirectMXTransport`), `SMTPMailer`, the connection pool, the DNS resolver and MTA-STS. Re-exports `PerfectSMTPCore`. | +| `PerfectSMTPCore` | Message model, MIME composer, header/body encoders and the DKIM signer, with no networking. Depend on it directly only if you compose or sign messages without sending them. | ```swift .target( @@ -102,8 +105,8 @@ Send one email through an existing SMTP relay (a corporate MTA or an ESP like SendGrid/Postmark/SES): ```swift -import PerfectSMTP import NIOPosix +import PerfectSMTP let group = MultiThreadedEventLoopGroup(numberOfThreads: 1) @@ -128,34 +131,102 @@ for result in results { print(result.recipient, result.outcome) } +await transport.shutdown() try await group.shutdownGracefully() ``` +`MultiThreadedEventLoopGroup` comes from SwiftNIO's `NIOPosix`, which +PerfectSMTP already depends on; if your build requires every imported module +to be a declared dependency, add +`.package(url: "https://github.com/apple/swift-nio.git", from: "2.86.0")` and +`.product(name: "NIOPosix", package: "swift-nio")`. + That's it for the basic case. For DKIM signing, direct-MX delivery, authentication options, bulk sending, and deliverability headers, see the **[user guide](Documentation/user-guide.md)**. +## Security defaults + +You get these without configuring anything: + +- **TLS certificates are verified.** Every TLS connection (`.startTLS`, + `.implicit`, and `DirectMXTransport`'s STARTTLS attempts) uses + `TLSConfiguration.makeClientConfiguration()`: full chain and hostname + verification. +- **`.startTLS` never downgrades.** If the server doesn't advertise + `STARTTLS`, the connection fails with `SMTPError.starttlsRequired` + instead of continuing in plaintext. Bytes injected around the upgrade + (the CVE-2026-41319 class) fail the connection with + `.starttlsInjection`. +- **No AUTH in cleartext.** A `RelayConfig` with credentials and + `tls: .none` throws `RelayTransportError.authenticationRequiresTLS` + before dialing. Set `allowsAuthenticationWithoutTLS: true` only for a + relay you trust, such as a loopback MTA or a test server. +- **No header or command injection.** Addresses, the EHLO hostname, extra + headers, message IDs, content types and content IDs are rejected if they + contain CR, LF or other control characters; subjects and display names + have them stripped and are RFC 2047-encoded. The DATA payload is + dot-stuffed. Bcc addresses only ever go into `RCPT TO`, never a header. +- **Every network step has a timeout:** TCP connect (30 s), each SMTP reply + including the greeting, EHLO, STARTTLS and the TLS handshake (300 s), the + final reply after DATA (600 s), each DNS query (5 s), the MTA-STS policy + fetch (60 s), and the `sendmail` process (60 s). All are configurable. +- **No secrets in logs.** The library logs only the DKIM alignment warning + and MTA-STS decisions (domains, hosts, errors), never message bodies or + credentials. `RelayConfig.Auth`, `SASLPlain`, `SASLLogin` and + `SigningKey` print redacted descriptions. +- **Direct MX delivery** refuses private, loopback and link-local + addresses unless `allowPrivateAddresses` is set, uses opportunistic + STARTTLS that falls back to plaintext only when a server doesn't offer + STARTTLS (never after a failed handshake), and honors MTA-STS `enforce` + policies when you configure a policy provider. + +## Migrating from 5.x + +5.x wrapped libcurl through Perfect-CURL: you built an `SMTPClient` with a +URL and credentials, filled in an `EMail`, and called a blocking +`send(completion:)`. 6.0.0 shares no types with it, so a port is a rewrite +of the call site, not a find-and-replace: + +| 5.x | 6.0.0 | +|---|---| +| `SMTPClient(url: "smtp://host", username:, password:)` | `RelayTransport(config: RelayConfig(host:port:tls:auth:), group:)` | +| `smtps://` URL | `tls: .implicit` (port 465); `tls: .startTLS` for port 587 | +| `Recipient(name:address:)` | `EmailAddress(displayName:address:)` | +| `EMail(client:)` | `EmailMessage(from:)`, sent with `SMTPMailer(transport:).send(_:envelopeFrom:)` | +| `email.content` / `.html` / `.text` | `message.htmlBody` / `message.textBody` (set both for multipart/alternative) | +| `email.bcc` | the `bcc:` parameter of `send` (never written to a header) | +| `email.attachments` (file paths) | `message.attachments` (`Attachment(filename:contentType:data:)`) | +| `try email.send { code, header, body in }` | `let results = try await mailer.send(...)`, one `DeliveryResult` per recipient | + +What changes in behavior: sending is `async`; the envelope sender is +explicit (`envelopeFrom: .address(...)` or `.null`); STARTTLS is mandatory +when requested; credentials over a plaintext connection are refused (see +above); and the 5.x bugs that wrote a `Bcc:` header to every recipient and +emitted a mislabeled quoted-printable subject are gone. There is no libcurl +or OpenSSL system dependency any more, so the old `libssl-dev`/`libcurl` +install notes no longer apply. + +The [user guide](Documentation/user-guide.md#migrating-from-the-old-perfect-smtp) +explains why this is a rewrite rather than an update. + ## Testing ``` swift test ``` -346 tests (188 in `PerfectSMTPTests`, 158 in `PerfectSMTPCoreTests`) run with -no external services and no environment variables — this includes tests -that open real loopback sockets (a STARTTLS handshake and a full DirectMX -delivery each run against an in-process fake SMTP server on `127.0.0.1`), -but nothing here talks to the real network or a real mail server. - -Note: the original rewrite plan (`Documentation/swift6-nio-rewrite-plan.md` -§4.1/§5) describes an additional `SMTP_TESTS=1`-gated live-integration tier -against a MailHog/smtp4dev CI service container. That tier was never built — -there is no such environment variable referenced anywhere in `Tests/`, and -this repository has no CI workflow files at all. If you need to verify -against a real SMTP server, point a `RelayTransport` or `DirectMXTransport` -at a local MailHog/smtp4dev instance yourself; see -[Testing your integration](Documentation/user-guide.md#testing-your-integration) -in the user guide. +The whole suite is hermetic: no environment variables, no internet, no DNS +and no external mail server. Tests that need sockets (a real STARTTLS +handshake, a full DirectMX delivery, the DNS resolver, the MTA-STS +fetcher) run against in-process fake servers on `127.0.0.1`. CI runs it on +Linux and macOS in debug and release with `-warnings-as-errors`. + +There is no live-integration tier. To check your own setup against a real +SMTP server, point a `RelayTransport` at a local +[MailHog](https://github.com/mailhog/MailHog) or +[smtp4dev](https://github.com/rnwood/smtp4dev) instance; see +[Testing your integration](Documentation/user-guide.md#testing-your-integration). ## License diff --git a/README.zh_CN.md b/README.zh_CN.md index 8fedd40..021e1db 100644 --- a/README.zh_CN.md +++ b/README.zh_CN.md @@ -23,10 +23,10 @@

- Swift 6.2 + Swift 6.2+ - - Platforms macOS 26+ + + Platforms macOS 12+ | Linux License Apache 2.0 @@ -49,10 +49,11 @@ libcurl 或其他邮件库的封装——它自己实现了 SMTP 线路协议, 或自行解析 MX 记录直接投递),你可以选择与现有邮件运维方式匹配的一种,也可以 让 Perfect-SMTP 本身充当最终的 MTA。 -> 这是 2026 年之前基于 libcurl 的旧版 Perfect-SMTP 的完整重写。如果你使用过旧版 -> `EMail`/`SMTPClient`/`Recipient` API,请参阅用户指南中的 +> 6.0.0 是对基于 libcurl 的 Perfect-SMTP 5.x 的完整重写。如果你使用过旧版 +> `EMail`/`SMTPClient`/`Recipient` API,请参阅英文 README 的 +> [Migrating from 5.x](README.md#migrating-from-5x) 一节和用户指南中的 > [从旧版 Perfect-SMTP 迁移](Documentation/user-guide.md#migrating-from-the-old-perfect-smtp) -> 一节——这不是一次可以直接替换的升级。 +> 一节——这不是一次可以直接替换的升级。旧代码保留在 `legacy` 分支和 `5.0.0` 标签上。 ## 功能特性 @@ -82,8 +83,10 @@ libcurl 或其他邮件库的封装——它自己实现了 SMTP 线路协议, ## 环境要求 -- Swift 6.2 工具链(`swift-tools-version: 6.2`,`.swiftLanguageMode(.v6)`) -- macOS 26 或更高版本(`Package.swift` 声明 `platforms: [.macOS(.v26)]`) +- Swift 6.2 或更高版本(`swift-tools-version: 6.2`,Swift 6 语言模式) +- macOS 12 或更高版本,或 Linux(CI 覆盖 Ubuntu 24.04 上的 Swift 6.2 与 6.4) + +不支持 iOS/tvOS/watchOS:这是一个服务端软件包。 ## 安装 @@ -111,8 +114,8 @@ libcurl 或其他邮件库的封装——它自己实现了 SMTP 线路协议, 发送一封邮件: ```swift -import PerfectSMTP import NIOPosix +import PerfectSMTP let group = MultiThreadedEventLoopGroup(numberOfThreads: 1) @@ -137,9 +140,16 @@ for result in results { print(result.recipient, result.outcome) } +await transport.shutdown() try await group.shutdownGracefully() ``` +**安全默认值:** 所有 TLS 连接都会完整校验证书;`.startTLS` 在服务器未提供 +STARTTLS 时直接失败,绝不降级为明文;当 `tls: .none` 且配置了认证信息时, +`send` 会抛出 `RelayTransportError.authenticationRequiresTLS`,除非显式设置 +`allowsAuthenticationWithoutTLS: true`。详见英文 README 的 +[Security defaults](README.md#security-defaults) 一节。 + 基础用法就是这些。关于 DKIM 签名、直连 MX 投递、认证方式、批量发送以及 送达率相关头字段,请参阅**[用户指南](Documentation/user-guide.md)**。 @@ -149,15 +159,15 @@ try await group.shutdownGracefully() swift test ``` -全部 323 个测试都无需任何外部服务或环境变量即可运行——其中包括会打开真实 -loopback 套接字的测试(一次 STARTTLS 握手和一次完整的 DirectMX 投递,各自 -针对 `127.0.0.1` 上一个进程内的伪 SMTP 服务器运行),但这里没有任何测试会 -连接真实网络或真实邮件服务器。 +全部测试都无需任何外部服务、环境变量、互联网或 DNS 即可运行——需要套接字的 +测试(STARTTLS 握手、完整的 DirectMX 投递、DNS 解析器、MTA-STS 获取)都针对 +`127.0.0.1` 上的进程内伪服务器运行。CI 在 Linux 和 macOS 上以 debug 和 +release 两种配置、带 `-warnings-as-errors` 运行全部测试。 说明:原始重写计划文档(`Documentation/swift6-nio-rewrite-plan.md` §4.1/§5) 中描述了另一层通过 `SMTP_TESTS=1` 环境变量启用、针对 MailHog/smtp4dev CI 服务容器的实时集成测试。这一层从未被实现——`Tests/` 目录下没有任何地方引用 -这个环境变量,本仓库也没有任何 CI 工作流文件。如果你需要针对真实 SMTP 服务器 +这个环境变量,也没有任何需要手动开启的测试。如果你需要针对真实 SMTP 服务器 做验证,可以自行将 `RelayTransport` 或 `DirectMXTransport` 指向本地的 MailHog/smtp4dev 实例;参见用户指南中的 [测试你的集成](Documentation/user-guide.md#testing-your-integration)一节。 From 56e217ef0d3c669319e8aa2854fd3ac6bdcebf9c Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Fri, 23 Oct 2026 09:30:25 -0500 Subject: [PATCH 09/39] Stop the streaming send pulling a message after cancellation; deflake its test startNextIfPossible checked Task.isCancelled before awaiting the source iterator, so a cancellation that landed during iterator.next() still started a send for the message it returned. Check again after the await. cancellingTheConsumingTaskStopsThePipelinePromptly failed 2 of 3 full runs on linux/amd64 debug (emulated): it read the send counter 100 ms and 300 ms after cancel() and required them to match, but on a slow machine cancellation had not reached the pipeline by the first read (instrumented runs showed the first window of sends starting after cancel()). It now waits for the consumer task to finish, waits for the count to settle, then checks that it stays put and that at most one window of sends ran after cancellation. 8/8 debug and 8/8 release on linux/amd64 afterwards. Co-Authored-By: Claude Opus 5.5 --- Sources/PerfectSMTP/SMTPMailer.swift | 4 ++ .../SMTPMailerStreamingSendTests.swift | 42 ++++++++++++------- 2 files changed, 32 insertions(+), 14 deletions(-) diff --git a/Sources/PerfectSMTP/SMTPMailer.swift b/Sources/PerfectSMTP/SMTPMailer.swift index 989eacf..620fc76 100644 --- a/Sources/PerfectSMTP/SMTPMailer.swift +++ b/Sources/PerfectSMTP/SMTPMailer.swift @@ -322,6 +322,10 @@ public struct SMTPMailer: Sendable { sourceExhausted = true return false } + // Cancellation can land while `iterator.next()` is + // suspended; don't start a send for a message pulled + // after the consumer went away. + guard !Task.isCancelled else { return false } group.addTask { do { let sent = try await mailer.send(message, envelopeFrom: envelopeFrom) diff --git a/Tests/PerfectSMTPTests/SMTPMailerStreamingSendTests.swift b/Tests/PerfectSMTPTests/SMTPMailerStreamingSendTests.swift index 4cbd18c..991824d 100644 --- a/Tests/PerfectSMTPTests/SMTPMailerStreamingSendTests.swift +++ b/Tests/PerfectSMTPTests/SMTPMailerStreamingSendTests.swift @@ -180,23 +180,37 @@ struct SMTPMailerStreamingSendTests { } } - // Let a handful of sends actually happen, then cancel. + // Let a handful of sends actually happen, then cancel, and wait + // for the consumer to finish: by then the pipeline has seen the + // cancellation (the consumer's `pull` cancels the driver, or the + // released stream does). Waiting for that, rather than a fixed + // delay, keeps a slow machine from reading the counter before + // cancellation has even been delivered. try await Task.sleep(nanoseconds: UInt64(60) * 1_000_000) consumer.cancel() - - // Give the cancellation-aware pipeline a moment to actually wind - // down (in-flight sends complete, `startNextIfPossible` stops - // advancing the source iterator). - try await Task.sleep(nanoseconds: UInt64(100) * 1_000_000) - let countShortlyAfterCancel = await tracker.totalSends - + _ = await consumer.result + let countAtCancel = await tracker.totalSends + + // Sends already admitted to the window may still start and finish; + // nothing new may be pulled from the source. Wait for the count to + // settle (nothing in flight, unchanged across 100 ms). + var settled = await tracker.totalSends + let deadline = Date().addingTimeInterval(10) + while Date() < deadline { + try await Task.sleep(nanoseconds: UInt64(100) * 1_000_000) + let now = await tracker.totalSends + if now == settled, await tracker.current == 0 { break } + settled = now + } try await Task.sleep(nanoseconds: UInt64(200) * 1_000_000) - let countMuchLaterAfterCancel = await tracker.totalSends - - // The defining assertion: production stopped -- it did not keep - // marching toward 100,000 in the background after cancellation. - #expect(countMuchLaterAfterCancel == countShortlyAfterCancel) - #expect(countMuchLaterAfterCancel < 100_000) + let countMuchLater = await tracker.totalSends + + // The defining assertions: production stopped -- it did not keep + // marching toward 100,000 in the background after cancellation -- + // and at most one window's worth of sends ran after it. + #expect(countMuchLater == settled, "sends kept starting after cancellation: \(settled) -> \(countMuchLater)") + #expect(countMuchLater <= countAtCancel + cap, "\(countMuchLater - countAtCancel) sends started after cancellation; at most \(cap) were in the window") + #expect(countMuchLater < 100_000) } @Test func abandoningTheStreamWithoutDrainingStopsThePipelinePromptly() async throws { From 4d460bbf2d463e2836f51820eed343f63e334f73 Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Fri, 23 Oct 2026 09:42:09 -0500 Subject: [PATCH 10/39] Narrow swift-crypto to 4.0.0..<5.0.0 and raise the swift-log floor to 1.5.4 Linux testing at the bottom of the range found two floors that only held on macOS: - swift-crypto 3.x: on Linux, Curve25519.Signing.PrivateKey (the BoringSSL implementation) isn't Sendable until 4.0.0, so SigningKey fails to compile. 4.0.0 and 4.5.2 build and pass on macOS and on Linux (Swift 6.2 and 6.4). - swift-log 1.5.0-1.5.3 don't compile on Linux with Swift 6.2+ (localtime returns an optional); 1.5.4 does. All floors together (nio 2.86.0, nio-ssl 2.27.0, log 1.5.4, crypto 4.0.0) build with -warnings-as-errors and pass the suite in debug and release on swift:6.2-noble and swift:6.4-noble. Co-Authored-By: Claude Opus 5.5 --- Package.swift | 14 ++++++++------ README.md | 5 ++--- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/Package.swift b/Package.swift index 497725d..5491826 100644 --- a/Package.swift +++ b/Package.swift @@ -16,16 +16,18 @@ let package = Package( // release whose `EmbeddedChannel` accepts the `autoRead` option. .package(url: "https://github.com/apple/swift-nio.git", from: "2.86.0"), .package(url: "https://github.com/apple/swift-nio-ssl.git", from: "2.27.0"), - .package(url: "https://github.com/apple/swift-log.git", from: "1.5.0"), + // 1.5.4: 1.5.0-1.5.3 don't compile on Linux with Swift 6.2+ + // (`localtime` returns an optional there). + .package(url: "https://github.com/apple/swift-log.git", from: "1.5.4"), // A range, not an exact pin: an exact pin in a library makes // resolution fail for any app that also needs a different // swift-crypto. `_CryptoExtras` is underscore-prefixed, but the only // part used here (`_RSA.Signing` PEM/DER init, `keySizeInBits`, - // PKCS#1 v1.5 sign/verify) is unchanged from 3.4.0 (the first - // release with `Sendable` RSA keys and the `unsafe*Representation` - // initializers the tests use) through 4.5.2; both ends of the range - // are built and tested. Matches Perfect-NIO's own 3.x..<5 range. - .package(url: "https://github.com/apple/swift-crypto.git", "3.4.0"..<"5.0.0"), + // PKCS#1 v1.5 sign/verify) is unchanged across 4.x; 4.0.0 and 4.5.2 + // are built and tested on macOS and Linux. 3.x is excluded: on + // Linux its `Curve25519.Signing.PrivateKey` isn't `Sendable`, so + // `SigningKey` doesn't compile (3.4.0+ does build on macOS). + .package(url: "https://github.com/apple/swift-crypto.git", "4.0.0"..<"5.0.0"), ], targets: [ // Foundation + Crypto/_CryptoExtras only. No NIO import — value diff --git a/README.md b/README.md index ad39ffe..6a076a6 100644 --- a/README.md +++ b/README.md @@ -71,9 +71,8 @@ iOS, tvOS and watchOS are not supported: this is a server-side package (SwiftNIO sockets, and `LocalMTATransport` launches a local `sendmail`). Dependencies are all Apple/SSWG packages: `swift-nio`, `swift-nio-ssl`, -`swift-log` and `swift-crypto` (the last for DKIM; its version range is -`3.4.0..<5.0.0`, so it resolves alongside packages on either swift-crypto -major). +`swift-log` and `swift-crypto` (the last for DKIM; any 4.x release, so it +resolves alongside other packages using swift-crypto 4). ## Installation From 9539de9295b40d8f0f387d21ae62c2596db4c540 Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Fri, 23 Oct 2026 10:27:41 -0500 Subject: [PATCH 11/39] Filter every non-global address before a direct-MX or MTA-STS dial DNSAddress.isRoutable missed 0.0.0.0/8, which reaches the local host, and most of the IANA special-purpose space. IPv4 now rejects every non-globally-reachable special-purpose block plus multicast and 240.0.0.0/4. IPv6 is an allow-list of global unicast 2000::/3, minus 2001::/23, 2001:db8::/32 and 3fff::/20; IPv4-mapped, NAT64 64:ff9b::/96 and 6to4 2002::/16 addresses are judged by the IPv4 address they embed. That also covers ::a.b.c.d, fec0::/10 and ff00::/8. Table tests cover both registries, and a real-socket test shows that a resolved 0.0.0.0 or ::ffff:0.0.0.0 no longer reaches a listener on 127.0.0.1. Co-Authored-By: Claude Opus 5.5 --- Sources/PerfectSMTP/DNS/DNSAddress.swift | 106 +++++++++++------- .../DNS/DNSAddressRoutabilityTests.swift | 49 ++++++++ .../DirectMX/DirectMXRealSocketTests.swift | 31 +++++ 3 files changed, 147 insertions(+), 39 deletions(-) diff --git a/Sources/PerfectSMTP/DNS/DNSAddress.swift b/Sources/PerfectSMTP/DNS/DNSAddress.swift index a9e973e..11647af 100644 --- a/Sources/PerfectSMTP/DNS/DNSAddress.swift +++ b/Sources/PerfectSMTP/DNS/DNSAddress.swift @@ -29,57 +29,85 @@ public enum DNSAddress: Sendable, Hashable { } extension DNSAddress { - /// FIX #2 (plan §9 Phase 3 milestone security review, SSRF-class - /// filtering): `false` for any address in a private, loopback, - /// link-local, unique-local, or carrier-grade-NAT range -- the ranges - /// a caller resolving an *untrusted* hostname (e.g. `DirectMXTransport` - /// resolving a recipient-controlled domain's MX/A/AAAA records) must - /// not blindly dial into. See `DirectMXConfig.allowPrivateAddresses` - /// for where this is applied and its documented opt-out. + /// `false` for any address that isn't a globally reachable unicast + /// address -- the addresses a caller resolving an *untrusted* hostname + /// (e.g. `DirectMXTransport` resolving a recipient-controlled domain's + /// MX/A/AAAA records) must not dial. See + /// `DirectMXConfig.allowPrivateAddresses` for where this is applied and + /// its opt-out. /// - /// Covers, at minimum: RFC 1918 (`10.0.0.0/8`, `172.16.0.0/12`, - /// `192.168.0.0/16`), loopback (`127.0.0.0/8`, `::1`), link-local - /// (`169.254.0.0/16` -- which includes the cloud-metadata - /// `169.254.169.254` -- and `fe80::/10`), IPv6 unique-local - /// (`fc00::/7`), RFC 6598 carrier-grade NAT (`100.64.0.0/10`), and the - /// IPv6 unspecified address (`::`, never a sensible dial target - /// either way). Also unwraps an IPv4-mapped IPv6 address - /// (`::ffff:a.b.c.d`, RFC 4291 §2.5.5.2) and evaluates the *embedded* - /// IPv4 address against the same IPv4 ranges -- without this, an - /// attacker could trivially bypass every IPv4 check above by - /// publishing an AAAA record for `::ffff:127.0.0.1` instead of an A - /// record for `127.0.0.1`, since that's the literal address that - /// would be handed to `SocketAddress(ipAddress:port:)` and dialed. + /// IPv4 rejects every block in the IANA IPv4 Special-Purpose Address + /// Registry that isn't globally reachable, plus multicast and the + /// reserved space: `0.0.0.0/8` ("this network"; `0.0.0.0` reaches the + /// local host on most systems), `10.0.0.0/8`, `100.64.0.0/10`, + /// `127.0.0.0/8`, `169.254.0.0/16` (including cloud metadata), + /// `172.16.0.0/12`, `192.0.0.0/24`, `192.0.2.0/24`, `192.88.99.0/24`, + /// `192.168.0.0/16`, `198.18.0.0/15` (benchmarking), `198.51.100.0/24`, + /// `203.0.113.0/24`, `224.0.0.0/4` (multicast) and `240.0.0.0/4` + /// (reserved, including broadcast). + /// + /// IPv6 is an allow-list: only global unicast `2000::/3` passes, so + /// `::`, `::1`, IPv4-compatible `::a.b.c.d`, `100::/64`, `fc00::/7`, + /// `fe80::/10`, `fec0::/10`, `ff00::/8` and every other block outside it + /// are rejected. Inside it, `2001::/23` (IETF protocol assignments, + /// including Teredo and benchmarking), `2001:db8::/32` and `3fff::/20` + /// (documentation) are rejected too. Addresses that embed an IPv4 + /// address are judged by it: IPv4-mapped `::ffff:a.b.c.d`, the NAT64 + /// well-known prefix `64:ff9b::/96` (the local-use `64:ff9b:1::/48` is + /// rejected), and 6to4 `2002::/16`. Without that, publishing an AAAA + /// record for `::ffff:127.0.0.1` or `2002:7f00:1::` would bypass every + /// IPv4 check. public var isRoutable: Bool { switch self { case .v4(let bytes): return Self.isRoutableIPv4(bytes) case .v6(let bytes): - guard bytes.count == 16 else { return true } // defensive; DNSWireFormat guarantees exactly 16 - if bytes[0..<10].allSatisfy({ $0 == 0 }), bytes[10] == 0xFF, bytes[11] == 0xFF { - return Self.isRoutableIPv4(Array(bytes[12..<16])) - } return Self.isRoutableIPv6(bytes) } } - private static func isRoutableIPv4(_ bytes: [UInt8]) -> Bool { - guard bytes.count == 4 else { return true } // defensive; DNSWireFormat guarantees exactly 4 - if bytes[0] == 127 { return false } // 127.0.0.0/8 -- loopback - if bytes[0] == 10 { return false } // 10.0.0.0/8 -- RFC 1918 - if bytes[0] == 172, (16...31).contains(bytes[1]) { return false } // 172.16.0.0/12 -- RFC 1918 - if bytes[0] == 192, bytes[1] == 168 { return false } // 192.168.0.0/16 -- RFC 1918 - if bytes[0] == 169, bytes[1] == 254 { return false } // 169.254.0.0/16 -- link-local (incl. 169.254.169.254 cloud metadata) - if bytes[0] == 100, (64...127).contains(bytes[1]) { return false } // 100.64.0.0/10 -- RFC 6598 CGNAT - return true + private static func isRoutableIPv4(_ b: [UInt8]) -> Bool { + guard b.count == 4 else { return false } + switch b[0] { + case 0: return false // 0.0.0.0/8 -- "this network" + case 10: return false // 10.0.0.0/8 -- RFC 1918 + case 100 where (64...127).contains(b[1]): return false // 100.64.0.0/10 -- RFC 6598 shared/CGNAT + case 127: return false // 127.0.0.0/8 -- loopback + case 169 where b[1] == 254: return false // 169.254.0.0/16 -- link-local + case 172 where (16...31).contains(b[1]): return false // 172.16.0.0/12 -- RFC 1918 + case 192 where b[1] == 0 && b[2] == 0: return false // 192.0.0.0/24 -- IETF protocol assignments + case 192 where b[1] == 0 && b[2] == 2: return false // 192.0.2.0/24 -- TEST-NET-1 + case 192 where b[1] == 88 && b[2] == 99: return false // 192.88.99.0/24 -- 6to4 relay anycast (deprecated) + case 192 where b[1] == 168: return false // 192.168.0.0/16 -- RFC 1918 + case 198 where b[1] == 18 || b[1] == 19: return false // 198.18.0.0/15 -- benchmarking + case 198 where b[1] == 51 && b[2] == 100: return false // 198.51.100.0/24 -- TEST-NET-2 + case 203 where b[1] == 0 && b[2] == 113: return false // 203.0.113.0/24 -- TEST-NET-3 + case 224...255: return false // 224.0.0.0/4 multicast, 240.0.0.0/4 reserved, 255.255.255.255 + default: return true + } } - private static func isRoutableIPv6(_ bytes: [UInt8]) -> Bool { - guard bytes.count == 16 else { return true } // defensive; DNSWireFormat guarantees exactly 16 - if bytes.allSatisfy({ $0 == 0 }) { return false } // :: -- unspecified - if bytes[0..<15].allSatisfy({ $0 == 0 }), bytes[15] == 1 { return false } // ::1 -- loopback - if bytes[0] == 0xFE, (bytes[1] & 0xC0) == 0x80 { return false } // fe80::/10 -- link-local - if (bytes[0] & 0xFE) == 0xFC { return false } // fc00::/7 -- unique-local + private static func isRoutableIPv6(_ b: [UInt8]) -> Bool { + guard b.count == 16 else { return false } + // ::ffff:a.b.c.d -- IPv4-mapped (RFC 4291 §2.5.5.2). + if b[0..<10].allSatisfy({ $0 == 0 }), b[10] == 0xFF, b[11] == 0xFF { + return isRoutableIPv4(Array(b[12..<16])) + } + // 64:ff9b::/96 -- NAT64 well-known prefix (RFC 6052): judged by the + // embedded IPv4 address. + if b[0] == 0x00, b[1] == 0x64, b[2] == 0xFF, b[3] == 0x9B, b[4..<12].allSatisfy({ $0 == 0 }) { + return isRoutableIPv4(Array(b[12..<16])) + } + // Allow-list: global unicast 2000::/3 only. + guard (b[0] & 0xE0) == 0x20 else { return false } + // 2001::/23 -- IETF protocol assignments (Teredo, benchmarking, ORCHID...). + if b[0] == 0x20, b[1] == 0x01, b[2] < 0x02 { return false } + // 2001:db8::/32 -- documentation. + if b[0] == 0x20, b[1] == 0x01, b[2] == 0x0D, b[3] == 0xB8 { return false } + // 2002::/16 -- 6to4: judged by the embedded IPv4 address. + if b[0] == 0x20, b[1] == 0x02 { return isRoutableIPv4(Array(b[2..<6])) } + // 3fff::/20 -- documentation (RFC 9637). + if b[0] == 0x3F, b[1] == 0xFF, (b[2] & 0xF0) == 0x00 { return false } return true } } diff --git a/Tests/PerfectSMTPTests/DNS/DNSAddressRoutabilityTests.swift b/Tests/PerfectSMTPTests/DNS/DNSAddressRoutabilityTests.swift index ae2742b..8cb5b04 100644 --- a/Tests/PerfectSMTPTests/DNS/DNSAddressRoutabilityTests.swift +++ b/Tests/PerfectSMTPTests/DNS/DNSAddressRoutabilityTests.swift @@ -10,6 +10,7 @@ // bypass this implementation additionally closes. // +import NIOCore import Testing @testable import PerfectSMTP @@ -136,3 +137,51 @@ struct DNSAddressRoutabilityTests { #expect(DNSAddress.v6(bytes).isRoutable) } } + +/// Table tests over the IANA IPv4 and IPv6 special-purpose registries. +struct DNSAddressSpecialPurposeRangeTests { + private struct NotIPv6: Error {} + + private static func v6(_ text: String) throws -> DNSAddress { + // Parse via NIOCore so the table can be written in normal notation. + var bytes = [UInt8](repeating: 0, count: 16) + let address = try SocketAddress(ipAddress: text, port: 0) + guard case .v6(let v6) = address else { throw NotIPv6() } + withUnsafeBytes(of: v6.address.sin6_addr) { raw in for i in 0..<16 { bytes[i] = raw[i] } } + return .v6(bytes) + } + + private static func v4(_ text: String) -> DNSAddress { .v4(text.split(separator: ".").map { UInt8($0)! }) } + + @Test(arguments: [ + "0.0.0.0", "0.1.2.3", "10.1.1.1", "100.64.0.1", "127.0.0.1", "169.254.169.254", "172.16.0.1", + "192.0.0.8", "192.0.2.1", "192.88.99.1", "192.168.1.1", "198.18.0.1", "198.19.255.255", + "198.51.100.7", "203.0.113.9", "224.0.0.1", "239.255.255.250", "240.0.0.1", "255.255.255.255", + ]) + func ipv4SpecialPurposeAddressesAreNotRoutable(address: String) { + #expect(!Self.v4(address).isRoutable) + } + + @Test(arguments: ["1.1.1.1", "8.8.8.8", "93.184.216.34", "192.0.1.1", "192.88.98.1", "198.20.0.1", "223.255.255.255", "100.128.0.1"]) + func ipv4GlobalUnicastAddressesAreRoutable(address: String) { + #expect(Self.v4(address).isRoutable) + } + + @Test(arguments: [ + "::", "::1", "::127.0.0.1", "::8.8.8.8", "::ffff:127.0.0.1", "::ffff:0.0.0.0", "::ffff:10.0.0.1", + "64:ff9b::7f00:1", "64:ff9b::a00:1", "64:ff9b:1::808:808", "100::1", "fc00::1", "fd12:3456::1", + "fe80::1", "fec0::1", "ff02::1", "ff0e::1", "2001::1", "2001:2::1", "2001:10::1", "2001:db8::1", + "2002:7f00:1::", "2002:a00:1::1", "2002:0:0::1", "3fff::1", "4000::1", "e000::1", + ]) + func ipv6NonGlobalAddressesAreNotRoutable(address: String) throws { + #expect(try !Self.v6(address).isRoutable) + } + + @Test(arguments: [ + "2606:4700:4700::1111", "2a00:1450:4001:81a::200e", "2001:4860:4860::8888", "2001:200::1", + "::ffff:8.8.8.8", "64:ff9b::808:808", "2002:808:808::1", + ]) + func ipv6GlobalUnicastAddressesAreRoutable(address: String) throws { + #expect(try Self.v6(address).isRoutable) + } +} diff --git a/Tests/PerfectSMTPTests/DirectMX/DirectMXRealSocketTests.swift b/Tests/PerfectSMTPTests/DirectMX/DirectMXRealSocketTests.swift index 8ead619..7cb146f 100644 --- a/Tests/PerfectSMTPTests/DirectMX/DirectMXRealSocketTests.swift +++ b/Tests/PerfectSMTPTests/DirectMX/DirectMXRealSocketTests.swift @@ -107,6 +107,37 @@ struct DirectMXRealSocketTests { } #expect(host == "mx.ssrf.example") } + + /// Release review: `0.0.0.0` (and its IPv4-mapped and 6to4 forms) + /// reaches the local host on macOS and Linux, and used to pass the + /// private-address filter. With a real listener on 127.0.0.1, the dial + /// must be refused rather than reaching it. + @Test(arguments: [DNSAddress.v4([0, 0, 0, 0]), .v6([0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xFF, 0xFF, 0, 0, 0, 0])]) + func productionDialerRefusesTheUnspecifiedAddressThatReachesLocalhost(address: DNSAddress) async throws { + let group = MultiThreadedEventLoopGroup(numberOfThreads: 1) + let server = try await AcceptingFakeSMTPServer.start(group: group) + let resolver = FakeMXResolver( + mxRecords: ["zero.example": [DNSResolver.MXRecord(preference: 10, exchange: "mx.zero.example")]], + addresses: ["mx.zero.example": [address]] + ) + let transport = DirectMXTransport( + resolver: resolver, config: DirectMXConfig(port: server.port, tlsPolicy: .fixed(.none)), group: group + ) + let envelope = try SMTPEnvelope(mailFrom: .address("from@sender.example"), recipients: ["rcpt@zero.example"]) + let message = SignedMessage(rfc5322: Array("Subject: hi\r\nFrom: from@sender.example\r\n\r\nbody".utf8)) + + let results = try await transport.send(envelope, message) + await transport.shutdown() + try await server.channel.close() + try await group.shutdownGracefully() + + guard case .failed(let error)? = results.first?.outcome, + case .allResolvedAddressesFilteredAsPrivate? = error as? DirectMXError + else { + Issue.record("expected the address to be filtered as private, got \(String(describing: results.first?.outcome))") + return + } + } } /// A minimal, real-socket fake SMTP server that accepts a full EHLO / MAIL From 9f7519cb35ac5f93c889336dc3a734e5c75a5878 Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Fri, 23 Oct 2026 10:29:01 -0500 Subject: [PATCH 12/39] Enforce the MTA-STS 64 KiB policy cap while streaming URLSessionMTASTSFetcher buffered the whole body before MTASTSPolicyManager checked its 64 KiB cap; the reviewer's endless-body server got 3 GB read and 2 GB peak RSS. The fetcher now takes maximumBodySize (default 64 KiB) and throws MTASTSHTTPFetchError.responseTooLarge as soon as it is exceeded. On Darwin it streams with bytes(for:delegate:) and counts. On Linux the session delegate cancels in didReceive past the cap. A larger Content-Length is refused before any body is read. The policy manager maps it to MTASTSDiscoveryError.responseTooLarge. Tests use a local server that streams 64 KiB chunks for as long as the client reads, with and without a 10 GB Content-Length. Co-Authored-By: Claude Opus 5.5 --- .../MTASTS/MTASTSHTTPFetching.swift | 86 ++++++++++++++- .../MTASTS/MTASTSPolicyManager.swift | 14 +-- .../MTASTSHTTPFetcherSizeLimitTests.swift | 104 ++++++++++++++++++ 3 files changed, 191 insertions(+), 13 deletions(-) create mode 100644 Tests/PerfectSMTPTests/MTASTS/MTASTSHTTPFetcherSizeLimitTests.swift diff --git a/Sources/PerfectSMTP/MTASTS/MTASTSHTTPFetching.swift b/Sources/PerfectSMTP/MTASTS/MTASTSHTTPFetching.swift index b06f911..51568b4 100644 --- a/Sources/PerfectSMTP/MTASTS/MTASTSHTTPFetching.swift +++ b/Sources/PerfectSMTP/MTASTS/MTASTSHTTPFetching.swift @@ -74,6 +74,10 @@ public enum MTASTSHTTPFetchError: Error, Sendable, Equatable { /// The whole fetch (connect, TLS, headers and body) did not finish /// within the fetcher's `timeout`. case timedOut + /// The response declared, or delivered, more than the fetcher's + /// `maximumBodySize` bytes of body. The fetch stops reading at that + /// point instead of buffering the rest. + case responseTooLarge(limit: Int) } /// The production `MTASTSHTTPFetching` implementation: a plain `URLSession` @@ -124,6 +128,7 @@ public enum MTASTSHTTPFetchError: Error, Sendable, Equatable { public struct URLSessionMTASTSFetcher: MTASTSHTTPFetching { private let session: URLSession private let timeout: TimeInterval + private let maximumBodySize: Int /// - Parameter session: On Darwin, the session every fetch runs on. On /// platforms that use `FoundationNetworking` (Linux), only its @@ -137,9 +142,20 @@ public struct URLSessionMTASTSFetcher: MTASTSHTTPFetching { /// timeout only bounds the gap between bytes, and its resource /// timeout defaults to seven days, so without this a policy host /// trickling bytes could hold up every direct-MX send to its domain. - public init(session: URLSession = .shared, timeout: TimeInterval = 60) { + /// - Parameter maximumBodySize: The most body bytes read before the + /// fetch is abandoned with `MTASTSHTTPFetchError.responseTooLarge` + /// (default 64 KiB, the same cap `MTASTSPolicyManager` applies). It + /// is enforced while streaming, and a larger `Content-Length` is + /// rejected before any body is read, so an endless body costs at most + /// this much memory. + public init( + session: URLSession = .shared, + timeout: TimeInterval = 60, + maximumBodySize: Int = MTASTSPolicyManager.maximumPolicyResponseSizeBytes + ) { self.session = session self.timeout = timeout + self.maximumBodySize = max(0, maximumBodySize) } public func fetch(url: URL) async throws -> MTASTSHTTPResponse { @@ -148,11 +164,12 @@ public struct URLSessionMTASTSFetcher: MTASTSHTTPFetching { request.cachePolicy = .reloadIgnoringLocalAndRemoteCacheData request.timeoutInterval = max(0, timeout) let session = self.session + let limit = maximumBodySize let (data, response) = try await Self.withDeadline(timeout) { [request] in #if canImport(FoundationNetworking) - try await RedirectRefusingFetchDelegate.fetch(request, configuration: session.configuration) + try await RedirectRefusingFetchDelegate.fetch(request, configuration: session.configuration, limit: limit) #else - try await session.data(for: request, delegate: RedirectRefusingTaskDelegate()) + try await Self.cappedFetch(request, session: session, limit: limit) #endif } guard let http = response as? HTTPURLResponse else { @@ -165,6 +182,27 @@ public struct URLSessionMTASTSFetcher: MTASTSHTTPFetching { ) } + #if !canImport(FoundationNetworking) + /// Streams the body through `bytes(for:delegate:)`, counting as it + /// goes, so at most `limit + 1` bytes are ever held. + private static func cappedFetch(_ request: URLRequest, session: URLSession, limit: Int) async throws -> (Data, URLResponse) { + let (bytes, response) = try await session.bytes(for: request, delegate: RedirectRefusingTaskDelegate()) + if response.expectedContentLength > Int64(limit) { + bytes.task.cancel() + throw MTASTSHTTPFetchError.responseTooLarge(limit: limit) + } + var body = Data() + for try await byte in bytes { + guard body.count < limit else { + bytes.task.cancel() + throw MTASTSHTTPFetchError.responseTooLarge(limit: limit) + } + body.append(byte) + } + return (body, response) + } + #endif + /// Runs `operation`, cancelling it and throwing `.timedOut` if it is /// still running after `seconds`. private static func withDeadline( @@ -214,9 +252,15 @@ private final class RedirectRefusingFetchDelegate: NSObject, URLSessionDataDeleg private var continuation: CheckedContinuation<(Data, URLResponse), any Error>? private var task: URLSessionDataTask? private var cancelled = false + private var tooLarge = false + private let limit: Int + + private init(limit: Int) { + self.limit = limit + } - static func fetch(_ request: URLRequest, configuration: URLSessionConfiguration) async throws -> (Data, URLResponse) { - let delegate = RedirectRefusingFetchDelegate() + static func fetch(_ request: URLRequest, configuration: URLSessionConfiguration, limit: Int) async throws -> (Data, URLResponse) { + let delegate = RedirectRefusingFetchDelegate(limit: limit) let session = URLSession(configuration: configuration, delegate: delegate, delegateQueue: nil) defer { session.finishTasksAndInvalidate() } return try await withTaskCancellationHandler { @@ -268,8 +312,35 @@ private final class RedirectRefusingFetchDelegate: NSObject, URLSessionDataDeleg completionHandler(nil) } + func urlSession( + _ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, + completionHandler: @escaping (URLSession.ResponseDisposition) -> Void + ) { + // Reject a declared oversized body before reading any of it. + guard response.expectedContentLength <= Int64(limit) else { + lock.lock() + tooLarge = true + lock.unlock() + completionHandler(.cancel) + return + } + completionHandler(.allow) + } + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { lock.lock() + guard !tooLarge else { + lock.unlock() + return + } + if body.count + data.count > limit { + // Stop reading: drop what was buffered and cancel the task. + tooLarge = true + body = Data() + lock.unlock() + dataTask.cancel() + return + } body.append(data) lock.unlock() } @@ -280,9 +351,12 @@ private final class RedirectRefusingFetchDelegate: NSObject, URLSessionDataDeleg self.continuation = nil self.task = nil let data = body + let overLimit = tooLarge lock.unlock() guard let continuation else { return } - if let error { + if overLimit { + continuation.resume(throwing: MTASTSHTTPFetchError.responseTooLarge(limit: limit)) + } else if let error { continuation.resume(throwing: error) } else if let response = task.response { continuation.resume(returning: (data, response)) diff --git a/Sources/PerfectSMTP/MTASTS/MTASTSPolicyManager.swift b/Sources/PerfectSMTP/MTASTS/MTASTSPolicyManager.swift index 250fecf..948fb00 100644 --- a/Sources/PerfectSMTP/MTASTS/MTASTSPolicyManager.swift +++ b/Sources/PerfectSMTP/MTASTS/MTASTSPolicyManager.swift @@ -230,13 +230,11 @@ public actor MTASTSPolicyManager: MTASTSPolicyProviding { /// codebase for the same reason: a real MTA-STS policy file (a handful /// of `key: value` lines, at most a few dozen `mx:` patterns) is never /// remotely close to this size, so the cap is pure DoS hardening with - /// no legitimate-policy cost. Enforced post-fetch (see - /// `fetchAndParsePolicy(domain:)`) -- `URLSession.data(for:)` gives no - /// clean hook to abort mid-stream at a byte cap, so this doesn't stop - /// the oversized bytes from being received, but it does guarantee an - /// oversized response is never parsed, cached, or acted upon, which is - /// the protection that actually matters here. - static let maximumPolicyResponseSizeBytes = 64 * 1024 + /// no legitimate-policy cost. `URLSessionMTASTSFetcher` enforces it + /// while streaming (and rejects a larger `Content-Length` up front), so + /// an endless body is never buffered; `fetchAndParsePolicy(domain:)` + /// checks it again for any other `MTASTSHTTPFetching` implementation. + public static let maximumPolicyResponseSizeBytes = 64 * 1024 private var cache: [String: CachedPolicy] = [:] private let dnsResolver: any TXTResolving @@ -543,6 +541,8 @@ public actor MTASTSPolicyManager: MTASTSPolicyProviding { let response: MTASTSHTTPResponse do { response = try await httpFetcher.fetch(url: url) + } catch MTASTSHTTPFetchError.responseTooLarge { + throw MTASTSDiscoveryError.responseTooLarge } catch { throw MTASTSDiscoveryError.fetchFailed } diff --git a/Tests/PerfectSMTPTests/MTASTS/MTASTSHTTPFetcherSizeLimitTests.swift b/Tests/PerfectSMTPTests/MTASTS/MTASTSHTTPFetcherSizeLimitTests.swift new file mode 100644 index 0000000..c4d830f --- /dev/null +++ b/Tests/PerfectSMTPTests/MTASTS/MTASTSHTTPFetcherSizeLimitTests.swift @@ -0,0 +1,104 @@ +// +// MTASTSHTTPFetcherSizeLimitTests.swift +// PerfectSMTPTests +// +// Release review: `URLSessionMTASTSFetcher` used to buffer the whole body +// before `MTASTSPolicyManager` checked the 64 KiB cap, so a policy host +// streaming an endless body drove memory without bound (3 GB read, 2 GB +// peak RSS in the reviewer's repro). The cap is now enforced while +// streaming, and a larger Content-Length is refused before the body. +// Runs against local servers on 127.0.0.1. +// + +import Foundation +import NIOConcurrencyHelpers +import NIOCore +import NIOPosix +import Testing +@testable import PerfectSMTP + +struct MTASTSHTTPFetcherSizeLimitTests { + @Test(.timeLimit(.minutes(1)), arguments: [false, true]) + func anEndlessBodyIsCutOffAtTheCapWithoutBufferingIt(declareHugeLength: Bool) async throws { + let group = MultiThreadedEventLoopGroup(numberOfThreads: 1) + let sent = NIOLockedValueBox(0) + let channel = try await ServerBootstrap(group: group) + .childChannelInitializer { channel in + channel.eventLoop.makeCompletedFuture { + try channel.pipeline.syncOperations.addHandler( + EndlessBodyHTTPHandler(declareHugeLength: declareHugeLength, sent: sent) + ) + } + } + .bind(host: "127.0.0.1", port: 0).get() + let port = try #require(channel.localAddress?.port) + + let fetcher = URLSessionMTASTSFetcher(timeout: 30) + let url = try #require(URL(string: "http://127.0.0.1:\(port)/.well-known/mta-sts.txt")) + await #expect(throws: MTASTSHTTPFetchError.responseTooLarge(limit: MTASTSPolicyManager.maximumPolicyResponseSizeBytes)) { + _ = try await fetcher.fetch(url: url) + } + + try await channel.close() + try await group.shutdownGracefully() + // Socket buffers let the server get some way ahead of the client, + // but nowhere near the unbounded amount an uncapped read would pull. + let total = sent.withLockedValue { $0 } + #expect(total < 64 * 1024 * 1024, "the server streamed \(total) bytes before the client stopped reading") + } + + @Test func aPolicyManagerTreatsAnOversizedFetchAsNoPolicy() async throws { + struct Oversized: MTASTSHTTPFetching { + func fetch(url: URL) async throws -> MTASTSHTTPResponse { + throw MTASTSHTTPFetchError.responseTooLarge(limit: 1) + } + } + struct TXT: TXTResolving { + func resolveTXT(name: String) async throws -> [String] { ["v=STSv1; id=1"] } + } + let manager = MTASTSPolicyManager(dnsResolver: TXT(), httpFetcher: Oversized()) + #expect(await manager.policy(for: "example.test") == nil) + } +} + +/// Answers any request with a 200 and then writes 64 KiB chunks for as +/// long as the client keeps reading, counting what it sent. +private final class EndlessBodyHTTPHandler: ChannelInboundHandler, @unchecked Sendable { + typealias InboundIn = ByteBuffer + typealias OutboundOut = ByteBuffer + // Event-loop confined. + private let declareHugeLength: Bool + private let sent: NIOLockedValueBox + private var started = false + private let chunk = ByteBuffer(repeating: UInt8(ascii: "#"), count: 64 * 1024) + + init(declareHugeLength: Bool, sent: NIOLockedValueBox) { + self.declareHugeLength = declareHugeLength + self.sent = sent + } + + func channelRead(context: ChannelHandlerContext, data: NIOAny) { + guard !started else { return } + started = true + let length = declareHugeLength ? "Content-Length: 10000000000\r\n" : "" + let head = "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\(length)Connection: close\r\n\r\nversion: STSv1\n" + context.writeAndFlush(Self.wrapOutboundOut(ByteBuffer(string: head)), promise: nil) + writeMore(context: context) + } + + func channelWritabilityChanged(context: ChannelHandlerContext) { + if started { writeMore(context: context) } + context.fireChannelWritabilityChanged() + } + + private func writeMore(context: ChannelHandlerContext) { + while context.channel.isActive, context.channel.isWritable { + sent.withLockedValue { $0 += chunk.readableBytes } + context.writeAndFlush(Self.wrapOutboundOut(chunk), promise: nil) + } + } + + func errorCaught(context: ChannelHandlerContext, error: any Error) { + context.close(promise: nil) + } +} From 0eacb3118ab98b98ad087f2828c4a578524f551c Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Fri, 23 Oct 2026 10:29:40 -0500 Subject: [PATCH 13/39] Require RFC 5322 field names in extraHeaders; reject C1 and U+2028/9 An extra-header name was only checked for control characters and against the forbidden list, so "Bcc:secret@victim.test" composed to a Bcc: header and "To " to a second To:. Names must now be one or more printable ASCII characters other than ':' (RFC 5322 3.6.8), checked before the forbidden-name lookup (ComposerError.invalidHeaderName). The shared injection check also rejects C1 controls (including NEL U+0085) and U+2028/U+2029, which some receivers treat as line breaks; subjects and display names have them stripped. Co-Authored-By: Claude Opus 5.5 --- Sources/PerfectSMTPCore/HeaderEncoder.swift | 10 +++-- Sources/PerfectSMTPCore/MIMEComposer.swift | 14 +++++++ .../BugRegressionTests.swift | 38 +++++++++++++++++++ 3 files changed, 59 insertions(+), 3 deletions(-) diff --git a/Sources/PerfectSMTPCore/HeaderEncoder.swift b/Sources/PerfectSMTPCore/HeaderEncoder.swift index ea840a1..ac84335 100644 --- a/Sources/PerfectSMTPCore/HeaderEncoder.swift +++ b/Sources/PerfectSMTPCore/HeaderEncoder.swift @@ -114,7 +114,7 @@ public enum HeaderEncoder { case leadingHyphenInField(String) } - /// True for any C0 control character (0x00–0x1F) or DEL (0x7F) — the + /// True for any C0 control character (0x00–0x1F), DEL (0x7F) — the /// character class that must never survive, unescaped, into a string /// bound for an RFC 5322 header line or an SMTP command line. This is /// broader than just CR/LF: it also covers other C0 controls such as @@ -124,8 +124,12 @@ public enum HeaderEncoder { /// review finding — a `quoted-string` only escapes `\` and `"`, not /// control characters, so a control byte in a display name survives /// straight through `quotedString` below). - private static func isForbiddenControlScalar(_ scalar: Unicode.Scalar) -> Bool { - scalar.value <= 0x1F || scalar.value == 0x7F + /// + /// Release review: also the C1 controls (U+0080–U+009F, including NEL + /// U+0085) and the Unicode line and paragraph separators U+2028/U+2029, + /// which some receivers and tools treat as line breaks. + static func isForbiddenControlScalar(_ scalar: Unicode.Scalar) -> Bool { + scalar.value <= 0x1F || (0x7F...0x9F).contains(scalar.value) || scalar.value == 0x2028 || scalar.value == 0x2029 } /// The single, uniformly-applied discipline for every caller-controlled diff --git a/Sources/PerfectSMTPCore/MIMEComposer.swift b/Sources/PerfectSMTPCore/MIMEComposer.swift index eced799..7897974 100644 --- a/Sources/PerfectSMTPCore/MIMEComposer.swift +++ b/Sources/PerfectSMTPCore/MIMEComposer.swift @@ -37,6 +37,11 @@ public struct MIMEComposer: Sendable { /// upstream, but composition fails loudly rather than silently /// emitting a malformed header. case invalidHeaderValue(String) + /// An `extraHeaders` name isn't an RFC 5322 §3.6.8 field name: one + /// or more printable ASCII characters (33–126) other than `:`. A + /// name like `"Bcc:x@y"` or `"To "` would otherwise slip past + /// `forbiddenExtraHeaderNames` and still be read as `Bcc`/`To`. + case invalidHeaderName(String) /// `EmailMessage.listUnsubscribe.postOneClick` was `true` but `url` /// was `nil` — RFC 8058 §2's one-click mechanism is specifically /// for the HTTPS POST target, so there is no URL for @@ -183,6 +188,12 @@ public struct MIMEComposer: Sendable { "perfect-smtp-\(UUID().uuidString)" } + /// RFC 5322 §3.6.8 `field-name = 1*ftext`, `ftext` = printable ASCII + /// 33–126 except `:`. + static func isValidFieldName(_ name: String) -> Bool { + !name.isEmpty && name.utf8.allSatisfy { (33...126).contains($0) && $0 != UInt8(ascii: ":") } + } + /// Routes a caller-controlled string through `HeaderEncoder`'s shared /// `rejectHeaderInjection`, re-surfacing any control-character rejection /// as this composer's own `ComposerError.invalidHeaderValue` — the @@ -209,6 +220,9 @@ public struct MIMEComposer: Sendable { // first, before the denylist lookup, so a mangled name is // caught regardless of what it lowercases to. try Self.requireNoInjection(name, field: name) + guard Self.isValidFieldName(name) else { + throw ComposerError.invalidHeaderName(name) + } if Self.forbiddenExtraHeaderNames.contains(name.lowercased()) { throw ComposerError.forbiddenHeader(name) } diff --git a/Tests/PerfectSMTPCoreTests/BugRegressionTests.swift b/Tests/PerfectSMTPCoreTests/BugRegressionTests.swift index 59940a5..e2a7679 100644 --- a/Tests/PerfectSMTPCoreTests/BugRegressionTests.swift +++ b/Tests/PerfectSMTPCoreTests/BugRegressionTests.swift @@ -314,3 +314,41 @@ struct BugRegressionTests { // assertion that no live extra header was ever embedded. } } + +/// Release review: extra-header names that aren't RFC 5322 field names +/// used to get past `forbiddenExtraHeaderNames` -- `"Bcc:secret@victim"` +/// was emitted as a `Bcc:` header and `"To "` as a second `To:`. +struct ExtraHeaderNameTests { + private func message(extraName: String, value: String = "x") -> EmailMessage { + var message = EmailMessage(from: EmailAddress(address: "ops@example.com")) + message.to = [EmailAddress(address: "user@example.com")] + message.textBody = "hi" + message.extraHeaders = [(extraName, value)] + return message + } + + @Test(arguments: ["Bcc:secret@victim.test", "To ", " To", "From:attacker@evil.test", "Content-Type:text/html", "X-Ünicode", "", "X A", "X\tA"]) + func aNameThatIsNotAFieldNameIsRejected(name: String) { + #expect(throws: (any Error).self) { _ = try MIMEComposer(message(extraName: name)).compose() } + do { + _ = try MIMEComposer(message(extraName: name)).compose() + } catch let error as MIMEComposer.ComposerError { + #expect(error == .invalidHeaderName(name) || error == .invalidHeaderValue(name)) + } catch { + Issue.record("unexpected error \(error)") + } + } + + @Test(arguments: ["X-Mailer", "List-Id", "X-Custom.Header_1", "X-~!#$%&'*+"]) + func aValidFieldNameIsAccepted(name: String) throws { + let composed = try MIMEComposer(message(extraName: name, value: "v")).compose() + #expect(composed.headers.contains { $0.name == name && $0.value == "v" }) + } + + @Test(arguments: ["v\u{2028}Bcc: x", "v\u{2029}x", "v\u{85}Bcc: x", "v\u{9B}x"]) + func aValueWithAUnicodeLineBreakOrC1ControlIsRejected(value: String) { + #expect(throws: MIMEComposer.ComposerError.invalidHeaderValue("X-A")) { + _ = try MIMEComposer(message(extraName: "X-A", value: value)).compose() + } + } +} From a7a74b7fc36acc692b68ae44bb4a108bf4e70485 Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Fri, 23 Oct 2026 10:32:11 -0500 Subject: [PATCH 14/39] Require plain mailboxes and LDH hostnames wherever they reach the wire Addresses were only checked for control characters, so 'victim@example.test> NOTIFY=SUCCESS ORCPT=rfc822;spoof@evil.test' went out as an ESMTP parameter on RCPT TO, and 's@example.test>, ', '"' or whitespace, and exactly one '@'. Non-ASCII is rejected for now: SMTPUTF8 isn't implemented, so IDN domains must be passed in A-label form. It is applied to: - MAIL FROM, envelope recipients, and again in SMTPConnection (the recipients array is mutable after init); - From, Sender, Reply-To, To and Cc; - the EHLO hostname (LDH or address literal); - MIMEComposer's messageIDDomain (invalidMessageIDDomain); - DKIMSigner's d= and s= (invalidDomain / invalidSelector). Boundary-generator output must be 1-70 RFC 2046 bchars not ending in a space (invalidBoundary). Co-Authored-By: Claude Opus 5.5 --- Sources/PerfectSMTP/SMTPConnection.swift | 6 + Sources/PerfectSMTPCore/AddressSyntax.swift | 126 ++++++++++++++++++ Sources/PerfectSMTPCore/DKIM/DKIMSigner.swift | 8 ++ Sources/PerfectSMTPCore/HeaderEncoder.swift | 3 + Sources/PerfectSMTPCore/MIMEComposer.swift | 26 +++- Sources/PerfectSMTPCore/SMTPEnvelope.swift | 2 + .../AddressSyntaxTests.swift | 115 ++++++++++++++++ .../RelayTransportAuthTLSTests.swift | 21 +++ 8 files changed, 304 insertions(+), 3 deletions(-) create mode 100644 Sources/PerfectSMTPCore/AddressSyntax.swift create mode 100644 Tests/PerfectSMTPCoreTests/AddressSyntaxTests.swift diff --git a/Sources/PerfectSMTP/SMTPConnection.swift b/Sources/PerfectSMTP/SMTPConnection.swift index 0766360..797128f 100644 --- a/Sources/PerfectSMTP/SMTPConnection.swift +++ b/Sources/PerfectSMTP/SMTPConnection.swift @@ -209,6 +209,9 @@ public final class SMTPConnection: @unchecked Sendable { // The hostname comes from configuration, but it is written into a // command line verbatim: a CR/LF in it would inject commands. _ = try HeaderEncoder.rejectHeaderInjection(ehloHostname, field: "EHLO hostname") + guard AddressSyntax.isHostnameOrAddressLiteral(ehloHostname) else { + throw AddressSyntax.Error.invalidHostname(field: "EHLO hostname") + } try await writeLine("EHLO \(ehloHostname)") let reply = try await nextReply() if reply.replyClass == .permanentNegative { @@ -295,7 +298,10 @@ public final class SMTPConnection: @unchecked Sendable { mailFromLine += " SIZE=\(message.estimatedSize)" } let recipientLines = try envelope.recipients.map { recipient -> String in + // Re-checked here: `SMTPEnvelope.recipients` is mutable after + // its validating init. let validated = try HeaderEncoder.rejectHeaderInjection(recipient, field: "RCPT TO address") + try AddressSyntax.validateMailbox(validated, field: "RCPT TO address") return "RCPT TO:<\(validated)>" } diff --git a/Sources/PerfectSMTPCore/AddressSyntax.swift b/Sources/PerfectSMTPCore/AddressSyntax.swift new file mode 100644 index 0000000..7dd5a5a --- /dev/null +++ b/Sources/PerfectSMTPCore/AddressSyntax.swift @@ -0,0 +1,126 @@ +// +// AddressSyntax.swift +// PerfectSMTPCore +// +// Release review: addresses used to be checked for control characters +// only, so `victim@example.test> NOTIFY=SUCCESS ORCPT=rfc822;spoof@evil` +// reached the wire as `RCPT TO: NOTIFY=... >` (ESMTP +// parameter injection), and `s@example.test>, ` and spaces hide. +/// - a domain that is an LDH hostname (see `isHostname`) or an address +/// literal (`[192.0.2.1]`, `[IPv6:2001:db8::1]`). +/// - nothing else: no `<`, `>`, `"`, whitespace of any kind, or control +/// characters, and exactly one `@`. +/// +/// Non-ASCII addresses are rejected. Sending them needs SMTPUTF8 (RFC 6531) +/// end to end, which this library doesn't implement yet; convert an +/// internationalized domain to its A-label (punycode) form before use. +public enum AddressSyntax { + public enum Error: Swift.Error, Sendable, Equatable { + /// `field` holds something that isn't a plain mailbox. + case invalidMailbox(field: String) + /// `field` holds something that isn't an LDH hostname. + case invalidHostname(field: String) + } + + /// Throws `Error.invalidMailbox` unless `address` is a mailbox as + /// described on `AddressSyntax`. + public static func validateMailbox(_ address: String, field: String) throws { + guard isMailbox(address) else { throw Error.invalidMailbox(field: field) } + } + + /// Throws `Error.invalidHostname` unless `name` is an LDH hostname. + public static func validateHostname(_ name: String, field: String) throws { + guard isHostname(name) else { throw Error.invalidHostname(field: field) } + } + + public static func isMailbox(_ address: String) -> Bool { + let bytes = Array(address.utf8) + guard bytes.allSatisfy({ $0 < 0x80 }) else { return false } + let parts = bytes.split(separator: UInt8(ascii: "@"), omittingEmptySubsequences: false) + guard parts.count == 2 else { return false } + return isDotAtom(Array(parts[0])) && parts[0].count <= 64 && isDomainOrLiteral(Array(parts[1])) + } + + /// An LDH hostname (RFC 1123 §2.1, RFC 5890 A-labels included): one or + /// more dot-separated labels of 1–63 letters, digits and hyphens, not + /// starting or ending with a hyphen, at most 253 octets in all, with no + /// trailing dot. Single-label names (`localhost`) are accepted. + public static func isHostname(_ name: String) -> Bool { + isHostname(Array(name.utf8)) + } + + static func isHostname(_ bytes: [UInt8]) -> Bool { + guard !bytes.isEmpty, bytes.count <= 253 else { return false } + let labels = bytes.split(separator: UInt8(ascii: "."), omittingEmptySubsequences: false) + return labels.allSatisfy { label in + guard (1...63).contains(label.count), + label.first != UInt8(ascii: "-"), label.last != UInt8(ascii: "-") + else { return false } + return label.allSatisfy { isLetterOrDigit($0) || $0 == UInt8(ascii: "-") } + } + } + + /// An LDH hostname or an address literal (`[192.0.2.1]`) -- what RFC + /// 5321 allows as the EHLO argument. + public static func isHostnameOrAddressLiteral(_ name: String) -> Bool { + isDomainOrLiteral(Array(name.utf8)) + } + + private static func isDomainOrLiteral(_ bytes: [UInt8]) -> Bool { + if bytes.first == UInt8(ascii: "[") { + guard bytes.count > 2, bytes.last == UInt8(ascii: "]") else { return false } + return isAddressLiteral(String(decoding: bytes.dropFirst().dropLast(), as: UTF8.self)) + } + return isHostname(bytes) + } + + /// RFC 5321 §4.1.3: an IPv4 dotted quad, or `IPv6:` followed by an IPv6 + /// address. The IPv6 form is checked for its character set and shape, + /// not fully parsed. + private static func isAddressLiteral(_ text: String) -> Bool { + if text.hasPrefix("IPv6:") { + let rest = text.dropFirst(5) + guard rest.contains(":"), rest.count <= 45 else { return false } + return rest.utf8.allSatisfy { isHexDigit($0) || $0 == UInt8(ascii: ":") || $0 == UInt8(ascii: ".") } + } + let octets = text.split(separator: ".", omittingEmptySubsequences: false) + return octets.count == 4 && octets.allSatisfy { octet in + (1...3).contains(octet.count) && octet.utf8.allSatisfy(isDigit) && Int(octet).map { $0 <= 255 } == true + } + } + + private static func isDotAtom(_ bytes: [UInt8]) -> Bool { + guard !bytes.isEmpty else { return false } + let atoms = bytes.split(separator: UInt8(ascii: "."), omittingEmptySubsequences: false) + return atoms.allSatisfy { atom in !atom.isEmpty && atom.allSatisfy(isAtext) } + } + + /// RFC 5322 §3.2.3 `atext`. + private static func isAtext(_ byte: UInt8) -> Bool { + isLetterOrDigit(byte) || "!#$%&'*+-/=?^_`{|}~".utf8.contains(byte) + } + + private static func isLetterOrDigit(_ byte: UInt8) -> Bool { + isDigit(byte) || (UInt8(ascii: "a")...UInt8(ascii: "z")).contains(byte) || (UInt8(ascii: "A")...UInt8(ascii: "Z")).contains(byte) + } + + private static func isDigit(_ byte: UInt8) -> Bool { + (UInt8(ascii: "0")...UInt8(ascii: "9")).contains(byte) + } + + private static func isHexDigit(_ byte: UInt8) -> Bool { + isDigit(byte) || (UInt8(ascii: "a")...UInt8(ascii: "f")).contains(byte) || (UInt8(ascii: "A")...UInt8(ascii: "F")).contains(byte) + } +} diff --git a/Sources/PerfectSMTPCore/DKIM/DKIMSigner.swift b/Sources/PerfectSMTPCore/DKIM/DKIMSigner.swift index d3ae861..a8b5572 100644 --- a/Sources/PerfectSMTPCore/DKIM/DKIMSigner.swift +++ b/Sources/PerfectSMTPCore/DKIM/DKIMSigner.swift @@ -65,6 +65,10 @@ public struct DKIMSigner: Sendable, MessageSigner { /// directly from a key parsed some other way, bypassing that /// convenience initializer entirely. case rsaKeyTooSmall(bits: Int) + /// `domain` (the `d=` tag) isn't an LDH hostname. + case invalidDomain(String) + /// `selector` (the `s=` tag) isn't one or more LDH labels. + case invalidSelector(String) } let domain: String @@ -175,6 +179,10 @@ public struct DKIMSigner: Sendable, MessageSigner { // construction time rather than at the point of use. self.domain = try HeaderEncoder.rejectHeaderInjection(domain, field: "domain") self.selector = try HeaderEncoder.rejectHeaderInjection(selector, field: "selector") + // RFC 6376 §3.5: `d=` is a domain name and `s=` a sequence of + // sub-domains; both end up in a DNS query (`s._domainkey.d`). + guard AddressSyntax.isHostname(domain) else { throw ConfigurationError.invalidDomain(domain) } + guard AddressSyntax.isHostname(selector) else { throw ConfigurationError.invalidSelector(selector) } self.signedHeaders = signedHeaders self.canon = canon self.keys = keys diff --git a/Sources/PerfectSMTPCore/HeaderEncoder.swift b/Sources/PerfectSMTPCore/HeaderEncoder.swift index ac84335..bb83ead 100644 --- a/Sources/PerfectSMTPCore/HeaderEncoder.swift +++ b/Sources/PerfectSMTPCore/HeaderEncoder.swift @@ -83,6 +83,9 @@ public enum HeaderEncoder { /// (`ComposerError.invalidHeaderValue`, thrown rather than stripped). public static func encodeAddress(_ address: EmailAddress) throws -> String { let addrSpec = try rejectHeaderInjection(address.address, field: "address") + // A plain mailbox only: `s@example.test>, 1 { - let boundary = boundaryGenerator() + let boundary = try makeBoundary() alternativeOrSingle = MIMEPart( headers: [("Content-Type", "multipart/alternative; boundary=\"\(boundary)\"")], body: .multipart(boundary: boundary, parts: bodyParts) @@ -455,7 +463,7 @@ public struct MIMEComposer: Sendable { let relatedOrSingle: MIMEPart if !message.inlineImages.isEmpty { - let boundary = boundaryGenerator() + let boundary = try makeBoundary() var parts = [alternativeOrSingle] parts.append(contentsOf: try message.inlineImages.map(inlineLeaf)) relatedOrSingle = MIMEPart( @@ -467,7 +475,7 @@ public struct MIMEComposer: Sendable { } if !message.attachments.isEmpty { - let boundary = boundaryGenerator() + let boundary = try makeBoundary() var parts = [relatedOrSingle] parts.append(contentsOf: try message.attachments.map(attachmentLeaf)) return MIMEPart( @@ -823,6 +831,18 @@ public struct MIMEComposer: Sendable { value.unicodeScalars.allSatisfy { $0.value >= 0x20 && $0.value < 0x7F } } + /// Calls `boundaryGenerator` and checks the result against RFC 2046 + /// §5.1.1: 1–70 characters from `bchars`, not ending in a space. + private func makeBoundary() throws -> String { + let boundary = boundaryGenerator() + let bchars = Set("0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'()+_,-./:=? ".utf8) + guard (1...70).contains(boundary.utf8.count), + boundary.utf8.allSatisfy(bchars.contains), + boundary.utf8.last != UInt8(ascii: " ") + else { throw ComposerError.invalidBoundary(boundary) } + return boundary + } + private func synthesizeMessageID() -> String { let domain = messageIDDomain ?? Self.domain(from: message.from.address) return "<\(UUID().uuidString)@\(domain)>" diff --git a/Sources/PerfectSMTPCore/SMTPEnvelope.swift b/Sources/PerfectSMTPCore/SMTPEnvelope.swift index e100be1..583863e 100644 --- a/Sources/PerfectSMTPCore/SMTPEnvelope.swift +++ b/Sources/PerfectSMTPCore/SMTPEnvelope.swift @@ -38,6 +38,7 @@ public enum ReversePath: Sendable, Hashable { case .address(let address): let validated = try HeaderEncoder.rejectHeaderInjection(address, field: "MAIL FROM address") try Self.rejectLeadingHyphen(validated, field: "MAIL FROM address") + try AddressSyntax.validateMailbox(validated, field: "MAIL FROM address") return "MAIL FROM:<\(validated)>" case .null: return "MAIL FROM:<>" @@ -124,6 +125,7 @@ public struct SMTPEnvelope: Sendable { // Recipients reach `LocalMTATransport`'s argv exactly the same // way the reverse-path address does. try ReversePath.rejectLeadingHyphen(recipient, field: "RCPT TO address") + try AddressSyntax.validateMailbox(recipient, field: "RCPT TO address") } self.mailFrom = mailFrom self.recipients = recipients diff --git a/Tests/PerfectSMTPCoreTests/AddressSyntaxTests.swift b/Tests/PerfectSMTPCoreTests/AddressSyntaxTests.swift new file mode 100644 index 0000000..289cbd3 --- /dev/null +++ b/Tests/PerfectSMTPCoreTests/AddressSyntaxTests.swift @@ -0,0 +1,115 @@ +// +// AddressSyntaxTests.swift +// PerfectSMTPCoreTests +// +// Release review: mailbox and hostname syntax, and the injection +// payloads the reviewer sent through MAIL FROM / RCPT TO and the address +// headers. +// + +import Foundation +import Testing +@testable import PerfectSMTPCore + +struct AddressSyntaxTests { + /// The reviewer's envelope payloads: each one reached the wire as an + /// extra ESMTP parameter (or an extra mailbox) before this check. + static let injectionPayloads = [ + "victim@example.test> NOTIFY=SUCCESS,FAILURE,DELAY ORCPT=rfc822;spoof@evil.test", + "a@example.test> SIZE=1 BODY=BINARYMIME SMTPUTF8 AUTH= RET=FULL ENVID=x", + "a@example.test> ", + "a\u{2028}@x.test", + "\"quoted local\"@example.test", + "", + "a@b\u{85}", + "s@example.test>, , ")) + } + + @Test(arguments: ["x.test>\r\nBcc: leak@evil.test\r\nX: <", "exa mple.com", "example.com>", "", "-bad.example"]) + func anInvalidMessageIDDomainIsRejected(domain: String) { + var message = EmailMessage(from: EmailAddress(address: "ops@example.com")) + message.textBody = "hi" + #expect(throws: MIMEComposer.ComposerError.invalidMessageIDDomain(domain)) { + _ = try MIMEComposer(message, messageIDDomain: domain).compose() + } + } + + @Test(arguments: ["b\"\r\nBcc: leak@evil.test", "", String(repeating: "a", count: 71), "ends-in-space ", "semi;colon", "quote\"", "ü"]) + func anInvalidBoundaryIsRejected(boundary: String) { + var message = EmailMessage(from: EmailAddress(address: "ops@example.com")) + message.textBody = "hi" + message.attachments = [Attachment(filename: "x.txt", contentType: "text/plain", data: Data("x".utf8))] + #expect(throws: MIMEComposer.ComposerError.invalidBoundary(boundary)) { + _ = try MIMEComposer(message, boundaryGenerator: { boundary }).compose() + } + } + + @Test(arguments: ["simple", "with space inside", "'()+_,-./:=?", String(repeating: "b", count: 70)]) + func aValidBoundaryIsAccepted(boundary: String) throws { + var message = EmailMessage(from: EmailAddress(address: "ops@example.com")) + message.textBody = "hi" + message.attachments = [Attachment(filename: "x.txt", contentType: "text/plain", data: Data("x".utf8))] + let composed = try MIMEComposer(message, boundaryGenerator: { boundary }).compose() + #expect(String(decoding: composed.body, as: UTF8.self).contains("--\(boundary)")) + } + + @Test(arguments: [("exa mple.com", "s1"), ("example.com", "s 1"), ("example.com", "s1._domainkey;x"), ("example.com.", "s1"), ("", "s1"), ("example.com", "")]) + func anInvalidDKIMDomainOrSelectorIsRejected(domain: String, selector: String) throws { + let key = SigningKey.ed25519(try .init(rawRepresentation: Data(repeating: 7, count: 32))) + #expect(throws: (any Error).self) { + _ = try DKIMSigner(domain: domain, selector: selector, signedHeaders: ["from"], keys: [key]) + } + } +} diff --git a/Tests/PerfectSMTPTests/RelayTransportAuthTLSTests.swift b/Tests/PerfectSMTPTests/RelayTransportAuthTLSTests.swift index c068227..2a10b24 100644 --- a/Tests/PerfectSMTPTests/RelayTransportAuthTLSTests.swift +++ b/Tests/PerfectSMTPTests/RelayTransportAuthTLSTests.swift @@ -103,3 +103,24 @@ struct CredentialRedactionTests { #expect("\(config.auth)".contains("")) } } + +struct WireInjectionTests { + @Test(arguments: ["client example.com", "client.example.com>", "-client", "client_example.com", "[1.2.3]"]) + func anEHLOHostnameThatIsNotAnLDHNameIsRejected(hostname: String) async throws { + let (connection, channel) = try await ConnectionHarness.make(ehloHostname: hostname) + await #expect(throws: (any Error).self) { _ = try await connection.negotiateCapabilities() } + #expect(try await channel.readOutbound(as: ByteBuffer.self) == nil) + } + + /// `SMTPEnvelope.recipients` is mutable after its validating init, so + /// the connection re-checks: the reviewer's `NOTIFY=`/`ORCPT=` payload + /// must not reach the wire, and nothing (not even MAIL FROM) is sent. + @Test func aRecipientMutatedAfterValidationIsRejectedBeforeAnythingIsWritten() async throws { + let (connection, channel) = try await ConnectionHarness.make() + var envelope = try SMTPEnvelope(mailFrom: .address("from@sender.example"), recipients: ["ok@example.com"]) + envelope.recipients = ["victim@example.test> NOTIFY=SUCCESS,FAILURE,DELAY ORCPT=rfc822;spoof@evil.test"] + let message = SignedMessage(rfc5322: Array("Subject: hi\r\n\r\nbody".utf8)) + await #expect(throws: (any Error).self) { _ = try await connection.sendMessage(envelope, message) } + #expect(try await channel.readOutbound(as: ByteBuffer.self) == nil) + } +} From ccd46bea754766e92ed2b09b6f12710fd03335c2 Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Fri, 23 Oct 2026 10:32:47 -0500 Subject: [PATCH 15/39] Normalize bare CR and LF to CRLF before dot-stuffing DotStuffing only treated LF as a line start, so a raw SignedMessage with 'x\r.\r\nMAIL FROM:...' went out with the dot unstuffed after a lone CR. Receivers that treat a bare CR or LF as a line break would then see an early end of data followed by smuggled commands. Every bare CR and bare LF now becomes CRLF before stuffing. This is the one function every SMTP DATA path (relay, direct MX) goes through, so the only CRLF.CRLF on the wire is the terminator. Composed messages already use CRLF throughout and are unchanged. Co-Authored-By: Claude Opus 5.5 --- Sources/PerfectSMTPCore/DotStuffing.swift | 29 +++++++++++++++---- .../DotStuffingTests.swift | 28 ++++++++++++++++++ 2 files changed, 51 insertions(+), 6 deletions(-) diff --git a/Sources/PerfectSMTPCore/DotStuffing.swift b/Sources/PerfectSMTPCore/DotStuffing.swift index fff146c..77b94db 100644 --- a/Sources/PerfectSMTPCore/DotStuffing.swift +++ b/Sources/PerfectSMTPCore/DotStuffing.swift @@ -12,10 +12,16 @@ // public enum DotStuffing { - /// Applies leading-dot doubling to every line of `message`, then - /// appends the terminal `.\r\n` sequence that signals end-of-DATA. - /// Ensures the transformed content ends in CRLF before the terminator - /// is appended, even if `message` itself did not end in CRLF. + /// Normalizes line endings, dot-stuffs, and appends the terminator. + /// + /// Release review: every bare CR and bare LF is turned into CRLF first. + /// Composed messages already use CRLF throughout, but a raw + /// `SignedMessage` could carry `x\r.\r\nMAIL FROM:...`; a lone CR is + /// never a line start here, so its dot was not stuffed, and receivers + /// that treat a bare CR or LF as a line break would see an early end + /// of data followed by smuggled commands (the 2023 SMTP-smuggling + /// class). After normalization every line break is CRLF, so the only + /// `CRLF.CRLF` on the wire is the terminator. public static func encode(_ message: [UInt8]) -> [UInt8] { let dot: UInt8 = 0x2E let cr: UInt8 = 0x0D @@ -25,12 +31,23 @@ public enum DotStuffing { output.reserveCapacity(message.count + 8) var atLineStart = true - for byte in message { + var index = 0 + while index < message.count { + let byte = message[index] + if byte == cr || byte == lf { + // CRLF, bare CR and bare LF all become one CRLF. + output.append(cr) + output.append(lf) + index += (byte == cr && index + 1 < message.count && message[index + 1] == lf) ? 2 : 1 + atLineStart = true + continue + } if atLineStart && byte == dot { output.append(dot) } output.append(byte) - atLineStart = (byte == lf) + atLineStart = false + index += 1 } if !(output.count >= 2 && output[output.count - 2] == cr && output[output.count - 1] == lf) { diff --git a/Tests/PerfectSMTPCoreTests/DotStuffingTests.swift b/Tests/PerfectSMTPCoreTests/DotStuffingTests.swift index 2939b6f..8ffb3f5 100644 --- a/Tests/PerfectSMTPCoreTests/DotStuffingTests.swift +++ b/Tests/PerfectSMTPCoreTests/DotStuffingTests.swift @@ -40,3 +40,31 @@ struct DotStuffingTests { #expect(output == "..a.b.c\r\n.\r\n") } } + +/// Release review: bare CR and bare LF on the raw `SignedMessage` path. +struct DotStuffingLineEndingTests { + private static func onlyCRLF(_ bytes: [UInt8]) -> Bool { + for i in bytes.indices { + if bytes[i] == 0x0D, i + 1 >= bytes.count || bytes[i + 1] != 0x0A { return false } + if bytes[i] == 0x0A, i == 0 || bytes[i - 1] != 0x0D { return false } + } + return true + } + + @Test(arguments: [ + ("A\r\n\r\nx\r.\r\nMAIL FROM:\r\n", "A\r\n\r\nx\r\n..\r\nMAIL FROM:\r\n.\r\n"), + ("A\r\n\r\nx\n.\nMAIL\r\n", "A\r\n\r\nx\r\n..\r\nMAIL\r\n.\r\n"), + ("A\r\n\r\nx\n.\r\nMAIL\r\n", "A\r\n\r\nx\r\n..\r\nMAIL\r\n.\r\n"), + ("x\r\n.\rRSET", "x\r\n..\r\nRSET\r\n.\r\n"), + ("\r.\r", "\r\n..\r\n.\r\n"), + ("a\r\r\nb", "a\r\n\r\nb\r\n.\r\n"), + ]) + func bareLineEndingsBecomeCRLFBeforeStuffing(input: String, expected: String) { + let wire = DotStuffing.encode(Array(input.utf8)) + #expect(String(decoding: wire, as: UTF8.self) == expected) + #expect(Self.onlyCRLF(wire)) + // The terminator is the only CRLF.CRLF. + let text = String(decoding: wire, as: UTF8.self) + #expect(text.components(separatedBy: "\r\n.\r\n").count == 2) + } +} From 586d32b0c250071c3fbad42ee30ebea684038cc9 Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Fri, 23 Oct 2026 10:33:31 -0500 Subject: [PATCH 16/39] End DATA with a lone dot when 354 follows all-rejected RCPTs With PIPELINING, a server may answer the pipelined DATA with 354 even though it rejected every RCPT. The connection then went back to the pool still in DATA mode, so the next transaction's MAIL FROM became message body. Per RFC 2920 3.1 it now sends the terminating dot with no body and reads the reply. If that fails, it closes the channel so the pool drops the connection. A test checks that the dot, never the body, is written and that the next transaction starts with a real MAIL FROM. Co-Authored-By: Claude Opus 5.5 --- Sources/PerfectSMTP/SMTPConnection.swift | 15 +++++++ .../SMTPConnectionStateMachineTests.swift | 45 +++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/Sources/PerfectSMTP/SMTPConnection.swift b/Sources/PerfectSMTP/SMTPConnection.swift index 797128f..1d34a27 100644 --- a/Sources/PerfectSMTP/SMTPConnection.swift +++ b/Sources/PerfectSMTP/SMTPConnection.swift @@ -381,6 +381,21 @@ public final class SMTPConnection: @unchecked Sendable { } let dataReply = try await nextReply() + // Release review: a server may still answer the pipelined DATA with + // 354 after rejecting every RCPT. It is then in DATA mode, and + // returning the connection to the pool as is would feed the next + // transaction's MAIL FROM into a message body. RFC 2920 §3.1: send + // the terminating dot without a body and read its reply. If that + // fails, close the channel so the pool drops the connection. + if !anyAccepted, dataReply.code == 354 { + do { + try await write(.line(".")) + _ = try await nextReply() + } catch { + channel.close(promise: nil) + } + return results + } // DATA proceeds only if at least one RCPT was accepted -- otherwise // the pipelined DATA command itself draws a rejection and the body // must never be sent (plan §4.3's corrected PIPELINING semantics). diff --git a/Tests/PerfectSMTPTests/SMTPConnectionStateMachineTests.swift b/Tests/PerfectSMTPTests/SMTPConnectionStateMachineTests.swift index a81cc23..fb09fee 100644 --- a/Tests/PerfectSMTPTests/SMTPConnectionStateMachineTests.swift +++ b/Tests/PerfectSMTPTests/SMTPConnectionStateMachineTests.swift @@ -263,6 +263,51 @@ struct SMTPConnectionStateMachineTests { } } + /// Release review: a server that answers the pipelined DATA with 354 + /// even though every RCPT was rejected leaves the session in DATA mode. + /// The connection must end the empty message with a lone dot (never the + /// body) and be back in command mode afterwards. + @Test func a354AfterEveryRCPTWasRejectedIsClosedWithADotNotTheBody() async throws { + let (connection, channel) = try await ConnectionHarness.make() + try await negotiateWithCapabilities(connection, channel, lines: ["250-smtp.example.com", "250 PIPELINING"]) + + let envelope = try SMTPEnvelope(mailFrom: .address("from@example.com"), recipients: ["bad@example.com"]) + let message = SignedMessage(rfc5322: Array("Subject: hi\r\n\r\nSECRET BODY".utf8)) + + async let sendTask = connection.sendMessage(envelope, message) + _ = try await expectClientLine(channel) // MAIL + _ = try await expectClientLine(channel) // RCPT + _ = try await expectClientLine(channel) // DATA + try await serverSend(channel, "250 2.1.0 OK") + try await serverSend(channel, "550 5.1.1 User unknown") + try await serverSend(channel, "354 Start mail input") + #expect(try await expectClientLine(channel) == ".") + try await serverSend(channel, "554 5.5.1 No valid recipients") + + let results = try await sendTask + guard case .permanentlyFailed(let reply)? = results.first?.outcome else { + Issue.record("expected the RCPT rejection, got \(String(describing: results.first?.outcome))") + return + } + #expect(reply.code == 550) + + // Back in command mode: the next transaction's MAIL FROM is a command. + let next = try SMTPEnvelope(mailFrom: .address("from@example.com"), recipients: ["good@example.com"]) + async let secondSend = connection.sendMessage(next, message) + #expect(try await expectClientLine(channel) == "MAIL FROM:") + _ = try await expectClientLine(channel) + _ = try await expectClientLine(channel) + try await serverSend(channel, "250 OK") + try await serverSend(channel, "250 OK") + try await serverSend(channel, "354 go") + try await serverSend(channel, "250 queued") + let second = try await secondSend + guard case .delivered? = second.first?.outcome else { + Issue.record("expected the second message delivered, got \(String(describing: second.first?.outcome))") + return + } + } + @Test func allRCPTRejectedNeverSendsMessageBody() async throws { let (connection, channel) = try await ConnectionHarness.make() try await negotiateWithCapabilities(connection, channel, lines: ["250-smtp.example.com", "250 PIPELINING"]) From b2b5f4234e3cd45444457b511a42c4b9604e53de Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Fri, 23 Oct 2026 10:34:35 -0500 Subject: [PATCH 17/39] Redact credentials from dump()/Mirror; harden SASL framing and the 334 loop - RelayConfig.Auth, SASLPlain, SASLLogin and XOAuth2 get CustomReflectable mirrors with the password or token replaced by . dump(relayConfig) and Mirror(reflecting:) printed the secret: they walk stored properties and ignore the redacted descriptions. - XOAUTH2 rejects U+0001 in the username or token, so the username can't forge its own auth= field. PLAIN rejects NUL in any field (SASLError.separatorInCredential). - The AUTH exchange gives up after 10 334 continuations (SMTPConnectionError.tooManySASLChallenges) instead of looping for as long as the server keeps sending them. Co-Authored-By: Claude Opus 5.5 --- Sources/PerfectSMTP/SASLMechanism.swift | 41 +++++++++++++++++++ Sources/PerfectSMTP/SMTPConnection.swift | 14 +++++++ .../RelayTransportAuthTLSTests.swift | 19 +++++++++ .../SMTPConnectionStateMachineTests.swift | 32 +++++++++++++++ 4 files changed, 106 insertions(+) diff --git a/Sources/PerfectSMTP/SASLMechanism.swift b/Sources/PerfectSMTP/SASLMechanism.swift index 6e1a941..657ec96 100644 --- a/Sources/PerfectSMTP/SASLMechanism.swift +++ b/Sources/PerfectSMTP/SASLMechanism.swift @@ -53,6 +53,15 @@ public protocol SASLMechanism: Sendable { /// RFC 4616. `authzid` is almost always empty for SMTP AUTH (the /// authentication identity and authorization identity are the same /// mailbox); exposed for completeness. +/// A credential can't be framed for its SASL mechanism. +public enum SASLError: Error, Sendable, Equatable { + /// `field` contains the mechanism's field separator: NUL for PLAIN, + /// U+0001 for XOAUTH2. Sending it would let the value forge the + /// following fields (for example an XOAUTH2 username adding its own + /// `auth=` part). + case separatorInCredential(field: String) +} + public struct SASLPlain: SASLMechanism { public let name = "PLAIN" public let authzid: String @@ -67,6 +76,9 @@ public struct SASLPlain: SASLMechanism { } public mutating func initialResponse() async throws -> [UInt8]? { + for (field, value) in [("authzid", authzid), ("username", username), ("password", password)] where value.utf8.contains(0) { + throw SASLError.separatorInCredential(field: field) + } sentInitial = true var bytes: [UInt8] = [] bytes.append(contentsOf: Array(authzid.utf8)) @@ -130,8 +142,10 @@ public struct XOAuth2: SASLMechanism { } public mutating func initialResponse() async throws -> [UInt8]? { + guard !username.utf8.contains(1) else { throw SASLError.separatorInCredential(field: "username") } sentInitial = true let token = try await tokenProvider() + guard !token.utf8.contains(1) else { throw SASLError.separatorInCredential(field: "token") } let framed = "user=\(username)\u{1}auth=Bearer \(token)\u{1}\u{1}" return Array(framed.utf8) } @@ -174,3 +188,30 @@ extension RelayConfig.Auth: CustomStringConvertible, CustomDebugStringConvertibl public var debugDescription: String { description } } + +// Redacted mirrors too: `dump(_:)`, `Mirror(reflecting:)` and debuggers' +// `po` walk stored properties through reflection and ignore the +// descriptions above. +extension SASLPlain: CustomReflectable { + public var customMirror: Mirror { + Mirror(self, children: ["username": username, "password": ""], displayStyle: .struct) + } +} + +extension SASLLogin: CustomReflectable { + public var customMirror: Mirror { + Mirror(self, children: ["username": username, "password": ""], displayStyle: .struct) + } +} + +extension XOAuth2: CustomReflectable { + public var customMirror: Mirror { + Mirror(self, children: ["username": username, "tokenProvider": ""], displayStyle: .struct) + } +} + +extension RelayConfig.Auth: CustomReflectable { + public var customMirror: Mirror { + Mirror(self, children: ["description": description], displayStyle: .enum) + } +} diff --git a/Sources/PerfectSMTP/SMTPConnection.swift b/Sources/PerfectSMTP/SMTPConnection.swift index 1d34a27..5cfbc58 100644 --- a/Sources/PerfectSMTP/SMTPConnection.swift +++ b/Sources/PerfectSMTP/SMTPConnection.swift @@ -37,6 +37,9 @@ public enum SMTPConnectionError: Error, Sendable, Equatable { /// existing catch path exactly like any other thrown error, marking the /// connection unhealthy (closed, not returned to idle) via `release`. case replyTimedOut + /// The server kept answering an AUTH exchange with `334` continuations + /// past `SMTPConnection.maximumSASLChallenges`. + case tooManySASLChallenges } /// The live, TLS-ready connection's async conversation surface. @@ -263,6 +266,10 @@ public final class SMTPConnection: @unchecked Sendable { isAuthenticated = true } + /// No supported mechanism needs more than two continuations (LOGIN's + /// username and password, or XOAUTH2's error detail); 10 is headroom. + static let maximumSASLChallenges = 10 + private func performSASLExchange(_ mechanism: inout any SASLMechanism) async throws { let initial = try await mechanism.initialResponse() var line = "AUTH \(mechanism.name)" @@ -271,7 +278,14 @@ public final class SMTPConnection: @unchecked Sendable { } try await writeLine(line) var reply = try await nextReply() + var challenges = 0 while reply.code == 334 { + // Bounded: a hostile or broken server could otherwise keep the + // exchange going forever, one reply timeout at a time. + challenges += 1 + guard challenges <= Self.maximumSASLChallenges else { + throw SMTPConnectionError.tooManySASLChallenges + } guard let challengeText = reply.lines.first, let challengeData = Data(base64Encoded: challengeText) else { diff --git a/Tests/PerfectSMTPTests/RelayTransportAuthTLSTests.swift b/Tests/PerfectSMTPTests/RelayTransportAuthTLSTests.swift index 2a10b24..4bb296e 100644 --- a/Tests/PerfectSMTPTests/RelayTransportAuthTLSTests.swift +++ b/Tests/PerfectSMTPTests/RelayTransportAuthTLSTests.swift @@ -102,6 +102,25 @@ struct CredentialRedactionTests { } #expect("\(config.auth)".contains("")) } + + @Test func dumpAndMirrorNeverExposeThePasswordOrToken() { + let secret = "S3CRET-PW" + let values: [Any] = [ + RelayConfig(host: "h", port: 587, tls: .startTLS, auth: .plain(username: "u", password: secret)), + RelayConfig(host: "h", port: 587, tls: .startTLS, auth: .login(username: "u", password: secret)), + RelayConfig.Auth.plain(username: "u", password: secret), + SASLPlain(username: "u", password: secret), + SASLLogin(username: "u", password: secret), + XOAuth2(username: "u") { secret }, + ] + for value in values { + var dumped = "" + dump(value, to: &dumped) + #expect(!dumped.contains(secret), "\(dumped)") + let children = Mirror(reflecting: value).children.map { "\($0.label ?? ""): \($0.value)" }.joined() + #expect(!children.contains(secret), "\(children)") + } + } } struct WireInjectionTests { diff --git a/Tests/PerfectSMTPTests/SMTPConnectionStateMachineTests.swift b/Tests/PerfectSMTPTests/SMTPConnectionStateMachineTests.swift index fb09fee..8c9c2cc 100644 --- a/Tests/PerfectSMTPTests/SMTPConnectionStateMachineTests.swift +++ b/Tests/PerfectSMTPTests/SMTPConnectionStateMachineTests.swift @@ -449,3 +449,35 @@ private actor CallCounter { private(set) var value = 0 func increment() { value += 1 } } + +struct SASLHardeningTests { + @Test func anEndlessChallengeLoopIsCutOff() async throws { + let (connection, channel) = try await ConnectionHarness.make() + async let negotiate: Capabilities = connection.negotiateCapabilities() + _ = try await expectClientLine(channel) + try await serverSend(channel, "250-smtp.example.com") + try await serverSend(channel, "250 AUTH LOGIN") + _ = try await negotiate + + let auth = Task { try await connection.authenticate(SASLLogin(username: "u", password: "p")) } + _ = try await expectClientLine(channel) // AUTH LOGIN + for _ in 0.. Date: Fri, 23 Oct 2026 10:37:54 -0500 Subject: [PATCH 18/39] Harden DNS: label bytes, 255-octet names, question/owner matching, null MX - Decoding rejects labels with bytes outside printable ASCII, or with a space or '.'. The reviewer's 'mx\0.victim.test' exchange matched *.victim.test before. Names longer than 255 octets on the wire are rejected on decode and encode. - A response must echo exactly one question with the asked name and type. Only records owned by the asked name, or by the end of a CNAME chain from it in the same answer, are used for A/AAAA, MX and TXT. - A malformed or mismatched UDP datagram is ignored and the wait goes on, instead of aborting the lookup. - MX exchanges and CNAME targets must be LDH host names. An MX set with no usable exchange is malformedResponse, not noRecordsFound (which would trigger the implicit-MX fallback). - Any null MX (preference 0, exchange '.') in the RRset is a null MX. RFC 7505 section 3 forbids mixing it with other MX records and doesn't say what a sender should do when a zone does; refusing delivery is the conservative reading. - resolveAddresses no longer swallows failures with try?. A timeout or SERVFAIL in both families is thrown as such (ResolveError.isTransient) rather than collapsing to noRecordsFound. Co-Authored-By: Claude Opus 5.5 --- Sources/PerfectSMTP/DNS/DNSResolver.swift | 144 +++++++++++++----- Sources/PerfectSMTP/DNS/DNSTransport.swift | 40 ++--- Sources/PerfectSMTP/DNS/DNSWireFormat.swift | 20 ++- .../DNS/DNSHardeningTests.swift | 120 +++++++++++++++ .../DNS/DNSResolverMXOrderingTests.swift | 34 ++++- .../PerfectSMTPTests/DNS/FakeDNSServer.swift | 15 +- 6 files changed, 308 insertions(+), 65 deletions(-) create mode 100644 Tests/PerfectSMTPTests/DNS/DNSHardeningTests.swift diff --git a/Sources/PerfectSMTP/DNS/DNSResolver.swift b/Sources/PerfectSMTP/DNS/DNSResolver.swift index ea63841..597d279 100644 --- a/Sources/PerfectSMTP/DNS/DNSResolver.swift +++ b/Sources/PerfectSMTP/DNS/DNSResolver.swift @@ -171,7 +171,7 @@ public struct DNSResolver: Sendable { /// sketch line that named a real design choice to make. public func resolveMX(domain: String) async throws -> [MXRecord] { let message = try await query(name: domain, type: .mx) - return try Self.processMXAnswers(message.answers) + return try Self.processMXAnswers(Self.answers(in: message, for: domain)) } /// Resolves both A and AAAA records for `hostname`, following CNAME @@ -195,27 +195,35 @@ public struct DNSResolver: Sendable { /// family's failure is silently dropped (documented simplification -- /// see `resolveAddresses`'s implementation comment for the trade-off). public func resolveAddresses(hostname: String) async throws -> [DNSAddress] { - async let aAddresses = try? resolveAddressesOfType(.a, name: hostname) - async let aaaaAddresses = try? resolveAddressesOfType(.aaaa, name: hostname) - // `try?` here means a hard failure in one family (timeout, - // malformed response, a genuine CNAME loop) is indistinguishable - // from that family simply having no records -- both collapse to - // "contributed nothing" once combined below. This is a deliberate - // simplification: distinguishing them cleanly would need a - // Sendable error-carrying result type threaded through `async let` - // (non-trivial since `any Error` isn't itself `Sendable`), for a - // benefit that's marginal here -- the two families are queried - // independently and a real, permanent problem with one nameserver - // will affect both anyway. The one real cost: if *both* families - // fail for the same underlying reason (e.g. the nameserver is - // unreachable), the caller sees `.noRecordsFound` rather than - // `.timeout`/`.malformedResponse`, which is less precise than it - // could be. Flagged in the Phase 3 report as a judgment call worth - // revisiting if `DirectMXTransport` needs to distinguish those - // cases (e.g. to decide whether to retry at all). - let combined = (await aAddresses ?? []) + (await aaaaAddresses ?? []) - guard !combined.isEmpty else { throw ResolveError.noRecordsFound } - return combined + async let aResult = Self.capture { try await self.resolveAddressesOfType(.a, name: hostname) } + async let aaaaResult = Self.capture { try await self.resolveAddressesOfType(.aaaa, name: hostname) } + let results = [await aResult, await aaaaResult] + let combined = results.flatMap { (try? $0.get()) ?? [] } + if !combined.isEmpty { return combined } + // Release review: neither family produced an address. Report why, + // rather than collapsing every failure into `.noRecordsFound`: a + // timeout or SERVFAIL is temporary (the caller should retry), while + // NODATA/NXDOMAIN in both families is a real "no such host". + let failures: [ResolveError] = results.compactMap { + if case .failure(let error) = $0 { return error } + return nil + } + if let transient = failures.first(where: \.isTransient) { throw transient } + if let other = failures.first(where: { $0 != .noRecordsFound }) { throw other } + throw ResolveError.noRecordsFound + } + + /// Runs `operation`, mapping any non-`ResolveError` failure (a socket + /// error, say) to `.timeout`: from the caller's side both mean the + /// nameservers couldn't be reached. + private static func capture(_ operation: @Sendable () async throws -> [DNSAddress]) async -> Result<[DNSAddress], ResolveError> { + do { + return .success(try await operation()) + } catch let error as ResolveError { + return .failure(error) + } catch { + return .failure(.timeout) + } } /// Resolves TXT records for `name`, returning one `String` per resource @@ -240,7 +248,7 @@ public struct DNSResolver: Sendable { public func resolveTXT(name: String) async throws -> [String] { let message = try await query(name: name, type: .txt) try Self.validateResponseCode(message) - let strings: [String] = message.answers.compactMap { record in + let strings: [String] = Self.answers(in: message, for: name).compactMap { record in guard case .txt(let parts) = record.rdata else { return nil } return parts.joined() } @@ -257,15 +265,27 @@ public struct DNSResolver: Sendable { /// unit-tested against hand-built `DNSResourceRecord` values without a /// live resolver or wire bytes at all. static func processMXAnswers(_ answers: [DNSResourceRecord]) throws -> [MXRecord] { - let entries: [(preference: UInt16, exchange: String)] = answers.compactMap { + var entries: [(preference: UInt16, exchange: String)] = answers.compactMap { guard case .mx(let preference, let exchange) = $0.rdata else { return nil } return (preference, exchange) } guard !entries.isEmpty else { throw ResolveError.noRecordsFound } - if entries.count == 1, entries[0].preference == 0, isRootDomain(entries[0].exchange) { + // RFC 7505 §3: a domain that publishes a null MX "MUST NOT + // advertise any other MX RR", and §4 has senders treat it as "this + // domain accepts no mail". The RFC doesn't spell out a mixed RRset, + // so any null MX in the set wins: falling through to the other + // records would deliver mail the domain owner said to refuse. + if entries.contains(where: { $0.preference == 0 && isRootDomain($0.exchange) }) { throw ResolveError.nullMX } + // Release review: only LDH host names are usable exchanges. A + // root exchange at another preference, or a name with a byte that + // made it through DNS decoding but isn't a host name, is dropped; + // if nothing usable is left the response is malformed rather than + // "no MX" (which would trigger the implicit-MX fallback). + entries.removeAll { !AddressSyntax.isHostname($0.exchange) } + guard !entries.isEmpty else { throw ResolveError.malformedResponse } let grouped = Dictionary(grouping: entries, by: { $0.preference }) return grouped.keys.sorted().flatMap { preference in @@ -308,9 +328,10 @@ public struct DNSResolver: Sendable { let message = try await query(name: currentName, type: type) try Self.validateResponseCode(message) - let (addresses, cnameTarget) = Self.extractAddresses(from: message, type: type) + let (addresses, cnameTarget) = Self.extractAddresses(from: message, type: type, name: currentName) if !addresses.isEmpty { return addresses } guard let cnameTarget else { return [] } // NODATA: name exists, no record of this type, no CNAME either + guard AddressSyntax.isHostname(cnameTarget) else { throw ResolveError.malformedResponse } currentName = cnameTarget } throw ResolveError.cnameLoop @@ -321,21 +342,57 @@ public struct DNSResolver: Sendable { /// hop) if no terminal address record for `type` was present. Pure -- /// unit-tested directly against hand-built `DNSMessage` values. static func extractAddresses(from message: DNSMessage, type: DNSRecordType) -> (addresses: [DNSAddress], cnameTarget: String?) { + extractAddresses(from: message, type: type, name: message.questions.first?.name ?? "") + } + + /// Release review: only records owned by `name`, or by the end of a + /// CNAME chain starting at `name` within this same answer section, are + /// used. A response that also carries `127.0.0.1` for some other owner + /// name no longer contributes it. + static func extractAddresses(from message: DNSMessage, type: DNSRecordType, name: String) -> (addresses: [DNSAddress], cnameTarget: String?) { + let owner = chainEnd(in: message.answers, from: name) var addresses: [DNSAddress] = [] - var cnameTarget: String? - for record in message.answers { + for record in message.answers where sameName(record.name, owner) { switch record.rdata { - case .a(let address): - if type == .a { addresses.append(address) } - case .aaaa(let address): - if type == .aaaa { addresses.append(address) } - case .cname(let target): - cnameTarget = target - case .mx, .txt, .other: - break + case .a(let address) where type == .a: addresses.append(address) + case .aaaa(let address) where type == .aaaa: addresses.append(address) + default: break } } - return (addresses, addresses.isEmpty ? cnameTarget : nil) + if !addresses.isEmpty { return (addresses, nil) } + return ([], sameName(owner, name) ? nil : owner) + } + + /// The answer records that belong to `name`: owned by it, or by the end + /// of the CNAME chain that starts at it. + static func answers(in message: DNSMessage, for name: String) -> [DNSResourceRecord] { + let owner = chainEnd(in: message.answers, from: name) + return message.answers.filter { sameName($0.name, owner) } + } + + /// Follows CNAME records in `answers` from `name`, at most + /// `maximumCNAMEHops` times, and returns where the chain ends. + private static func chainEnd(in answers: [DNSResourceRecord], from name: String) -> String { + var current = name + var visited: Set = [canonical(name)] + for _ in 0.. String? in + guard sameName(record.name, current), case .cname(let target) = record.rdata else { return nil } + return target + }).first, visited.insert(canonical(next)).inserted else { break } + current = next + } + return current + } + + /// DNS names compare case-insensitively (ASCII only) and ignore a + /// trailing root dot. + static func sameName(_ a: String, _ b: String) -> Bool { + canonical(a) == canonical(b) + } + + private static func canonical(_ name: String) -> String { + (name.hasSuffix(".") ? String(name.dropLast()) : name).lowercased() } // MARK: - Response-code classification @@ -433,6 +490,19 @@ public struct DNSResolver: Sendable { } } +extension DNSResolver.ResolveError { + /// Whether retrying later could succeed: no answer in time, a SERVFAIL + /// or other failure RCODE, an unparseable reply, or no nameserver. + /// NODATA/NXDOMAIN, null MX and CNAME loops are answers, not failures + /// to get one. + public var isTransient: Bool { + switch self { + case .timeout, .serverFailure, .malformedResponse, .noNameserversConfigured: return true + case .nullMX, .noRecordsFound, .cnameLoop: return false + } + } +} + /// A minimal `String.trimmingCharacters(in: .whitespaces)` equivalent that /// doesn't require `Foundation.CharacterSet` -- used only by /// `DNSResolver.parseResolvConf`'s line trimming. diff --git a/Sources/PerfectSMTP/DNS/DNSTransport.swift b/Sources/PerfectSMTP/DNS/DNSTransport.swift index 7aaaaeb..c85fc37 100644 --- a/Sources/PerfectSMTP/DNS/DNSTransport.swift +++ b/Sources/PerfectSMTP/DNS/DNSTransport.swift @@ -62,13 +62,13 @@ enum DNSTransport { do { let response = try await withTimeout(resolver.queryTimeout) { try await sendUDPQuery( - to: nameserver, message: queryBytes, expectedID: id, group: resolver.group + to: nameserver, message: queryBytes, expectedID: id, name: name, type: type, group: resolver.group ) } if response.header.truncated { let tcpResponse = try await withTimeout(resolver.queryTimeout) { try await sendTCPQuery( - to: nameserver, message: queryBytes, expectedID: id, group: resolver.group + to: nameserver, message: queryBytes, expectedID: id, name: name, type: type, group: resolver.group ) } try DNSResolver.validateResponseCode(tcpResponse) @@ -94,7 +94,8 @@ enum DNSTransport { // MARK: - UDP private static func sendUDPQuery( - to nameserver: SocketAddress, message: [UInt8], expectedID: UInt16, group: any EventLoopGroup + to nameserver: SocketAddress, message: [UInt8], expectedID: UInt16, name: String, type: DNSRecordType, + group: any EventLoopGroup ) async throws -> DNSMessage { let channel = try await DatagramBootstrap(group: group) .bind(host: wildcardBindHost(for: nameserver), port: 0) @@ -120,18 +121,13 @@ enum DNSTransport { // from the nameserver we queried. guard envelope.remoteAddress == nameserver else { continue } let responseBytes = [UInt8](envelope.data.readableBytesView) - let decoded: DNSMessage - do { - decoded = try DNSMessage.decode(responseBytes) - } catch { - // A malformed packet from the *correct* source is a - // genuine protocol violation worth surfacing distinctly - // -- unlike an ID mismatch (below), this isn't "keep - // waiting for the real reply," it's "the real reply we - // got back doesn't parse." - throw DNSResolver.ResolveError.malformedResponse - } - guard isValidResponse(decoded, expectedID: expectedID) else { continue } + // Release review: a malformed or mismatched datagram is + // ignored and the wait continues, so one spoofed packet + // can't abort the lookup; the real reply (or the timeout) + // still decides. TCP, below, has no such spoofing surface. + guard let decoded = try? DNSMessage.decode(responseBytes), + isValidResponse(decoded, expectedID: expectedID, name: name, type: type) + else { continue } return decoded } throw DNSResolver.ResolveError.timeout @@ -146,7 +142,8 @@ enum DNSTransport { // MARK: - TCP (RFC 1035 §4.2.2 fallback on a truncated UDP response) private static func sendTCPQuery( - to nameserver: SocketAddress, message: [UInt8], expectedID: UInt16, group: any EventLoopGroup + to nameserver: SocketAddress, message: [UInt8], expectedID: UInt16, name: String, type: DNSRecordType, + group: any EventLoopGroup ) async throws -> DNSMessage { let channel = try await ClientBootstrap(group: group) .channelInitializer { channel in @@ -189,7 +186,7 @@ enum DNSTransport { // there's no equivalent source-address spoofing surface -- // the transaction-ID check is still applied for // defense-in-depth and consistency with the UDP path. - guard isValidResponse(decoded, expectedID: expectedID) else { continue } + guard isValidResponse(decoded, expectedID: expectedID, name: name, type: type) else { continue } return decoded } throw DNSResolver.ResolveError.timeout @@ -210,6 +207,15 @@ enum DNSTransport { message.header.isResponse && message.header.id == expectedID } + /// `isValidResponse(_:expectedID:)` plus (release review) the question + /// section: exactly one question, echoing the name (case-insensitively) + /// and type that were asked. + static func isValidResponse(_ message: DNSMessage, expectedID: UInt16, name: String, type: DNSRecordType) -> Bool { + guard isValidResponse(message, expectedID: expectedID), message.questions.count == 1 else { return false } + let question = message.questions[0] + return question.type == type.rawValue && DNSResolver.sameName(question.name, name) + } + // MARK: - Timeout private static func withTimeout( diff --git a/Sources/PerfectSMTP/DNS/DNSWireFormat.swift b/Sources/PerfectSMTP/DNS/DNSWireFormat.swift index 9391219..f0d2abd 100644 --- a/Sources/PerfectSMTP/DNS/DNSWireFormat.swift +++ b/Sources/PerfectSMTP/DNS/DNSWireFormat.swift @@ -55,7 +55,11 @@ public enum DNSWireError: Error, Sendable, Equatable { /// An MX record's RDATA was too short to contain even a preference /// field and a (possibly root) exchange name. case invalidMXRecordData - /// A query name label was empty or exceeded 63 bytes (RFC 1035 §2.3.4). + /// A query name label was empty or exceeded 63 bytes (RFC 1035 §2.3.4), + /// or (release review) a decoded label contained a byte that can't be + /// part of a host or service name: NUL and other controls, space, `.`, + /// DEL or non-ASCII. `mx\0.victim.test` used to decode to a name that + /// matched `*.victim.test` but reached TLS and logs with a NUL in it. case invalidLabel /// FIX #4 (plan §9 Phase 3 milestone security review, decompression /// amplification): the total number of compression-pointer jumps @@ -234,6 +238,8 @@ public struct DNSMessage: Sendable, Equatable { bytes.append(0) return } + // RFC 1035 §2.3.4: at most 255 octets on the wire. + guard trimmed.utf8.count + 2 <= 255 else { throw DNSWireError.nameTooLong } for label in trimmed.split(separator: ".", omittingEmptySubsequences: false) { let labelBytes = Array(label.utf8) guard (1...63).contains(labelBytes.count) else { throw DNSWireError.invalidLabel } @@ -425,6 +431,7 @@ public struct DNSMessage: Sendable, Equatable { ) throws -> (name: String, nextOffset: Int) { var offset = startOffset var labels: [String] = [] + var wireLength = 1 // the terminating root label var visitedPointers = Set() var jumps = 0 var firstJumpNextOffset: Int? @@ -455,9 +462,16 @@ public struct DNSMessage: Sendable, Equatable { let labelLength = Int(lengthByte) offset += 1 guard offset + labelLength <= bytes.count else { throw DNSWireError.truncated } - labels.append(String(decoding: bytes[offset.. [UInt8] { + var bytes = TestDNSMessageBuilder.name(owner) + bytes += [0, 1, 0, 1, 0, 0, 0, 60, 0, 4] + bytes += address + return bytes + } + + // MARK: - Decoding + + /// The reviewer's packet: an MX whose exchange label is `mx\0`. It used + /// to decode to "mx\u{0}.victim.test", which matches `*.victim.test`. + @Test func aLabelWithANULByteIsRejected() { + var packet: [UInt8] = [0, 1, 0x81, 0x80, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 15, 0, 1, 0, 0, 0, 60] + let rdata: [UInt8] = [0, 10, 3, 0x6D, 0x78, 0x00, 6] + Array("victim".utf8) + [4] + Array("test".utf8) + [0] + packet += [0, UInt8(rdata.count)] + rdata + #expect(throws: DNSWireError.invalidLabel) { _ = try DNSMessage.decode(packet) } + } + + @Test(arguments: [[0x61, 0x2E, 0x62], [0x61, 0x20], [0x61, 0x7F], [0xC3, 0xA9], [0x0A]] as [[UInt8]]) + func aLabelWithADotSpaceControlOrNonASCIIByteIsRejected(label: [UInt8]) { + var packet = TestDNSMessageBuilder.header(id: 1, isResponse: true, ancount: 0) + packet += [UInt8(label.count)] + label + [0, 0, 1, 0, 1] + #expect(throws: DNSWireError.invalidLabel) { _ = try DNSMessage.decode(packet) } + } + + @Test func aNameLongerThan255OctetsIsRejectedOnDecodeAndEncode() { + // Five 63-octet labels: 5 * 64 + 1 = 321 octets on the wire. + let long = (0..<5).map { _ in String(repeating: "a", count: 63) }.joined(separator: ".") + var packet = TestDNSMessageBuilder.header(id: 1, isResponse: true, ancount: 0) + packet += TestDNSMessageBuilder.question(name: long, type: 1) + #expect(throws: DNSWireError.nameTooLong) { _ = try DNSMessage.decode(packet) } + #expect(throws: DNSWireError.nameTooLong) { _ = try DNSMessage.encodeQuery(id: 1, name: long, type: .a) } + // Exactly 255: three 63s, one 61 -> 3 * 64 + 62 + 1 = 255. + let max = ((0..<3).map { _ in String(repeating: "b", count: 63) } + [String(repeating: "c", count: 61)]).joined(separator: ".") + #expect(throws: Never.self) { _ = try DNSMessage.encodeQuery(id: 1, name: max, type: .a) } + } + + // MARK: - Matching + + @Test func aResponseForADifferentQuestionIsNotValid() throws { + let response = try DNSMessage.decode(TestDNSMessageBuilder.response(id: 7, qname: "evil.test", qtype: 1, records: [])) + #expect(!DNSTransport.isValidResponse(response, expectedID: 7, name: "victim.test", type: .a)) + #expect(!DNSTransport.isValidResponse(response, expectedID: 7, name: "evil.test", type: .aaaa)) + #expect(DNSTransport.isValidResponse(response, expectedID: 7, name: "EVIL.test.", type: .a)) + } + + @Test func addressesOwnedBySomeOtherNameAreIgnored() throws { + let bytes = TestDNSMessageBuilder.response(id: 7, qname: "mx.victim.test", qtype: 1, records: [ + Self.aRecord(owner: "other.test", address: [127, 0, 0, 1]), + Self.aRecord(owner: "mx.victim.test", address: [93, 184, 216, 34]), + ]) + let message = try DNSMessage.decode(bytes) + let (addresses, cname) = DNSResolver.extractAddresses(from: message, type: .a, name: "mx.victim.test") + #expect(addresses == [.v4([93, 184, 216, 34])]) + #expect(cname == nil) + } + + @Test func aCNAMEChainInsideOneAnswerIsFollowedToItsOwner() throws { + let bytes = TestDNSMessageBuilder.response(id: 7, qname: "www.victim.test", qtype: 1, records: [ + TestDNSMessageBuilder.cnameRecord(owner: "www.victim.test", target: "edge.victim.test"), + Self.aRecord(owner: "unrelated.test", address: [10, 0, 0, 1]), + Self.aRecord(owner: "edge.victim.test", address: [93, 184, 216, 34]), + ]) + let (addresses, _) = DNSResolver.extractAddresses(from: try DNSMessage.decode(bytes), type: .a, name: "www.victim.test") + #expect(addresses == [.v4([93, 184, 216, 34])]) + } + + // MARK: - Over the wire + + @Test(.timeLimit(.minutes(1))) + func malformedAndMismatchedDatagramsAreIgnoredUntilTheRealReply() async throws { + let group = MultiThreadedEventLoopGroup(numberOfThreads: 1) + let real = TestDNSMessageBuilder.response(id: 0, qname: "mx.victim.test", qtype: 1, records: [ + Self.aRecord(owner: "mx.victim.test", address: [93, 184, 216, 34]), + ]) + let wrongQuestion = TestDNSMessageBuilder.response(id: 0, qname: "evil.test", qtype: 1, records: [ + Self.aRecord(owner: "evil.test", address: [127, 0, 0, 1]), + ]) + let garbage: [UInt8] = [0, 0, 0x81, 0x80, 0, 1, 0, 1, 0xFF, 0xFF] + let server = try await FakeDNSServer.start(group: group, udpResponses: [ + FakeDNSServer.scriptKey(name: "mx.victim.test", type: .a): .init(real, decoys: [garbage, wrongQuestion]), + ]) + let resolver = DNSResolver(nameservers: [server.nameserver], group: group, queryTimeout: .seconds(5)) + let addresses = try await resolver.resolveAddressesOfType(.a, name: "mx.victim.test") + await server.shutdown() + try await group.shutdownGracefully() + #expect(addresses == [.v4([93, 184, 216, 34])]) + } + + /// A nameserver that never answers used to surface as `.noRecordsFound` + /// (both families' timeouts swallowed by `try?`), which DirectMX treats + /// as "use the implicit MX" or a permanent failure. It is a timeout. + @Test(.timeLimit(.minutes(1))) + func resolveAddressesReportsATimeoutRatherThanNoRecords() async throws { + let group = MultiThreadedEventLoopGroup(numberOfThreads: 1) + let server = try await FakeDNSServer.start(group: group, udpResponses: [:]) // drops every query + let resolver = DNSResolver(nameservers: [server.nameserver], group: group, queryTimeout: .milliseconds(100)) + await #expect(throws: DNSResolver.ResolveError.timeout) { + _ = try await resolver.resolveAddresses(hostname: "mx.victim.test") + } + await server.shutdown() + try await group.shutdownGracefully() + } +} diff --git a/Tests/PerfectSMTPTests/DNS/DNSResolverMXOrderingTests.swift b/Tests/PerfectSMTPTests/DNS/DNSResolverMXOrderingTests.swift index 620d236..59ebd72 100644 --- a/Tests/PerfectSMTPTests/DNS/DNSResolverMXOrderingTests.swift +++ b/Tests/PerfectSMTPTests/DNS/DNSResolverMXOrderingTests.swift @@ -106,16 +106,36 @@ struct DNSResolverMXOrderingTests { #expect(resolved == [DNSResolver.MXRecord(preference: 0, exchange: "mail.example.org")]) } - /// RFC 7505 requires the null-MX to be the *only* record. Two records, - /// one of which happens to be a root exchange at preference 0, is a - /// malformed zone, not a valid null-MX signal -- must not be - /// misclassified either. - @Test func rootExchangeAlongsideAnotherRecordIsNotTreatedAsNullMX() throws { + /// RFC 7505 §3 forbids publishing a null MX alongside other MX + /// records, and §4 says not to deliver to a domain with a null MX. The + /// RFC doesn't address the mixed (misconfigured) case, so the release + /// review settled it conservatively: any null MX in the RRset means + /// "this domain accepts no mail" rather than falling through to the + /// other records. + @Test func aNullMXAlongsideAnotherRecordIsStillNullMX() { let records = [ Self.mxRecord(preference: 0, exchange: ""), Self.mxRecord(preference: 10, exchange: "mail.example.org"), ] - let resolved = try DNSResolver.processMXAnswers(records) - #expect(resolved.count == 2) + #expect(throws: DNSResolver.ResolveError.nullMX) { _ = try DNSResolver.processMXAnswers(records) } + } + + /// A root exchange at a non-zero preference isn't a null MX, but it + /// can't be dialed either: it's dropped, and the real host kept. + @Test func aRootExchangeAtANonZeroPreferenceIsDropped() throws { + let records = [ + Self.mxRecord(preference: 5, exchange: ""), + Self.mxRecord(preference: 10, exchange: "mail.example.org"), + ] + #expect(try DNSResolver.processMXAnswers(records) == [DNSResolver.MXRecord(preference: 10, exchange: "mail.example.org")]) + } + + @Test(arguments: ["mx\u{0}.victim.test", "mx .victim.test", "-mx.victim.test", "mx_.victim.test"]) + func aNonHostnameExchangeIsDroppedAndAnAllInvalidSetIsMalformed(exchange: String) throws { + #expect(throws: DNSResolver.ResolveError.malformedResponse) { + _ = try DNSResolver.processMXAnswers([Self.mxRecord(preference: 10, exchange: exchange)]) + } + let mixed = [Self.mxRecord(preference: 10, exchange: exchange), Self.mxRecord(preference: 20, exchange: "mx2.victim.test")] + #expect(try DNSResolver.processMXAnswers(mixed) == [DNSResolver.MXRecord(preference: 20, exchange: "mx2.victim.test")]) } } diff --git a/Tests/PerfectSMTPTests/DNS/FakeDNSServer.swift b/Tests/PerfectSMTPTests/DNS/FakeDNSServer.swift index 6f38818..827e577 100644 --- a/Tests/PerfectSMTPTests/DNS/FakeDNSServer.swift +++ b/Tests/PerfectSMTPTests/DNS/FakeDNSServer.swift @@ -126,10 +126,15 @@ final class FakeDNSUDPHandler: ChannelInboundHandler, @unchecked Sendable { /// `DNSTransportTests` uses this to confirm the resolver doesn't /// accept a stray/mismatched reply as answering the pending query. var precededByWrongID = false + /// Datagrams sent (with the real query's ID patched in) before the + /// real response: malformed or mismatched packets the resolver + /// must ignore. + var decoys: [[UInt8]] = [] - init(_ bytes: [UInt8], precededByWrongID: Bool = false) { + init(_ bytes: [UInt8], precededByWrongID: Bool = false, decoys: [[UInt8]] = []) { self.bytes = bytes self.precededByWrongID = precededByWrongID + self.decoys = decoys } } @@ -159,6 +164,14 @@ final class FakeDNSUDPHandler: ChannelInboundHandler, @unchecked Sendable { send(decoy, to: envelope.remoteAddress, context: context) } + for var decoy in scripted.decoys { + if decoy.count >= 2 { + decoy[0] = queryBytes[0] + decoy[1] = queryBytes[1] + } + send(decoy, to: envelope.remoteAddress, context: context) + } + var real = scripted.bytes real[0] = queryBytes[0] real[1] = queryBytes[1] From 5eb98e278acc77204c5a769666b6ecf2b3b4e3cc Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Fri, 23 Oct 2026 10:45:46 -0500 Subject: [PATCH 19/39] STARTTLS name checking, a distinct certificate error, and DirectMX retries TLS names: - makeSSLHandler only dials without SNI for a real IP literal. Before, any name NIOSSL refused for SNI (the reviewer used mx\0.victim.test) fell back to checking the dialed IP, so a certificate with only an IP SAN passed. SMTPBootstrap.connect now refuses a non-LDH TLS name or EHLO name up front, and the 'confirmed safe' comment is gone. - Under MTA-STS (testing and enforce), DirectMX keys carry requireCertificateDNSName. An additional NIOSSL verification callback then requires a DNS subjectAltName matching the MX host, because NIOSSL otherwise accepts an IP SAN equal to the dialed address. - Certificate failures (untrusted chain, wrong name, no DNS SAN) are reported as SMTPError.tlsCertificateInvalid, not .starttlsInjection. - The pre-TLS EHLO probe uses the configured EHLO hostname. DirectMX failures (RFC 5321 4.5.4.1), when no host of a domain could be used: - Temporary, so .queuedForRetry (451 4.4.1) and queued: refused or reset connections, timeouts, a peer hanging up, an open breaker, TLS failures, and DNS timeouts or SERVFAIL. - Final (.failed): NXDOMAIN, private addresses, bad names. - An MTA-STS enforce violation is temporary too (451 4.7.5). Opportunistic TLS still falls back to plaintext on 454, a failed handshake or certificate, or a missing STARTTLS, but never after injection or an open breaker. .fixed(.startTLS) and enforce never fall back. Tests use a local CA with IP-only, DNS-only and combined leaf certificates on 127.0.0.1, plus dialer-driven tests of every fallback and classification case. Co-Authored-By: Claude Opus 5.5 --- .../DirectMX/DirectMXTransport.swift | 114 +++++++-- .../PerfectSMTP/SMTPBootstrapHandler.swift | 132 ++++++++--- Sources/PerfectSMTP/SMTPConnectionPool.swift | 12 +- Sources/PerfectSMTPCore/SMTPError.swift | 6 + .../DirectMXFailureClassificationTests.swift | 213 +++++++++++++++++ .../DirectMX/DirectMXMTASTSEnforceTests.swift | 16 +- .../DirectMXOpportunisticTLSTests.swift | 16 +- .../DirectMX/DirectMXTransportTests.swift | 14 +- .../STARTTLSCertificateTests.swift | 219 ++++++++++++++++++ Tests/PerfectSMTPTests/TestCertificates.swift | 96 ++++++++ 10 files changed, 760 insertions(+), 78 deletions(-) create mode 100644 Tests/PerfectSMTPTests/DirectMX/DirectMXFailureClassificationTests.swift create mode 100644 Tests/PerfectSMTPTests/STARTTLSCertificateTests.swift create mode 100644 Tests/PerfectSMTPTests/TestCertificates.swift diff --git a/Sources/PerfectSMTP/DirectMX/DirectMXTransport.swift b/Sources/PerfectSMTP/DirectMX/DirectMXTransport.swift index e2d735c..d97ce70 100644 --- a/Sources/PerfectSMTP/DirectMX/DirectMXTransport.swift +++ b/Sources/PerfectSMTP/DirectMX/DirectMXTransport.swift @@ -17,6 +17,8 @@ import Foundation import Logging import NIOCore +import NIOPosix +import NIOSSL /// The TLS policy `DirectMXTransport` applies when dialing a resolved MX /// host (plan §9 Phase 4). `DirectMXTransport` itself layers per-domain @@ -479,7 +481,8 @@ public final class DirectMXTransport: SMTPTransport, Sendable { recipients: recipients, mailFrom: mailFrom, message: message, pool: pool, retryQueueConfiguration: retryQueueConfiguration, lastError: &lastError ) { return results } - return recipients.map { DeliveryResult(recipient: $0, outcome: .failed(lastError)) } + let outcome = classify(connectionFailure: lastError, domain: domain, retryQueueConfiguration: retryQueueConfiguration) + return recipients.map { DeliveryResult(recipient: $0, outcome: outcome) } } if let mtaSTSPolicyProvider, let policy = await mtaSTSPolicyProvider.policy(for: domain), policy.mode != .none { @@ -492,11 +495,14 @@ public final class DirectMXTransport: SMTPTransport, Sendable { "MTA-STS enforce: no resolved MX host matches the policy's mx: patterns; hard-failing delivery", metadata: ["domain": "\(domain)", "resolvedHosts": "\(hosts)", "mxPatterns": "\(policy.mxPatterns)"] ) - let outcome = mtaSTSEnforceViolationOutcome(domain: domain, reason: "no resolved MX host matches the policy's mx: patterns") + let outcome = mtaSTSEnforceViolationOutcome( + domain: domain, reason: "no resolved MX host matches the policy's mx: patterns", + retryQueueConfiguration: retryQueueConfiguration + ) return recipients.map { DeliveryResult(recipient: $0, outcome: outcome) } } if let results = await attemptHostsInOrder( - matchedHosts, keyForHost: { SMTPConnectionPool.Key(host: $0, port: config.port, tls: .startTLS) }, + matchedHosts, keyForHost: { SMTPConnectionPool.Key(host: $0, port: config.port, tls: .startTLS, requireCertificateDNSName: true) }, recipients: recipients, mailFrom: mailFrom, message: message, pool: pool, retryQueueConfiguration: retryQueueConfiguration, lastError: &lastError ) { return results } @@ -504,12 +510,15 @@ public final class DirectMXTransport: SMTPTransport, Sendable { "MTA-STS enforce: every policy-matched MX host failed a mandatory-TLS connection attempt; hard-failing delivery", metadata: ["domain": "\(domain)", "matchedHosts": "\(matchedHosts)", "lastError": "\(lastError)"] ) - let outcome = mtaSTSEnforceViolationOutcome(domain: domain, reason: "every policy-matched MX host failed mandatory STARTTLS (last error: \(lastError))") + let outcome = mtaSTSEnforceViolationOutcome( + domain: domain, reason: "every policy-matched MX host failed mandatory STARTTLS (last error: \(lastError))", + retryQueueConfiguration: retryQueueConfiguration + ) return recipients.map { DeliveryResult(recipient: $0, outcome: outcome) } case .testing: if let results = await attemptHostsInOrder( - matchedHosts, keyForHost: { SMTPConnectionPool.Key(host: $0, port: config.port, tls: .startTLS) }, + matchedHosts, keyForHost: { SMTPConnectionPool.Key(host: $0, port: config.port, tls: .startTLS, requireCertificateDNSName: true) }, recipients: recipients, mailFrom: mailFrom, message: message, pool: pool, retryQueueConfiguration: retryQueueConfiguration, lastError: &lastError ) { return results } @@ -532,7 +541,8 @@ public final class DirectMXTransport: SMTPTransport, Sendable { hosts, port: config.port, recipients: recipients, mailFrom: mailFrom, message: message, pool: pool, retryQueueConfiguration: retryQueueConfiguration, lastError: &lastError ) { return results } - return recipients.map { DeliveryResult(recipient: $0, outcome: .failed(lastError)) } + let outcome = classify(connectionFailure: lastError, domain: domain, retryQueueConfiguration: retryQueueConfiguration) + return recipients.map { DeliveryResult(recipient: $0, outcome: outcome) } } /// Tries `hosts` in order, each with exactly `keyForHost(host)` -- no @@ -678,20 +688,74 @@ public final class DirectMXTransport: SMTPTransport, Sendable { } } - /// Plan §9 Phase 4's "enforce-mode hard-fail" requirement: a distinct, - /// clearly-classified `.permanentlyFailed` outcome -- never - /// `.queuedForRetry` (an MTA-STS enforce violation is a policy - /// decision, not a transient server condition retrying would resolve) - /// and never silently downgraded to plaintext or a non-matching host. - /// RFC 8461 itself defines no SMTP-level reply code for this (the - /// violation is detected before/around the SMTP conversation, at the - /// TLS-policy layer) -- `550 5.7.1` is this library's own conventional - /// choice (a generic, widely-recognized "delivery not authorized, - /// security policy" enhanced status), matching the precedent this - /// codebase already set for its own non-RFC-mandated synthesized - /// replies (e.g. `classify(resolveError:)`'s `556 5.1.10` for null-MX). - private static func mtaSTSEnforceViolationOutcome(domain: String, reason: String) -> DeliveryResult.Outcome { - .permanentlyFailed(SMTPReply(code: 550, lines: ["5.7.1 MTA-STS enforce policy violation for domain \(domain): \(reason)"])) + /// Plan §9 Phase 4's "enforce-mode hard-fail" requirement: never + /// silently downgraded to plaintext or a non-matching host. Release + /// review: classified as *temporary* (`451 4.7.5`, queued for retry), + /// not permanent. RFC 8461 §5 has the sender not deliver to a host + /// that fails the policy, and a missing match or a failed handshake is + /// often a passing condition (a certificate being renewed, an MX being + /// moved) that a later attempt can succeed past; the retry queue's + /// attempt and age ceilings still end it. + private static func mtaSTSEnforceViolationOutcome( + domain: String, reason: String, retryQueueConfiguration: DirectMXRetryQueue.Configuration + ) -> DeliveryResult.Outcome { + let reply = SMTPReply(code: 451, lines: ["4.7.5 MTA-STS enforce policy violation for domain \(domain): \(reason)"]) + return retryQueueConfiguration.classify(reply, attempt: 1) + } + + /// Release review (RFC 5321 §4.5.4.1: a sender keeps retrying a + /// destination it couldn't reach): what to report when no host of a + /// domain could be used. + /// + /// Temporary, so queued for retry (`451 4.4.1`): + /// - TCP connect refused, reset or timed out, and other socket errors; + /// - no reply in time, or the peer hanging up mid-conversation; + /// - an open circuit breaker; + /// - TLS: STARTTLS missing where it is required, a failed handshake or + /// certificate, or detected STARTTLS injection; + /// - DNS timeouts, SERVFAIL and other failures to get an answer + /// (`DNSResolver.ResolveError.isTransient`). + /// + /// Final (`.failed`): the host name doesn't exist (NXDOMAIN/NODATA for + /// its addresses), every address was filtered as private, a name or + /// address fails syntax checks, or anything this method doesn't + /// recognize. Null MX and 5yz replies never reach here; they are + /// classified where they occur. + static func classify( + connectionFailure error: any Error, domain: String, retryQueueConfiguration: DirectMXRetryQueue.Configuration + ) -> DeliveryResult.Outcome { + guard isTransientConnectionFailure(error) else { return .failed(error) } + let reply = SMTPReply(code: 451, lines: ["4.4.1 No usable connection to any MX host for \(domain) (\(error))"]) + return retryQueueConfiguration.classify(reply, attempt: 1) + } + + static func isTransientConnectionFailure(_ error: any Error) -> Bool { + switch error { + case let smtpError as SMTPError: + switch smtpError { + case .circuitOpen, .starttlsRequired, .starttlsInjection, .tlsCertificateInvalid, + .transientFailure, .serviceUnavailable, .greylisted: + return true + case .connectionFailed(let underlying): + return isTransientConnectionFailure(underlying) + case .permanentFailure, .sizeExceeded, .authenticationFailed, .tlsPolicyViolation, .ambiguousDelivery: + return false + } + case let connectionError as SMTPConnectionError: + switch connectionError { + case .replyTimedOut, .channelClosedByPeer: return true + case .malformedSASLChallenge, .tooManySASLChallenges: return false + } + case let resolveError as DNSResolver.ResolveError: + return resolveError.isTransient + case is DirectMXError, is AddressSyntax.Error, is HeaderEncoder.HeaderInjectionError: + return false + case is ChannelError, is IOError, is NIOConnectionError, is NIOSSLError, is CertificateNameError, + is SMTPResponseDecoder.DecoderError: + return true + default: + return false + } } /// Checks out (dialing if needed) a pooled connection to `key` and runs @@ -860,7 +924,7 @@ public final class DirectMXTransport: SMTPTransport, Sendable { // `.ambiguous` here is sufficient on its own to guarantee this // never gets auto-retried. return .ambiguous(reply) - case .starttlsRequired, .starttlsInjection, .tlsPolicyViolation, .circuitOpen, .connectionFailed: + case .starttlsRequired, .starttlsInjection, .tlsCertificateInvalid, .tlsPolicyViolation, .circuitOpen, .connectionFailed: // None of these are reply-classified rejections from a // connected peer -- they're connection/policy-level failures // that, in practice, are thrown by the pool's own dial path or @@ -906,6 +970,10 @@ public final class DirectMXTransport: SMTPTransport, Sendable { retryQueueConfiguration: DirectMXRetryQueue.Configuration ) -> @Sendable (SMTPConnectionPool.Key) async throws -> SMTPConnection { { key in + // A custom `MXResolving` could hand back anything; only LDH + // host names are dialed (they become SNI and EHLO-adjacent + // log and TLS names). + try AddressSyntax.validateHostname(key.host, field: "MX host") let resolvedAddresses = try await resolver.resolveAddresses(hostname: key.host) // FIX #2 (milestone security review, SSRF-class filtering): // drop every address in a private/loopback/link-local/ @@ -932,7 +1000,9 @@ public final class DirectMXTransport: SMTPTransport, Sendable { let socketAddress = try SocketAddress(ipAddress: address.description, port: key.port) let asyncChannel = try await SMTPBootstrap.connect( to: socketAddress, sniHostname: key.host, tls: key.tls, - connectTimeout: config.connectTimeout, replyTimeout: config.replyTimeout, group: group + connectTimeout: config.connectTimeout, replyTimeout: config.replyTimeout, + ehloHostname: config.ehloHostname, requireCertificateDNSName: key.requireCertificateDNSName, + group: group ) let connection = SMTPConnection( asyncChannel: asyncChannel, ehloHostname: config.ehloHostname, diff --git a/Sources/PerfectSMTP/SMTPBootstrapHandler.swift b/Sources/PerfectSMTP/SMTPBootstrapHandler.swift index 6cef274..0ce052f 100644 --- a/Sources/PerfectSMTP/SMTPBootstrapHandler.swift +++ b/Sources/PerfectSMTP/SMTPBootstrapHandler.swift @@ -74,12 +74,13 @@ public enum SMTPBootstrap { connectTimeout: TimeAmount = .seconds(30), replyTimeout: TimeInterval = 300, tlsConfiguration: TLSConfiguration = .makeClientConfiguration(), + ehloHostname: String = "localhost", group: any EventLoopGroup ) async throws -> NIOAsyncChannel { try await connect( target: .hostPort(host: host, port: port), sniHostname: host, tls: tls, connectTimeout: connectTimeout, replyTimeout: replyTimeout, - tlsConfiguration: tlsConfiguration, group: group + tlsConfiguration: tlsConfiguration, ehloHostname: ehloHostname, requireCertificateDNSName: false, group: group ) } @@ -97,6 +98,12 @@ public enum SMTPBootstrap { /// TLS SNI and certificate-hostname verification are about the name a /// certificate is expected to match, independent of which specific /// address that name happened to resolve to. + /// + /// `requireCertificateDNSName`: when `true` (DirectMX delivery under an + /// MTA-STS policy), the server certificate must also carry a DNS + /// subjectAltName matching `sniHostname` (RFC 8461 §4.2); one that + /// only verifies through an IP-address SAN for the dialed address is + /// refused with `SMTPError.tlsCertificateInvalid`. public static func connect( to socketAddress: SocketAddress, sniHostname: String, @@ -104,12 +111,15 @@ public enum SMTPBootstrap { connectTimeout: TimeAmount = .seconds(30), replyTimeout: TimeInterval = 300, tlsConfiguration: TLSConfiguration = .makeClientConfiguration(), + ehloHostname: String = "localhost", + requireCertificateDNSName: Bool = false, group: any EventLoopGroup ) async throws -> NIOAsyncChannel { try await connect( target: .socketAddress(socketAddress), sniHostname: sniHostname, tls: tls, connectTimeout: connectTimeout, replyTimeout: replyTimeout, - tlsConfiguration: tlsConfiguration, group: group + tlsConfiguration: tlsConfiguration, ehloHostname: ehloHostname, + requireCertificateDNSName: requireCertificateDNSName, group: group ) } @@ -129,8 +139,19 @@ public enum SMTPBootstrap { connectTimeout: TimeAmount, replyTimeout: TimeInterval, tlsConfiguration: TLSConfiguration, + ehloHostname: String, + requireCertificateDNSName: Bool, group: any EventLoopGroup ) async throws -> NIOAsyncChannel { + // The EHLO argument and the TLS name both reach the wire; refuse + // anything that isn't a host name (or, for EHLO, an address + // literal) before connecting. + guard AddressSyntax.isHostnameOrAddressLiteral(ehloHostname) else { + throw AddressSyntax.Error.invalidHostname(field: "EHLO hostname") + } + if tls != .none, !isIPAddressLiteral(sniHostname), !AddressSyntax.isHostname(sniHostname) { + throw AddressSyntax.Error.invalidHostname(field: "TLS server name") + } let stepTimeout = Self.timeAmount(seconds: replyTimeout) let promise = group.next().makePromise(of: NIOAsyncChannel.self) @@ -152,13 +173,17 @@ public enum SMTPBootstrap { ) try channel.pipeline.syncOperations.addHandler(SMTPCommandEncoder(), name: Names.encoder) if tls == .implicit { - let sslHandler = try Self.makeSSLHandler(host: sniHostname, configuration: tlsConfiguration) + let sslHandler = try Self.makeSSLHandler( + host: sniHostname, configuration: tlsConfiguration, requireDNSName: requireCertificateDNSName + ) try channel.pipeline.syncOperations.addHandler(sslHandler, position: .first) } let handler = SMTPBootstrapHandler( tls: tls, host: sniHostname, tlsConfiguration: tlsConfiguration, + requireCertificateDNSName: requireCertificateDNSName, + ehloHostname: ehloHostname, stepTimeout: stepTimeout, readyPromise: promise, initialDecoder: decoder @@ -218,30 +243,65 @@ public enum SMTPBootstrap { /// any real multiline EHLO/reply while being well short of unbounded. static let maximumReplyBufferSize = 64 * 1024 - static func makeSSLHandler(host: String, configuration: TLSConfiguration) throws -> NIOSSLClientHandler { + static func isIPAddressLiteral(_ host: String) -> Bool { + (try? SocketAddress(ipAddress: host, port: 0)) != nil + } + + /// Builds the client TLS handler for `host`. + /// + /// A host name is sent as SNI and the certificate must match it. Only a + /// genuine IP-address literal is dialed without SNI (RFC 6066 §3 forbids + /// IP addresses there); NIOSSL then checks the certificate against the + /// peer's address (an iPAddress subjectAltName). Release review: this + /// used to fall back to no SNI for *any* name NIOSSL's SNI check + /// rejected, so a name like `mx\0.victim.test` was verified against + /// the dialed IP instead, and a certificate whose only SAN was that IP + /// passed. Any other name NIOSSL refuses now fails the connection. + /// + /// With `requireDNSName`, the certificate must additionally have a DNS + /// subjectAltName matching `host` (wildcards as in RFC 8461 §4.2's + /// `mx` matching); an IP literal can't satisfy that. + static func makeSSLHandler(host: String, configuration: TLSConfiguration, requireDNSName: Bool = false) throws -> NIOSSLClientHandler { let context = try NIOSSLContext(configuration: configuration) - do { - return try NIOSSLClientHandler(context: context, serverHostname: host) - } catch { - // `host` failed SNI hostname validation (e.g. it's a bare IP - // address, which NIOSSL's SNI validator rejects outright per - // RFC 6066 §3) -- fall back to no SNI. This is NOT a - // verification bypass: `serverHostname: nil` only disables the - // SNI *extension* sent during the handshake (a hint some - // servers use for virtual hosting); it does not disable - // certificate verification. NIOSSL still performs full - // certificate-chain validation and still checks the peer's - // certificate against the real connection address -- for an - // IP-literal host specifically, that means verifying an - // iPAddress `subjectAltName` entry (RFC 6125 §6.4.2/RFC 5280) - // against the actual peer IP, exactly the check that would - // otherwise catch a MITM presenting the wrong certificate. - // Confirmed safe by the security review. + if isIPAddressLiteral(host) { + guard !requireDNSName else { throw SMTPError.tlsCertificateInvalid(underlying: CertificateNameError.noDNSName(host: host)) } return try NIOSSLClientHandler(context: context, serverHostname: nil) } + guard requireDNSName else { + return try NIOSSLClientHandler(context: context, serverHostname: host) + } + return try NIOSSLClientHandler._makeSSLClientHandler(context: context, serverHostname: host) { certificate, channel in + let names = certificate._subjectAlternativeNames().compactMap { name -> String? in + guard name.nameType == .dnsName else { return nil } + return String(decoding: name.contents, as: UTF8.self) + } + guard names.contains(where: { MXPatternMatcher.matches(pattern: $0, host: host) }) else { + return channel.eventLoop.makeFailedFuture(CertificateNameError.noDNSName(host: host)) + } + return channel.eventLoop.makeSucceededVoidFuture() + } + } + + /// Whether `error`, seen while TLS is being set up, means the peer's + /// certificate failed verification (as opposed to a malformed TLS + /// stream, which during STARTTLS is how injected plaintext shows up). + static func isCertificateFailure(_ error: any Error) -> Bool { + if error is CertificateNameError { return true } + if let extra = error as? NIOSSLExtraError, extra == .failedToValidateHostname { return true } + if case NIOSSLError.handshakeFailed(let underlying) = error { + return "\(underlying)".contains("CERTIFICATE_VERIFY_FAILED") + } + return false } } +/// The certificate verified as a chain but didn't name the host as +/// required. +public enum CertificateNameError: Error, Sendable, Equatable { + /// No DNS subjectAltName matches `host`. + case noDNSName(host: String) +} + /// Phase A's explicit state machine. `@unchecked Sendable`: constructed on, /// and every callback dispatched on, its channel's single event loop — /// never touched concurrently, matching this codebase's other @@ -269,6 +329,9 @@ final class SMTPBootstrapHandler: ChannelDuplexHandler, RemovableChannelHandler, private let tls: TLSMode private let host: String private let tlsConfiguration: TLSConfiguration + private let requireCertificateDNSName: Bool + /// The configured EHLO name, used for the pre-STARTTLS capability probe. + private let ehloHostname: String /// How long each bootstrap step (greeting, pre-TLS EHLO reply, /// STARTTLS reply, STARTTLS handshake) may take; see `SMTPBootstrap`. private let stepTimeout: TimeAmount @@ -286,6 +349,8 @@ final class SMTPBootstrapHandler: ChannelDuplexHandler, RemovableChannelHandler, tls: TLSMode, host: String, tlsConfiguration: TLSConfiguration, + requireCertificateDNSName: Bool = false, + ehloHostname: String = "localhost", stepTimeout: TimeAmount = .seconds(300), readyPromise: EventLoopPromise>, initialDecoder: SMTPResponseDecoder @@ -293,6 +358,8 @@ final class SMTPBootstrapHandler: ChannelDuplexHandler, RemovableChannelHandler, self.tls = tls self.host = host self.tlsConfiguration = tlsConfiguration + self.requireCertificateDNSName = requireCertificateDNSName + self.ehloHostname = ehloHostname self.stepTimeout = stepTimeout self.readyPromise = readyPromise self.initialDecoder = initialDecoder @@ -380,6 +447,13 @@ final class SMTPBootstrapHandler: ChannelDuplexHandler, RemovableChannelHandler, } func errorCaught(context: ChannelHandlerContext, error: Error) { + // A certificate that fails verification is its own outcome, in the + // upgrade window (STARTTLS) or before the greeting (implicit TLS): + // not an injection, and not a generic connection failure. + if SMTPBootstrap.isCertificateFailure(error) { + fail(context: context, .tlsCertificateInvalid(underlying: error)) + return + } guard state == .upgrading else { fail(context: context, .connectionFailed(error)) return @@ -420,7 +494,7 @@ final class SMTPBootstrapHandler: ChannelDuplexHandler, RemovableChannelHandler, case .startTLS: state = .awaitingPreTLSEHLOReply armStepTimer(context: context) - writeLine(context: context, "EHLO \(Self.probeHostname)") + writeLine(context: context, "EHLO \(ehloHostname)") } } @@ -523,7 +597,9 @@ final class SMTPBootstrapHandler: ChannelDuplexHandler, RemovableChannelHandler, guard state == .upgrading else { return } do { // Step 5: NIOSSLClientHandler at `.first`. - let sslHandler = try SMTPBootstrap.makeSSLHandler(host: host, configuration: tlsConfiguration) + let sslHandler = try SMTPBootstrap.makeSSLHandler( + host: host, configuration: tlsConfiguration, requireDNSName: requireCertificateDNSName + ) try context.pipeline.syncOperations.addHandler(sslHandler, position: .first) // Step 6: a fresh decoder above it. try context.pipeline.syncOperations.addHandler( @@ -567,6 +643,8 @@ final class SMTPBootstrapHandler: ChannelDuplexHandler, RemovableChannelHandler, context.channel.setOption(ChannelOptions.autoRead, value: true).whenFailure { [weak self] error in self?.fail(context: boundContext.value, .connectionFailed(error)) } + } catch let error as SMTPError { + fail(context: context, error) } catch { fail(context: context, .connectionFailed(error)) } @@ -627,12 +705,4 @@ final class SMTPBootstrapHandler: ChannelDuplexHandler, RemovableChannelHandler, context.writeAndFlush(Self.wrapOutboundOut(.line(line)), promise: nil) } - /// The EHLO identity used for the narrow pre-STARTTLS capability probe - /// only. Phase B's real EHLO (issued post-bootstrap) uses the caller's - /// actual configured EHLO hostname (plan §7's "no configurable - /// EHLO/HELO hostname" gap) -- this probe's own identity doesn't matter - /// since its only purpose is discovering whether STARTTLS is - /// advertised, and its capabilities are discarded, never reused past - /// the upgrade. - private static let probeHostname = "localhost" } diff --git a/Sources/PerfectSMTP/SMTPConnectionPool.swift b/Sources/PerfectSMTP/SMTPConnectionPool.swift index b77ca06..56402ec 100644 --- a/Sources/PerfectSMTP/SMTPConnectionPool.swift +++ b/Sources/PerfectSMTP/SMTPConnectionPool.swift @@ -61,10 +61,17 @@ public final class SMTPConnectionPool: Sendable { public let host: String public let port: Int public let tls: TLSMode - public init(host: String, port: Int, tls: TLSMode) { + /// Whether the server certificate must carry a DNS subjectAltName + /// for `host` (DirectMX under an MTA-STS policy; see + /// `SMTPBootstrap.connect(to:...)`). Part of the key, so a + /// connection verified without that check is never reused where + /// it is required. + public let requireCertificateDNSName: Bool + public init(host: String, port: Int, tls: TLSMode, requireCertificateDNSName: Bool = false) { self.host = host self.port = port self.tls = tls + self.requireCertificateDNSName = requireCertificateDNSName } } @@ -239,7 +246,8 @@ public final class SMTPConnectionPool: Sendable { self.dialer = { key in let asyncChannel = try await SMTPBootstrap.connect( host: key.host, port: key.port, tls: key.tls, - connectTimeout: capturedTimeout, replyTimeout: capturedReplyTimeout, group: capturedGroup + connectTimeout: capturedTimeout, replyTimeout: capturedReplyTimeout, + ehloHostname: capturedHostname, group: capturedGroup ) let connection = SMTPConnection( asyncChannel: asyncChannel, diff --git a/Sources/PerfectSMTPCore/SMTPError.swift b/Sources/PerfectSMTPCore/SMTPError.swift index 7721837..8fbd5cb 100644 --- a/Sources/PerfectSMTPCore/SMTPError.swift +++ b/Sources/PerfectSMTPCore/SMTPError.swift @@ -43,6 +43,12 @@ public enum SMTPError: Error, Sendable { /// `.connectionFailed`'s existing precedent for carrying an opaque /// underlying error in a `Sendable` enum. case starttlsInjection(underlying: (any Error & Sendable)?) + /// The server's TLS certificate didn't verify: an untrusted or expired + /// chain, a name that doesn't match the host, or (for MTA-STS) no DNS + /// subjectAltName for the MX host. Reported separately from + /// `.starttlsInjection`, which now only means bytes were injected + /// around the upgrade. + case tlsCertificateInvalid(underlying: (any Error & Sendable)?) /// MTA-STS / DANE policy violation (Phase 4). case tlsPolicyViolation(String) case circuitOpen diff --git a/Tests/PerfectSMTPTests/DirectMX/DirectMXFailureClassificationTests.swift b/Tests/PerfectSMTPTests/DirectMX/DirectMXFailureClassificationTests.swift new file mode 100644 index 0000000..aa275be --- /dev/null +++ b/Tests/PerfectSMTPTests/DirectMX/DirectMXFailureClassificationTests.swift @@ -0,0 +1,213 @@ +// +// DirectMXFailureClassificationTests.swift +// PerfectSMTPTests +// +// Release review: DirectMX used to report every connection-level failure +// as a final `.failed`, so nothing was ever retried. Following RFC 5321 +// §4.5.4.1, unreachable hosts, timeouts, an open breaker, TLS failures and +// DNS timeouts/SERVFAIL are now temporary (`.queuedForRetry`, and queued); +// NXDOMAIN, null MX and 5yz stay final. Also pins down when opportunistic +// TLS falls back to plaintext and when it never does. +// + +import NIOCore +import NIOEmbedded +import NIOPosix +import Testing +@testable import PerfectSMTP + +struct DirectMXFailureClassificationTests { + private func envelope(_ recipient: String) throws -> SMTPEnvelope { + try SMTPEnvelope(mailFrom: .address("from@sender.example"), recipients: [recipient]) + } + + private func message() -> SignedMessage { + SignedMessage(rfc5322: Array("Subject: hi\r\nFrom: from@sender.example\r\n\r\nbody".utf8)) + } + + private static let mx = FakeMXResolver(mxRecords: [ + "dest.example": [DNSResolver.MXRecord(preference: 10, exchange: "mx.dest.example")], + ], addresses: ["mx.dest.example": [.v4([127, 0, 0, 1])]]) + + // MARK: - The classification table + + @Test(arguments: [ + ("connect timeout", ChannelError.connectTimeout(.seconds(1)) as any Error, true), + ("connection refused", IOError(errnoCode: ECONNREFUSED, reason: "connect"), true), + ("reply timeout", SMTPConnectionError.replyTimedOut, true), + ("peer hung up", SMTPConnectionError.channelClosedByPeer, true), + ("circuit open", SMTPError.circuitOpen, true), + ("STARTTLS missing", SMTPError.starttlsRequired, true), + ("certificate", SMTPError.tlsCertificateInvalid(underlying: nil), true), + ("injection", SMTPError.starttlsInjection(underlying: nil), true), + ("wrapped timeout", SMTPError.connectionFailed(SMTPConnectionError.replyTimedOut), true), + ("DNS timeout", DNSResolver.ResolveError.timeout, true), + ("DNS SERVFAIL", DNSResolver.ResolveError.serverFailure(rcode: 2), true), + ("NXDOMAIN", DNSResolver.ResolveError.noRecordsFound, false), + ("null MX", DNSResolver.ResolveError.nullMX, false), + ("private address", DirectMXError.allResolvedAddressesFilteredAsPrivate(host: "mx"), false), + ("bad host name", AddressSyntax.Error.invalidHostname(field: "MX host"), false), + ("5yz", SMTPError.permanentFailure(SMTPReply(code: 550, lines: ["no"])), false), + ]) + func connectionFailuresAreClassifiedPerRFC5321(label: String, error: any Error, transient: Bool) { + #expect(DirectMXTransport.isTransientConnectionFailure(error) == transient, "\(label)") + let outcome = DirectMXTransport.classify(connectionFailure: error, domain: "dest.example", retryQueueConfiguration: .init()) + if transient { + guard case .queuedForRetry(_, _, let last) = outcome else { + Issue.record("\(label): expected .queuedForRetry, got \(outcome)") + return + } + #expect(last.code == 451) + } else { + guard case .failed = outcome else { + Issue.record("\(label): expected .failed, got \(outcome)") + return + } + } + } + + // MARK: - End to end + + @Test func anUnreachableHostIsQueuedForRetry() async throws { + let transport = DirectMXTransport( + resolver: Self.mx, config: DirectMXConfig(tlsPolicy: .fixed(.none)), group: NIOAsyncTestingEventLoop() + ) { _ in throw ChannelError.connectTimeout(.seconds(30)) } + let results = try await transport.send(try envelope("rcpt@dest.example"), message()) + guard case .queuedForRetry? = results.first?.outcome else { + Issue.record("expected .queuedForRetry, got \(String(describing: results.first?.outcome))") + return + } + #expect(await transport.pendingRetryEntries().count == 1) + await transport.shutdown() + } + + /// A real refused connect: a port on 127.0.0.1 with nothing listening. + @Test func aRealRefusedConnectionIsQueuedForRetry() async throws { + let group = MultiThreadedEventLoopGroup(numberOfThreads: 1) + let probe = try await ServerBootstrap(group: group).bind(host: "127.0.0.1", port: 0).get() + let port = try #require(probe.localAddress?.port) + try await probe.close() + + let transport = DirectMXTransport( + resolver: Self.mx, + config: DirectMXConfig(port: port, tlsPolicy: .fixed(.none), connectTimeout: .seconds(5), allowPrivateAddresses: true), + group: group + ) + let results = try await transport.send(try envelope("rcpt@dest.example"), message()) + await transport.shutdown() + try await group.shutdownGracefully() + guard case .queuedForRetry(_, _, let last)? = results.first?.outcome else { + Issue.record("expected .queuedForRetry, got \(String(describing: results.first?.outcome))") + return + } + #expect(last.lines.joined().contains("4.4.1")) + } + + @Test func aDNSTimeoutResolvingTheMXHostIsQueuedButNXDOMAINIsFinal() async throws { + struct Resolver: MXResolving { + let error: DNSResolver.ResolveError + func resolveMX(domain: String) async throws -> [DNSResolver.MXRecord] { [.init(preference: 10, exchange: "mx.dest.example")] } + func resolveAddresses(hostname: String) async throws -> [DNSAddress] { throw error } + } + for (error, transient) in [(DNSResolver.ResolveError.timeout, true), (.serverFailure(rcode: 2), true), (.noRecordsFound, false)] { + let group = MultiThreadedEventLoopGroup(numberOfThreads: 1) + let transport = DirectMXTransport(resolver: Resolver(error: error), config: DirectMXConfig(tlsPolicy: .fixed(.none)), group: group) + let results = try await transport.send(try envelope("rcpt@dest.example"), message()) + await transport.shutdown() + try await group.shutdownGracefully() + switch results.first?.outcome { + case .queuedForRetry?: #expect(transient, "\(error) should be final") + case .failed?: #expect(!transient, "\(error) should be retried") + default: Issue.record("\(error): unexpected \(String(describing: results.first?.outcome))") + } + } + } + + // MARK: - When opportunistic TLS falls back to plaintext + + private actor KeyLog { + private(set) var keys: [SMTPConnectionPool.Key] = [] + func record(_ key: SMTPConnectionPool.Key) { keys.append(key) } + var plaintextAttempts: Int { keys.filter { $0.tls == .none }.count } + } + + private func dial( + policy: DirectMXTLSPolicy, mtaSTS: MTASTSPolicy? = nil, startTLSFailure: any Error & Sendable + ) async throws -> (log: KeyLog, outcome: DeliveryResult.Outcome?) { + let log = KeyLog() + let provider = mtaSTS.map { FakeMTASTSPolicyProvider(policiesByDomain: ["dest.example": $0]) } + let transport = DirectMXTransport( + resolver: Self.mx, config: DirectMXConfig(tlsPolicy: policy), group: NIOAsyncTestingEventLoop(), + mtaSTSPolicyProvider: provider + ) { key in + await log.record(key) + if key.tls == .startTLS { throw startTLSFailure } + throw SimulatedConnectFailure(label: "plaintext attempt") + } + let results = try await transport.send(try envelope("rcpt@dest.example"), message()) + await transport.shutdown() + return (log, results.first?.outcome) + } + + /// No policy: a 454 reply to STARTTLS, a failed handshake or a bad + /// certificate falls back to plaintext on the same host (what an + /// attacker able to break TLS could force anyway by stripping STARTTLS). + @Test(arguments: [ + SMTPError.transientFailure(SMTPReply(code: 454, lines: ["4.7.0 TLS not available"])), + SMTPError.tlsCertificateInvalid(underlying: nil), + SMTPError.starttlsRequired, + SMTPError.connectionFailed(ChannelError.connectTimeout(.seconds(1))), + ]) + func opportunisticFallsBackToPlaintextWithoutAPolicy(failure: SMTPError) async throws { + let (log, _) = try await dial(policy: .opportunistic, startTLSFailure: failure) + #expect(await log.plaintextAttempts == 1, "\(failure)") + } + + /// ...but never after detected injection or an open breaker. + @Test(arguments: [SMTPError.starttlsInjection(underlying: nil), .circuitOpen]) + func opportunisticNeverFallsBackAfterInjectionOrAnOpenBreaker(failure: SMTPError) async throws { + let (log, outcome) = try await dial(policy: .opportunistic, startTLSFailure: failure) + #expect(await log.plaintextAttempts == 0) + guard case .queuedForRetry? = outcome else { + Issue.record("expected .queuedForRetry, got \(String(describing: outcome))") + return + } + } + + /// Required TLS (`.fixed(.startTLS)`) and MTA-STS enforce never fall + /// back, whatever the failure. + @Test(arguments: [ + SMTPError.transientFailure(SMTPReply(code: 454, lines: ["4.7.0 TLS not available"])), + SMTPError.tlsCertificateInvalid(underlying: nil), + SMTPError.starttlsRequired, + ]) + func requiredTLSAndEnforceNeverFallBack(failure: SMTPError) async throws { + let (fixedLog, fixedOutcome) = try await dial(policy: .fixed(.startTLS), startTLSFailure: failure) + #expect(await fixedLog.plaintextAttempts == 0) + guard case .queuedForRetry? = fixedOutcome else { + Issue.record("fixed(.startTLS): expected .queuedForRetry, got \(String(describing: fixedOutcome))") + return + } + + let enforce = MTASTSPolicy(mode: .enforce, mxPatterns: ["mx.dest.example"], maxAge: 86400) + let (enforceLog, enforceOutcome) = try await dial(policy: .opportunistic, mtaSTS: enforce, startTLSFailure: failure) + #expect(await enforceLog.plaintextAttempts == 0) + #expect(await enforceLog.keys.allSatisfy { $0.requireCertificateDNSName }) + guard case .queuedForRetry(_, _, let last)? = enforceOutcome, last.lines.joined().contains("4.7.5") else { + Issue.record("enforce: expected a 4.7.5 temporary failure, got \(String(describing: enforceOutcome))") + return + } + } + + /// Testing mode tries the policy hosts with the DNS-name check first, + /// then falls through to ordinary opportunistic delivery (RFC 8461 §5: + /// testing never blocks). + @Test func testingModeRequiresTheDNSNameFirstThenFallsThrough() async throws { + let testing = MTASTSPolicy(mode: .testing, mxPatterns: ["mx.dest.example"], maxAge: 86400) + let (log, _) = try await dial(policy: .opportunistic, mtaSTS: testing, startTLSFailure: SMTPError.tlsCertificateInvalid(underlying: nil)) + let keys = await log.keys + #expect(keys.first?.requireCertificateDNSName == true) + #expect(keys.contains { $0.tls == .startTLS && !$0.requireCertificateDNSName }) + #expect(await log.plaintextAttempts == 1) + } +} diff --git a/Tests/PerfectSMTPTests/DirectMX/DirectMXMTASTSEnforceTests.swift b/Tests/PerfectSMTPTests/DirectMX/DirectMXMTASTSEnforceTests.swift index fffacc7..6d39fad 100644 --- a/Tests/PerfectSMTPTests/DirectMX/DirectMXMTASTSEnforceTests.swift +++ b/Tests/PerfectSMTPTests/DirectMX/DirectMXMTASTSEnforceTests.swift @@ -124,12 +124,14 @@ struct DirectMXMTASTSEnforceTests { let results = try await transport.send(try envelope(recipients: ["rcpt@nomatch.example"]), message()) #expect(results.count == 1) - guard case .permanentlyFailed(let reply) = results[0].outcome else { - Issue.record("expected .permanentlyFailed, got \(results[0].outcome)") + // Release review: an enforce violation is temporary (RFC 8461 §5: + // don't deliver to it now; a later attempt may succeed). + guard case .queuedForRetry(_, _, let reply) = results[0].outcome else { + Issue.record("expected a temporary failure, got \(results[0].outcome)") return } - #expect(reply.code == 550) - #expect(reply.lines.first?.contains("5.7.1") == true) + #expect(reply.code == 451) + #expect(reply.lines.first?.contains("4.7.5") == true) } // MARK: - `enforce`: a policy-matched host without valid TLS hard-fails, never falls back to plaintext @@ -157,11 +159,11 @@ struct DirectMXMTASTSEnforceTests { let results = try await transport.send(try envelope(recipients: ["rcpt@tlsfail.example"]), message()) #expect(results.count == 1) - guard case .permanentlyFailed(let reply) = results[0].outcome else { - Issue.record("expected a hard-fail .permanentlyFailed outcome, not a plaintext-fallback delivery, got \(results[0].outcome)") + guard case .queuedForRetry(_, _, let reply) = results[0].outcome else { + Issue.record("expected a temporary policy failure, not a plaintext-fallback delivery, got \(results[0].outcome)") return } - #expect(reply.lines.first?.contains("5.7.1") == true) + #expect(reply.lines.first?.contains("4.7.5") == true) #expect(await plaintextWasAttempted.count == 0, "enforce mode must never attempt a plaintext (.none) connection") } diff --git a/Tests/PerfectSMTPTests/DirectMX/DirectMXOpportunisticTLSTests.swift b/Tests/PerfectSMTPTests/DirectMX/DirectMXOpportunisticTLSTests.swift index cda5312..e993b85 100644 --- a/Tests/PerfectSMTPTests/DirectMX/DirectMXOpportunisticTLSTests.swift +++ b/Tests/PerfectSMTPTests/DirectMX/DirectMXOpportunisticTLSTests.swift @@ -133,14 +133,14 @@ struct DirectMXOpportunisticTLSTests { try await group.shutdownGracefully() #expect(results.count == 1) - guard case .failed(let error) = results[0].outcome else { - Issue.record("expected .failed (an injection must hard-fail, never silently degrade to plaintext delivery), got \(results[0].outcome)") - return - } - guard let smtpError = error as? SMTPError, case .starttlsInjection = smtpError else { - Issue.record("expected the failure to be classified as SMTPError.starttlsInjection specifically -- got \(error) -- a different classification here would mean the injection went undetected or was misclassified as an ordinary opportunistic-fallback trigger") + // Release review: a connection-level failure is now temporary + // (queued for retry) rather than final, but it must still carry the + // injection classification and never have degraded to plaintext. + guard case .queuedForRetry(_, _, let last) = results[0].outcome, last.code == 451 else { + Issue.record("expected a temporary failure (an injection must never silently degrade to plaintext delivery), got \(results[0].outcome)") return } + #expect(last.lines.joined().contains("starttlsInjection"), "expected the failure to be classified as SMTPError.starttlsInjection specifically -- got \(last.lines)") } // MARK: - (4) FIX #1, CRITICAL (milestone security review): the pool's @@ -204,8 +204,8 @@ struct DirectMXOpportunisticTLSTests { for attempt in 1...5 { let recipientEnvelope = try envelope(recipients: ["rcpt\(attempt)@breakerlaundering.example"]) let results = try await transport.send(recipientEnvelope, message()) - guard results.count == 1, case .failed(let error) = results[0].outcome, - let smtpError = error as? SMTPError, case .starttlsInjection = smtpError + guard results.count == 1, case .queuedForRetry(_, _, let last) = results[0].outcome, + last.lines.joined().contains("starttlsInjection") else { Issue.record("attempt \(attempt): expected a detected STARTTLS-injection failure (to prime the circuit breaker), got \(results.map(\.outcome))") await transport.shutdown() diff --git a/Tests/PerfectSMTPTests/DirectMX/DirectMXTransportTests.swift b/Tests/PerfectSMTPTests/DirectMX/DirectMXTransportTests.swift index 3163bfa..8ff5ca9 100644 --- a/Tests/PerfectSMTPTests/DirectMX/DirectMXTransportTests.swift +++ b/Tests/PerfectSMTPTests/DirectMX/DirectMXTransportTests.swift @@ -217,14 +217,12 @@ struct DirectMXTransportTests { } #expect(lastResults.count == 1) - guard case .failed(let error) = lastResults[0].outcome else { - Issue.record("expected .failed once the sole host's breaker is open, got \(lastResults[0].outcome)") - return - } - guard let smtpError = error as? SMTPError, case .circuitOpen = smtpError else { - Issue.record("expected the .failed outcome to wrap SMTPError.circuitOpen, got \(error)") + // Release review: an open breaker is temporary -- queued for retry. + guard case .queuedForRetry(_, _, let last) = lastResults[0].outcome, last.code == 451 else { + Issue.record("expected a temporary failure once the sole host's breaker is open, got \(lastResults[0].outcome)") return } + #expect(last.lines.joined().contains("circuitOpen"), "expected the failure to name SMTPError.circuitOpen, got \(last.lines)") } // MARK: - `.ambiguous` is never queued for retry @@ -340,7 +338,7 @@ struct DirectMXTransportTests { // open -- proving the ambiguous outcome fed `recordFailure` // (not `recordSuccess`) during `release`. let r2 = try await transport.send(try envelope(recipients: ["b@goesambiguous.example"]), message()) - guard case .failed(let error) = r2[0].outcome, let smtpError = error as? SMTPError, case .circuitOpen = smtpError else { + guard case .queuedForRetry(_, _, let last) = r2[0].outcome, last.lines.joined().contains("circuitOpen") else { Issue.record("expected the second attempt to fail with SMTPError.circuitOpen (proving the ambiguous outcome fed the breaker), got \(r2[0].outcome)") return } @@ -422,7 +420,7 @@ struct DirectMXTransportTests { // hang writing a second MAIL FROM against a scripted server task // that already exited after replying once. let r2 = try await transport.send(try envelope(recipients: ["b@goes421.example"]), message()) - guard case .failed(let error) = r2[0].outcome, let smtpError = error as? SMTPError, case .circuitOpen = smtpError else { + guard case .queuedForRetry(_, _, let last) = r2[0].outcome, last.lines.joined().contains("circuitOpen") else { Issue.record("expected the second attempt to fail with SMTPError.circuitOpen, got \(r2[0].outcome)") return } diff --git a/Tests/PerfectSMTPTests/STARTTLSCertificateTests.swift b/Tests/PerfectSMTPTests/STARTTLSCertificateTests.swift new file mode 100644 index 0000000..bf339b1 --- /dev/null +++ b/Tests/PerfectSMTPTests/STARTTLSCertificateTests.swift @@ -0,0 +1,219 @@ +// +// STARTTLSCertificateTests.swift +// PerfectSMTPTests +// +// Release review: certificate name checking for STARTTLS, against a real +// TLS-speaking fake server on 127.0.0.1 with a local test CA +// (`TestCertificates`). +// +// - Only a genuine IP literal is dialed without SNI. Any other name +// NIOSSL refuses for SNI (the reviewer used `mx\0.victim.test`) used to +// fall back to checking the IP, so a certificate with just an IP SAN +// passed. +// - Under MTA-STS (`requireCertificateDNSName`), the certificate needs a +// DNS SAN for the MX host; an IP SAN alone is refused. +// - Certificate failures surface as `.tlsCertificateInvalid`, not as +// `.starttlsInjection`. +// + +import Foundation +import NIOCore +import NIOPosix +import NIOSSL +import Testing +@testable import PerfectSMTP + +struct STARTTLSCertificateTests { + enum Leaf { case ipOnly, named, dnsOnly } + + private func clientTLS() throws -> TLSConfiguration { + var configuration = TLSConfiguration.makeClientConfiguration() + configuration.trustRoots = .certificates([try NIOSSLCertificate(bytes: Array(TestCertificates.caPEM.utf8), format: .pem)]) + return configuration + } + + /// Runs one STARTTLS bootstrap against a fresh server presenting + /// `leaf`, returning the error (or nil on success). + private func bootstrap( + leaf: Leaf, sniHostname: String, requireDNSName: Bool = false, tls: TLSMode = .startTLS + ) async throws -> (any Error)? { + let group = MultiThreadedEventLoopGroup(numberOfThreads: 1) + let server = try await TLSFakeSMTPServer.start(leaf: leaf, implicit: tls == .implicit, group: group) + let address = try SocketAddress(ipAddress: "127.0.0.1", port: server.port) + var failure: (any Error)? + do { + let channel = try await SMTPBootstrap.connect( + to: address, sniHostname: sniHostname, tls: tls, replyTimeout: 10, + tlsConfiguration: try clientTLS(), requireCertificateDNSName: requireDNSName, group: group + ) + try? await channel.channel.close() + } catch { + failure = error + } + try? await server.channel.close() + try await group.shutdownGracefully() + return failure + } + + private func isCertificateInvalid(_ error: (any Error)?) -> Bool { + if case SMTPError.tlsCertificateInvalid? = error { return true } + return false + } + + @Test(.timeLimit(.minutes(1))) + func aMatchingDNSNameConnects() async throws { + #expect(try await bootstrap(leaf: .named, sniHostname: "mx.victim.test") == nil) + #expect(try await bootstrap(leaf: .named, sniHostname: "mx.victim.test", requireDNSName: true) == nil) + #expect(try await bootstrap(leaf: .named, sniHostname: "mx.victim.test", tls: .implicit) == nil) + } + + @Test(.timeLimit(.minutes(1))) + func anIPLiteralIsCheckedAgainstAnIPSubjectAltName() async throws { + #expect(try await bootstrap(leaf: .ipOnly, sniHostname: "127.0.0.1") == nil) + } + + /// The reviewer's case: a NUL in the name made NIOSSL refuse it for + /// SNI, the old fallback verified the IP instead, and the IP-only + /// certificate passed for "mx\0.victim.test". + @Test(.timeLimit(.minutes(1)), arguments: ["mx\u{0}.victim.test", "mx.victim.test.", "mx victim.test"]) + func aNameThatIsNotAHostnameIsRefusedInsteadOfFallingBackToTheIP(name: String) async throws { + let error = try await bootstrap(leaf: .ipOnly, sniHostname: name) + #expect(error != nil, "connected with sni=\(name.debugDescription)") + } + + /// NIOSSL accepts an iPAddress SAN equal to the dialed address even when + /// a host name is expected (RFC 6125 permits either identifier). For + /// relays and opportunistic direct MX that stays as is; under MTA-STS + /// the DNS name is required (below). + @Test(.timeLimit(.minutes(1))) + func anIPOnlyCertificateMatchingTheDialedAddressNeedsTheMTASTSCheckToBeRefused() async throws { + #expect(try await bootstrap(leaf: .ipOnly, sniHostname: "mx.victim.test") == nil) + let error = try await bootstrap(leaf: .ipOnly, sniHostname: "mx.victim.test", requireDNSName: true) + #expect(isCertificateInvalid(error), "got \(String(describing: error))") + } + + @Test(.timeLimit(.minutes(1))) + func aWrongDNSNameIsACertificateFailureNotAnInjection() async throws { + #expect(try await bootstrap(leaf: .dnsOnly, sniHostname: "mx.victim.test") == nil) + let error = try await bootstrap(leaf: .dnsOnly, sniHostname: "mx2.victim.test") + #expect(isCertificateInvalid(error), "got \(String(describing: error))") + let implicit = try await bootstrap(leaf: .dnsOnly, sniHostname: "mx2.victim.test", tls: .implicit) + #expect(isCertificateInvalid(implicit), "got \(String(describing: implicit))") + } + + /// MTA-STS: an IP literal can't satisfy the DNS-name requirement, and + /// a certificate valid only through its IP SAN is refused even if the + /// chain verifies. + @Test(.timeLimit(.minutes(1))) + func underMTASTSAnIPSubjectAltNameAloneIsRefused() async throws { + let literal = try await bootstrap(leaf: .ipOnly, sniHostname: "127.0.0.1", requireDNSName: true) + #expect(isCertificateInvalid(literal), "got \(String(describing: literal))") + let named = try await bootstrap(leaf: .named, sniHostname: "127.0.0.1", requireDNSName: true) + #expect(isCertificateInvalid(named), "got \(String(describing: named))") + } + + @Test(.timeLimit(.minutes(1))) + func anUntrustedChainIsACertificateFailure() async throws { + let group = MultiThreadedEventLoopGroup(numberOfThreads: 1) + let server = try await TLSFakeSMTPServer.start(leaf: .named, implicit: false, group: group) + let address = try SocketAddress(ipAddress: "127.0.0.1", port: server.port) + var failure: (any Error)? + do { + // The default trust store doesn't know the test CA. + _ = try await SMTPBootstrap.connect(to: address, sniHostname: "mx.victim.test", tls: .startTLS, replyTimeout: 10, group: group) + } catch { + failure = error + } + try? await server.channel.close() + try await group.shutdownGracefully() + #expect(isCertificateInvalid(failure), "got \(String(describing: failure))") + } +} + +/// A fake SMTP server that really speaks TLS: STARTTLS (or implicit TLS +/// from the first byte) with the chosen test certificate. +enum TLSFakeSMTPServer { + struct Running { + let channel: Channel + let port: Int + } + + static func start(leaf: STARTTLSCertificateTests.Leaf, implicit: Bool, group: any EventLoopGroup) async throws -> Running { + let (certPEM, keyPEM): (String, String) + switch leaf { + case .ipOnly: (certPEM, keyPEM) = (TestCertificates.ipOnlyCertificatePEM, TestCertificates.ipOnlyKeyPEM) + case .named: (certPEM, keyPEM) = (TestCertificates.namedCertificatePEM, TestCertificates.namedKeyPEM) + case .dnsOnly: (certPEM, keyPEM) = (TestCertificates.dnsOnlyCertificatePEM, TestCertificates.dnsOnlyKeyPEM) + } + let configuration = TLSConfiguration.makeServerConfiguration( + certificateChain: [.certificate(try NIOSSLCertificate(bytes: Array(certPEM.utf8), format: .pem))], + privateKey: .privateKey(try NIOSSLPrivateKey(bytes: Array(keyPEM.utf8), format: .pem)) + ) + let context = try NIOSSLContext(configuration: configuration) + let channel = try await ServerBootstrap(group: group) + .childChannelInitializer { channel in + channel.eventLoop.makeCompletedFuture { + if implicit { + try channel.pipeline.syncOperations.addHandler(NIOSSLServerHandler(context: context)) + } + try channel.pipeline.syncOperations.addHandler(TLSFakeSMTPHandler(context: context, implicit: implicit)) + } + } + .bind(host: "127.0.0.1", port: 0).get() + guard let port = channel.localAddress?.port else { throw ServerError.noPort } + return Running(channel: channel, port: port) + } + + enum ServerError: Error { case noPort } +} + +private final class TLSFakeSMTPHandler: ChannelInboundHandler, @unchecked Sendable { + typealias InboundIn = ByteBuffer + typealias OutboundOut = ByteBuffer + // Event-loop confined. + private let sslContext: NIOSSLContext + private let implicit: Bool + private var buffer = ByteBuffer() + private var upgraded = false + + init(context: NIOSSLContext, implicit: Bool) { + self.sslContext = context + self.implicit = implicit + self.upgraded = implicit + } + + func channelActive(context: ChannelHandlerContext) { + write(context, "220 fake.victim.test ESMTP") + context.fireChannelActive() + } + + func channelRead(context: ChannelHandlerContext, data: NIOAny) { + var incoming = Self.unwrapInboundIn(data) + buffer.writeBuffer(&incoming) + while let lf = buffer.readableBytesView.firstIndex(of: 0x0A) { + let bytes = buffer.readBytes(length: lf - buffer.readerIndex + 1) ?? [] + let line = String(decoding: bytes, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines).uppercased() + if line.hasPrefix("EHLO") { + write(context, "250-fake.victim.test") + write(context, upgraded ? "250 PIPELINING" : "250 STARTTLS") + } else if line == "STARTTLS", !upgraded { + write(context, "220 Ready to start TLS") + upgraded = true + try? context.pipeline.syncOperations.addHandler(NIOSSLServerHandler(context: sslContext), position: .first) + } else if line == "QUIT" { + write(context, "221 bye") + context.close(promise: nil) + } else { + write(context, "250 OK") + } + } + } + + func errorCaught(context: ChannelHandlerContext, error: any Error) { + context.close(promise: nil) + } + + private func write(_ context: ChannelHandlerContext, _ line: String) { + context.writeAndFlush(Self.wrapOutboundOut(ByteBuffer(string: line + "\r\n")), promise: nil) + } +} diff --git a/Tests/PerfectSMTPTests/TestCertificates.swift b/Tests/PerfectSMTPTests/TestCertificates.swift new file mode 100644 index 0000000..8880819 --- /dev/null +++ b/Tests/PerfectSMTPTests/TestCertificates.swift @@ -0,0 +1,96 @@ +// +// TestCertificates.swift +// PerfectSMTPTests +// +// A throwaway local CA and two leaf certificates for TLS tests on +// 127.0.0.1, valid for 100 years. Generated with OpenSSL for the 6.0.0 +// release review; the keys protect nothing. +// +// - `ipOnly`: subjectAltName IP:127.0.0.1 only. +// - `named`: subjectAltName DNS:mx.victim.test and IP:127.0.0.1. +// - `dnsOnly`: subjectAltName DNS:mx.victim.test only. +// + +enum TestCertificates { + static let caPEM = """ +-----BEGIN CERTIFICATE----- +MIIBozCCAUmgAwIBAgIUCb2nbvAuiah+AQsT/sv+K6q3gpswCgYIKoZIzj0EAwIw +HjEcMBoGA1UEAwwTUGVyZmVjdFNNVFAgVGVzdCBDQTAgFw0yNjEwMjMxNTQyMzBa +GA8yMTI2MDkyOTE1NDIzMFowHjEcMBoGA1UEAwwTUGVyZmVjdFNNVFAgVGVzdCBD +QTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABMZhJV3cGT9VnFBffibhJywHIu0c +D2n6NtEbfuShrZwWVs0qqB2Q5YIHze7jotH2qiKq8QpzGTZEJrZKNzWLvXqjYzBh +MB0GA1UdDgQWBBS9PExBSMC+VOIByNQrhiuMTI7+pzAfBgNVHSMEGDAWgBS9PExB +SMC+VOIByNQrhiuMTI7+pzAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIB +BjAKBggqhkjOPQQDAgNIADBFAiBWnqZdjp4SL/jZkXzUBvGXHS5cyBgj4bI9r1EH +R64EMQIhAMR+YQNcdL2c4z8P4vVYA0RPDTEygzG1IBiDrNDXTdx1 +-----END CERTIFICATE----- +""" + + static let ipOnlyCertificatePEM = """ +-----BEGIN CERTIFICATE----- +MIIBpjCCAUygAwIBAgIUUdOq/ybMwYAOwX5EEScXB3G7RaUwCgYIKoZIzj0EAwIw +HjEcMBoGA1UEAwwTUGVyZmVjdFNNVFAgVGVzdCBDQTAgFw0yNjEwMjMxNTQyMzBa +GA8yMTI2MDkyOTE1NDIzMFowETEPMA0GA1UEAwwGaXBvbmx5MFkwEwYHKoZIzj0C +AQYIKoZIzj0DAQcDQgAES/m34z+FXbbhz18v1VHbWdnL6ofh531XwooILU7UdTNp +bf/2awpquZNwKlVsr/Xzh/YkvDm6sLcL1q0Z0A9qiqNzMHEwDwYDVR0RBAgwBocE +fwAAATAJBgNVHRMEAjAAMBMGA1UdJQQMMAoGCCsGAQUFBwMBMB0GA1UdDgQWBBS/ +XbYfB48RsQKCHftasSBdp5FHaDAfBgNVHSMEGDAWgBS9PExBSMC+VOIByNQrhiuM +TI7+pzAKBggqhkjOPQQDAgNIADBFAiAPYxOQMLhmsA74nBh1jHsijQS51+tP93ox +LAGbEeaEDgIhALbM1mFsKjnbeOi2goAeavMwou+BymZ42ySayIRNhWi7 +-----END CERTIFICATE----- +""" + + static let ipOnlyKeyPEM = """ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg6ttdJVF6z6ltUY8B +VX5OkNDLYdJw1xYDajCW7TH/6PyhRANCAARL+bfjP4VdtuHPXy/VUdtZ2cvqh+Hn +fVfCiggtTtR1M2lt//ZrCmq5k3AqVWyv9fOH9iS8ObqwtwvWrRnQD2qK +-----END PRIVATE KEY----- +""" + + static let namedCertificatePEM = """ +-----BEGIN CERTIFICATE----- +MIIBuDCCAV2gAwIBAgIUUdOq/ybMwYAOwX5EEScXB3G7RaYwCgYIKoZIzj0EAwIw +HjEcMBoGA1UEAwwTUGVyZmVjdFNNVFAgVGVzdCBDQTAgFw0yNjEwMjMxNTQyMzBa +GA8yMTI2MDkyOTE1NDIzMFowEDEOMAwGA1UEAwwFbmFtZWQwWTATBgcqhkjOPQIB +BggqhkjOPQMBBwNCAATG+Fi59Px82qAXfQZldbC6hmLEgMiKocq6/OwsnTBhwjdV +tWQ85I0u5gNhsdZJ1FzH+gw0q+H4+8OZgL4kLDlto4GEMIGBMB8GA1UdEQQYMBaC +Dm14LnZpY3RpbS50ZXN0hwR/AAABMAkGA1UdEwQCMAAwEwYDVR0lBAwwCgYIKwYB +BQUHAwEwHQYDVR0OBBYEFIF6vgxemI8wtfm9AVfT5lwOjRyeMB8GA1UdIwQYMBaA +FL08TEFIwL5U4gHI1CuGK4xMjv6nMAoGCCqGSM49BAMCA0kAMEYCIQD56lt5p0np +xBKKJvE2i+MCxiDQ+9u9/4AodPk0JzjxRgIhAJzUIk3rxSQ6HomDiY1nLE22x1Ls +TeT/C7HPBtu3n8dv +-----END CERTIFICATE----- +""" + + static let namedKeyPEM = """ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgXTotoEizfa35YTdh +sbe0nmWcqEKdGF/WzPEUfxeDYG+hRANCAATG+Fi59Px82qAXfQZldbC6hmLEgMiK +ocq6/OwsnTBhwjdVtWQ85I0u5gNhsdZJ1FzH+gw0q+H4+8OZgL4kLDlt +-----END PRIVATE KEY----- +""" + + static let dnsOnlyCertificatePEM = """ +-----BEGIN CERTIFICATE----- +MIIBsDCCAVegAwIBAgIUUdOq/ybMwYAOwX5EEScXB3G7RacwCgYIKoZIzj0EAwIw +HjEcMBoGA1UEAwwTUGVyZmVjdFNNVFAgVGVzdCBDQTAgFw0yNjEwMjMxNTQ0MDZa +GA8yMTI2MDkyOTE1NDQwNlowEjEQMA4GA1UEAwwHZG5zb25seTBZMBMGByqGSM49 +AgEGCCqGSM49AwEHA0IABDrfmaBCd8bH9Lci7s2eLjDA8wyPRxKHk31dcSkUvtls +eQgf7IZwN2eFn7h2PX5JYPw+mutbGx1OhZTKm2l7qQKjfTB7MBkGA1UdEQQSMBCC +Dm14LnZpY3RpbS50ZXN0MAkGA1UdEwQCMAAwEwYDVR0lBAwwCgYIKwYBBQUHAwEw +HQYDVR0OBBYEFAW69YeqAt6fiR5+xGXPuZzQ4Ha8MB8GA1UdIwQYMBaAFL08TEFI +wL5U4gHI1CuGK4xMjv6nMAoGCCqGSM49BAMCA0cAMEQCIFTLeWsxEjdJmRnqdXo9 ++ZaFxkjInkh6kHdo5GED/1XIAiBm5n3HPByNg91TE7iSiafZ2Q+GXIDZjvT0F6o6 +SgLnSQ== +-----END CERTIFICATE----- +""" + + static let dnsOnlyKeyPEM = """ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgrtR4qk/DDhN1OnF+ +XQFJBjiEn5QWrA7GGLxaE282VpOhRANCAAQ635mgQnfGx/S3Iu7Nni4wwPMMj0cS +h5N9XXEpFL7ZbHkIH+yGcDdnhZ+4dj1+SWD8PprrWxsdToWUyptpe6kC +-----END PRIVATE KEY----- +""" +} From 69fc1a49c3b383640430a07c73e623dd2f20f7a2 Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Fri, 23 Oct 2026 10:50:47 -0500 Subject: [PATCH 20/39] Fold headers, including DKIM-Signature, under RFC 5322's line limits Header values went out on one line however long they were: 200 To addresses, 100 References, the DKIM-Signature's h= and b=, and a 2000-character subject. RFC 5322 2.1.1 caps lines at 998 octets and recommends 78. - MIMEComposer folds every top-level and MIME-part header at existing whitespace (HeaderFolding) before anything else sees it. A header that still has a line over 998 octets is refused (ComposerError.headerLineTooLong). - An unstructured value with an unbreakable run over 900 octets is RFC 2047-encoded so it can be folded. Encoded words carry 39 payload bytes so 'Subject: ' plus one word stays under 78. - DKIMSigner folds its tag list (FWS after ';' and ':'), so the hashed form is the sent form under both canonicalizations, and folds b= after signing (it is removed whole before verification). dkimpy verifies all 24 signatures (12 messages, RSA and Ed25519, every canonicalization pair) from the reviewer's harness. Its longest header line is now 78 octets, down from several hundred. Co-Authored-By: Claude Opus 5.5 --- Sources/PerfectSMTPCore/DKIM/DKIMSigner.swift | 34 ++++++++- Sources/PerfectSMTPCore/HeaderEncoder.swift | 15 +++- Sources/PerfectSMTPCore/HeaderFolding.swift | 72 +++++++++++++++++++ Sources/PerfectSMTPCore/MIMEComposer.swift | 15 +++- .../DKIMSignerTests.swift | 7 +- .../DeliverabilityHeadersTests.swift | 3 +- .../HeaderFoldingTests.swift | 72 +++++++++++++++++++ .../SMTPMailerDKIMIntegrationTests.swift | 8 ++- 8 files changed, 212 insertions(+), 14 deletions(-) create mode 100644 Sources/PerfectSMTPCore/HeaderFolding.swift create mode 100644 Tests/PerfectSMTPCoreTests/HeaderFoldingTests.swift diff --git a/Sources/PerfectSMTPCore/DKIM/DKIMSigner.swift b/Sources/PerfectSMTPCore/DKIM/DKIMSigner.swift index a8b5572..262b477 100644 --- a/Sources/PerfectSMTPCore/DKIM/DKIMSigner.swift +++ b/Sources/PerfectSMTPCore/DKIM/DKIMSigner.swift @@ -270,8 +270,17 @@ public struct DKIMSigner: Sendable, MessageSigner { // explicit tag would be redundant. `i=` (the Agent or User // Identifier) is essentially unused outside third-party-signer // / ADSP-era configurations this signer doesn't target. - let tagPrefix = "v=1; a=\(key.algorithm.tagValue); c=\(canon.header.rawValue)/\(canon.body.rawValue); " + - "d=\(domain); s=\(selector); t=\(timestamp); h=\(hTagValue); bh=\(bh); b=" + // Folded (release review: h= and b= made the header several + // hundred octets long). FWS is allowed after every ';' and ':' + // in the tag list (RFC 6376 §3.2, §3.5 h=), and the folded form + // is what gets hashed, so simple and relaxed canonicalization + // both see exactly what is sent. + let tagPrefix = HeaderFolding.fold( + name: "DKIM-Signature", + value: "v=1; a=\(key.algorithm.tagValue); c=\(canon.header.rawValue)/\(canon.body.rawValue); " + + "d=\(domain); s=\(selector); t=\(timestamp); h=\(hTagValue); bh=\(bh); b=", + insertingBreaksAfter: [UInt8(ascii: ":")] + ) let hashInput = DKIMSigningInput.headerHashInput( actualHeaders: message.headers, hNames: hNames, @@ -279,12 +288,31 @@ public struct DKIMSigner: Sendable, MessageSigner { dkimSignatureHeaderValue: tagPrefix ) let signatureBase64 = try key.sign(hashInput) - dkimHeaders.append(("DKIM-Signature", tagPrefix + signatureBase64)) + // b= is removed whole (with its whitespace) before verifying + // (RFC 6376 §3.7), so it can be folded freely after signing. + dkimHeaders.append(("DKIM-Signature", Self.appendFolded(signatureBase64, to: tagPrefix))) } return RFC5322Message(headers: dkimHeaders + message.headers, body: message.body) } + /// Appends `base64` to `prefix` (which ends in `b=`), breaking it with + /// folding whitespace so no line passes 78 octets. + static func appendFolded(_ base64: String, to prefix: String) -> String { + let lastLine = prefix.utf8.split(separator: UInt8(ascii: "\n"), omittingEmptySubsequences: false).last.map(\.count) ?? 0 + var result = prefix + var remaining = Substring(base64) + var room = max(1, HeaderFolding.recommendedLineLength - lastLine - (prefix.contains("\r\n") ? 0 : "DKIM-Signature: ".count)) + while !remaining.isEmpty { + let chunk = remaining.prefix(room) + result += chunk + remaining = remaining.dropFirst(chunk.count) + if !remaining.isEmpty { result += "\r\n " } + room = HeaderFolding.recommendedLineLength - 1 + } + return result + } + /// DMARC-alignment lint (plan §4.6): exposed as a pure data check, no /// logging dependency -- `PerfectSMTPCore` stays Foundation(+Crypto)- /// only. `SMTPMailer` (in the NIO target, which already depends on diff --git a/Sources/PerfectSMTPCore/HeaderEncoder.swift b/Sources/PerfectSMTPCore/HeaderEncoder.swift index bb83ead..e0200bb 100644 --- a/Sources/PerfectSMTPCore/HeaderEncoder.swift +++ b/Sources/PerfectSMTPCore/HeaderEncoder.swift @@ -41,7 +41,7 @@ public enum HeaderEncoder { /// characters. Payload capped at 45 bytes so the base64 expansion /// (ceil(45/3)*4 = 60 chars) plus overhead stays comfortably at or /// under `maxEncodedWordLength`. - private static let maxPayloadBytesPerWord = 45 + private static let maxPayloadBytesPerWord = 39 // 52 base64 chars + 12 = 64, so "Subject: " plus a word stays under 78 private static let atextChars: Set = Set( "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!#$%&'*+-/=?^_`{|}~" @@ -52,7 +52,10 @@ public enum HeaderEncoder { /// anything else is emitted as one or more folded encoded-words. public static func encodeUnstructured(_ text: String) -> String { let sanitized = sanitizeForHeader(text) - guard needsEncoding(sanitized) else { return sanitized } + // A run without whitespace can't be folded; past 900 octets it + // would break the 998-octet line limit, so encode it instead + // (encoded words are folded between words). + guard needsEncoding(sanitized) || longestUnbreakableRun(sanitized) > 900 else { return sanitized } return encodedWords(for: sanitized).joined(separator: "\r\n ") } @@ -171,6 +174,14 @@ public enum HeaderEncoder { return String(String.UnicodeScalarView(text.unicodeScalars.filter { !isForbiddenControlScalar($0) })) } + private static func longestUnbreakableRun(_ text: String) -> Int { + var longest = 0, current = 0 + for byte in text.utf8 { + if byte == UInt8(ascii: " ") || byte == UInt8(ascii: "\t") { current = 0 } else { current += 1; longest = max(longest, current) } + } + return longest + } + private static func needsEncoding(_ text: String) -> Bool { !text.utf8.allSatisfy { $0 >= 0x20 && $0 < 0x7F } } diff --git a/Sources/PerfectSMTPCore/HeaderFolding.swift b/Sources/PerfectSMTPCore/HeaderFolding.swift new file mode 100644 index 0000000..7e6dc75 --- /dev/null +++ b/Sources/PerfectSMTPCore/HeaderFolding.swift @@ -0,0 +1,72 @@ +// +// HeaderFolding.swift +// PerfectSMTPCore +// +// Release review: header values were emitted on one line however long +// they got (200 To addresses, 100 References, the DKIM-Signature's h= and +// b=). RFC 5322 §2.1.1 caps a line at 998 octets (MUST) and recommends 78 +// (SHOULD); many receivers reject or truncate longer lines. +// + +/// RFC 5322 §2.2.3 folding: inserting CRLF before existing whitespace. +public enum HeaderFolding { + /// The SHOULD limit of RFC 5322 §2.1.1, excluding the CRLF. + public static let recommendedLineLength = 78 + /// The MUST limit of RFC 5322 §2.1.1, excluding the CRLF. + public static let maximumLineLength = 998 + + /// Folds `value` so that, written as `name: value`, each physical line + /// is at most `recommendedLineLength` octets wherever a break point + /// allows. Break points are the space or tab characters already in the + /// value (the whitespace starts the continuation line); characters in + /// `insertingBreaksAfter` additionally allow a fold to be inserted + /// right after them (`"\r\n "`), for structured values whose grammar + /// permits FWS there (the DKIM-Signature tag list). Existing folds are + /// kept, so folding is idempotent. + public static func fold(name: String, value: String, insertingBreaksAfter: Set = []) -> String { + let space = UInt8(ascii: " "), tab = UInt8(ascii: "\t"), cr = UInt8(ascii: "\r"), lf = UInt8(ascii: "\n") + let bytes = Array(value.utf8) + var output: [UInt8] = [] + output.reserveCapacity(bytes.count + bytes.count / 60 * 3) + var lineLength = name.utf8.count + 2 // "name: " + // Index in `output` of the latest break opportunity on the current + // line, and whether it's a WSP (fold before it) or an insertion + // point (fold after the character, adding a space). + var lastBreak: (index: Int, inserting: Bool)? + + var i = 0 + while i < bytes.count { + let byte = bytes[i] + if byte == cr, i + 1 < bytes.count, bytes[i + 1] == lf { + // An existing fold: keep it and start a new line. + output.append(cr); output.append(lf) + i += 2 + lineLength = 0 + lastBreak = nil + continue + } + if (byte == space || byte == tab), lineLength > 0 { + lastBreak = (output.count, false) + } + output.append(byte) + lineLength += 1 + if insertingBreaksAfter.contains(byte) { + lastBreak = (output.count, true) + } + if lineLength > recommendedLineLength, let point = lastBreak, point.index < output.count { + let fold: [UInt8] = point.inserting ? [cr, lf, space] : [cr, lf] + output.insert(contentsOf: fold, at: point.index) + lineLength = output.count - point.index - 2 + lastBreak = nil + } + i += 1 + } + return String(decoding: output, as: UTF8.self) + } + + /// The longest physical line `name: value` produces, in octets. + public static func longestLine(name: String, value: String) -> Int { + let lines = "\(name): \(value)".utf8.split(separator: UInt8(ascii: "\n"), omittingEmptySubsequences: false) + return lines.map { $0.last == UInt8(ascii: "\r") ? $0.count - 1 : $0.count }.max() ?? 0 + } +} diff --git a/Sources/PerfectSMTPCore/MIMEComposer.swift b/Sources/PerfectSMTPCore/MIMEComposer.swift index b0263e3..921afbc 100644 --- a/Sources/PerfectSMTPCore/MIMEComposer.swift +++ b/Sources/PerfectSMTPCore/MIMEComposer.swift @@ -47,6 +47,10 @@ public struct MIMEComposer: Sendable { /// The boundary generator returned something that isn't an RFC 2046 /// boundary: 1–70 `bchars`, not ending in a space. case invalidBoundary(String) + /// A header value has a run without whitespace long enough that the + /// header can't be folded under RFC 5322's 998-octet line limit + /// (a caller-supplied Message-ID, reference or extra-header value). + case headerLineTooLong(String) /// `EmailMessage.listUnsubscribe.postOneClick` was `true` but `url` /// was `nil` — RFC 8058 §2's one-click mechanism is specifically /// for the HTTPS POST target, so there is no URL for @@ -311,7 +315,14 @@ public struct MIMEComposer: Sendable { body = Self.serializeMultipart(boundary: boundary, parts: parts) } - return RFC5322Message(headers: headers, body: body) + // Release review: fold every header to the RFC 5322 §2.1.1 limits + // before anything (DKIM included) sees it, and refuse one that + // can't be brought under 998 octets per line. + let folded = headers.map { (name: $0.name, value: HeaderFolding.fold(name: $0.name, value: $0.value)) } + for header in folded where HeaderFolding.longestLine(name: header.name, value: header.value) > HeaderFolding.maximumLineLength { + throw ComposerError.headerLineTooLong(header.name) + } + return RFC5322Message(headers: folded, body: body) } /// Builds `List-Unsubscribe`/`List-Unsubscribe-Post` (RFC 8058, building @@ -700,7 +711,7 @@ public struct MIMEComposer: Sendable { for part in parts { out.append(contentsOf: Array("--\(boundary)\r\n".utf8)) for (name, value) in part.headers { - out.append(contentsOf: Array("\(name): \(value)\r\n".utf8)) + out.append(contentsOf: Array("\(name): \(HeaderFolding.fold(name: name, value: value))\r\n".utf8)) } out.append(contentsOf: Array("\r\n".utf8)) switch part.body { diff --git a/Tests/PerfectSMTPCoreTests/DKIMSignerTests.swift b/Tests/PerfectSMTPCoreTests/DKIMSignerTests.swift index 9a13c8f..c3ae9b6 100644 --- a/Tests/PerfectSMTPCoreTests/DKIMSignerTests.swift +++ b/Tests/PerfectSMTPCoreTests/DKIMSignerTests.swift @@ -232,10 +232,11 @@ struct DKIMSignerTests { /// instead do a proper tag-list parse; this shortcut is sufficient for /// a same-process round-trip test. private static func splitOffSignature(_ headerValue: String) throws -> (tagPrefix: String, signatureBase64: String) { - let marker = "; b=" - let range = try #require(headerValue.range(of: marker)) + // The header is folded; the signature is everything after the last + // "b=", with its folding whitespace removed. + let range = try #require(headerValue.range(of: "b=", options: .backwards)) let tagPrefix = String(headerValue[.., ") + // Folded to stay under 78 octets; compare the unfolded value. + #expect(header.replacingOccurrences(of: "\r\n", with: "") == ", ") } @Test func onlyMailtoPresentEmitsJustTheMailtoEntry() throws { diff --git a/Tests/PerfectSMTPCoreTests/HeaderFoldingTests.swift b/Tests/PerfectSMTPCoreTests/HeaderFoldingTests.swift new file mode 100644 index 0000000..e7e7c52 --- /dev/null +++ b/Tests/PerfectSMTPCoreTests/HeaderFoldingTests.swift @@ -0,0 +1,72 @@ +// +// HeaderFoldingTests.swift +// PerfectSMTPCoreTests +// +// Release review: long headers (200 To addresses, 100 References, a +// 2000-character subject, the DKIM-Signature) used to go out on one line, +// past RFC 5322's 998-octet limit. +// + +import Foundation +import Testing +@testable import PerfectSMTPCore + +struct HeaderFoldingTests { + private static func longestHeaderLine(_ message: RFC5322Message) -> Int { + let text = String(decoding: message.serialized(), as: UTF8.self) + let header = text.components(separatedBy: "\r\n\r\n")[0] + return header.components(separatedBy: "\r\n").map(\.utf8.count).max() ?? 0 + } + + private static func base() -> EmailMessage { + var message = EmailMessage(from: EmailAddress(displayName: "Sender", address: "sender@example.test")) + message.to = [EmailAddress(address: "rcpt@example.test")] + message.textBody = "hello" + return message + } + + @Test func foldingBreaksBeforeWhitespaceAndIsIdempotent() { + let value = (0..<40).map { "word\($0)" }.joined(separator: " ") + let folded = HeaderFolding.fold(name: "X-Long", value: value) + #expect(HeaderFolding.longestLine(name: "X-Long", value: folded) <= 78) + #expect(folded.replacingOccurrences(of: "\r\n", with: "") == value) + #expect(HeaderFolding.fold(name: "X-Long", value: folded) == folded) + } + + @Test func manyRecipientsAndReferencesFoldUnder78() throws { + var message = Self.base() + message.to = (0..<200).map { EmailAddress(displayName: "User \($0)", address: "user\($0)@example.test") } + message.cc = (0..<50).map { EmailAddress(address: "cc\($0)@example.test") } + message.references = (0..<100).map { _ in "<\(UUID().uuidString)@example.test>" } + let composed = try MIMEComposer(message).compose() + #expect(Self.longestHeaderLine(composed) <= 78) + // Unfolding gives back every address. + let to = try #require(composed.headers.first { $0.name == "To" }?.value).replacingOccurrences(of: "\r\n", with: "") + #expect(to.components(separatedBy: ", ").count == 200) + } + + @Test func aLongUnbrokenSubjectIsEncodedSoItCanBeFolded() throws { + var message = Self.base() + message.subject = String(repeating: "A", count: 2000) + let composed = try MIMEComposer(message).compose() + #expect(Self.longestHeaderLine(composed) <= 78) + } + + @Test func anUnfoldableExtraHeaderIsRejected() { + var message = Self.base() + message.extraHeaders = [("X-Token", String(repeating: "x", count: 1200))] + #expect(throws: MIMEComposer.ComposerError.headerLineTooLong("X-Token")) { _ = try MIMEComposer(message).compose() } + } + + @Test func theDKIMSignatureHeaderIsFolded() throws { + var message = Self.base() + message.subject = "Folded DKIM" + let signer = try DKIMSigner( + domain: "example.test", selector: "sel", + signedHeaders: ["from", "to", "subject", "date", "message-id", "mime-version", "content-type", "reply-to", "cc"], + keys: [try SigningKey.ed25519(rawRepresentation: Data(repeating: 7, count: 32))] + ) + let signed = try signer.sign(try MIMEComposer(message).compose()) + #expect(Self.longestHeaderLine(signed) <= 78) + } +} diff --git a/Tests/PerfectSMTPTests/SMTPMailerDKIMIntegrationTests.swift b/Tests/PerfectSMTPTests/SMTPMailerDKIMIntegrationTests.swift index 19ad558..765535d 100644 --- a/Tests/PerfectSMTPTests/SMTPMailerDKIMIntegrationTests.swift +++ b/Tests/PerfectSMTPTests/SMTPMailerDKIMIntegrationTests.swift @@ -119,9 +119,11 @@ struct SMTPMailerDKIMIntegrationTests { // ...and the DKIM-Signature's own `h=` tag must list its name, // proving it's actually covered by the signature, not just // present alongside it. - let dkimLine = try #require(headerBlock.components(separatedBy: "\r\n").first { $0.hasPrefix("DKIM-Signature:") }) - let hTag = try #require(dkimLine.components(separatedBy: "; ").first { $0.hasPrefix("h=") }) - let signedNames = hTag.dropFirst("h=".count).split(separator: ":").map { $0.lowercased() } + // Unfold first: the DKIM-Signature header is folded. + let unfolded = headerBlock.replacingOccurrences(of: "\r\n ", with: " ").replacingOccurrences(of: "\r\n\t", with: " ") + let dkimLine = try #require(unfolded.components(separatedBy: "\r\n").first { $0.hasPrefix("DKIM-Signature:") }) + let hTag = try #require(dkimLine.components(separatedBy: ";").map { $0.trimmingCharacters(in: .whitespaces) }.first { $0.hasPrefix("h=") }) + let signedNames = hTag.dropFirst("h=".count).split(separator: ":").map { $0.trimmingCharacters(in: .whitespaces).lowercased() } #expect(signedNames.contains("x-campaign-id")) } From a6db0b7c49c6a624bed1bf24ada6c764e32b4795 Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Fri, 23 Oct 2026 10:52:45 -0500 Subject: [PATCH 21/39] Send a best-effort QUIT when the pool retires an idle connection Idle connections closed by shutdown(), eviction or the idle timeout now get QUIT (bounded at 2 s, without waiting for the 221) before their channel closes, so the server sees an orderly end of session. A connection released unhealthy is still just closed. The pool's slot accounting still runs off the channel's close future. Co-Authored-By: Claude Opus 5.5 --- Sources/PerfectSMTP/SMTPConnection.swift | 12 +++++++ Sources/PerfectSMTP/SMTPConnectionPool.swift | 26 +++++++++------ .../SMTPConnectionPoolQuitTests.swift | 33 +++++++++++++++++++ 3 files changed, 61 insertions(+), 10 deletions(-) create mode 100644 Tests/PerfectSMTPTests/SMTPConnectionPoolQuitTests.swift diff --git a/Sources/PerfectSMTP/SMTPConnection.swift b/Sources/PerfectSMTP/SMTPConnection.swift index 5cfbc58..78c0a51 100644 --- a/Sources/PerfectSMTP/SMTPConnection.swift +++ b/Sources/PerfectSMTP/SMTPConnection.swift @@ -145,6 +145,18 @@ public final class SMTPConnection: @unchecked Sendable { } } + /// Best effort: sends `QUIT` (bounded by `timeout`, without waiting for + /// the `221`), then closes the channel. Used when the pool retires an + /// idle connection, and by `RelayTransport.shutdown()` through it, so + /// the server sees an orderly end of session rather than a dropped + /// socket (RFC 5321 §4.1.1.10). + func quitAndClose(timeout: TimeInterval = 2) async { + if channel.isActive { + _ = try? await raceAgainstTimeout(timeout) { try await self.outbound.write(.line("QUIT")) } + } + channel.close(promise: nil) + } + /// Closes the channel and waits until it has closed. Used by dialers /// that fail after the socket was opened, so the pool only frees the /// dial's slot once the socket is really gone. diff --git a/Sources/PerfectSMTP/SMTPConnectionPool.swift b/Sources/PerfectSMTP/SMTPConnectionPool.swift index 56402ec..2b19d7f 100644 --- a/Sources/PerfectSMTP/SMTPConnectionPool.swift +++ b/Sources/PerfectSMTP/SMTPConnectionPool.swift @@ -172,7 +172,7 @@ public final class SMTPConnectionPool: Sendable { /// released (see `lockedEffects` and `run`). private struct Effects { var resumes: [(CheckedContinuation, Result)] = [] - var closes: [(connection: SMTPConnection, isEviction: Bool)] = [] + var closes: [(connection: SMTPConnection, isEviction: Bool, polite: Bool)] = [] } private struct State { @@ -195,14 +195,16 @@ public final class SMTPConnectionPool: Sendable { var evicting = 0 /// Closes decided under the current lock acquisition, not yet /// issued; `lockedEffects` hands them to `run(_:)`. - var queuedCloses: [(connection: SMTPConnection, isEviction: Bool)] = [] + var queuedCloses: [(connection: SMTPConnection, isEviction: Bool, polite: Bool)] = [] /// The only way the pool closes a connection: it keeps counting /// toward `maxTotal` from this moment until `closeFinished()`. - mutating func beginClosing(_ connection: SMTPConnection, isEviction: Bool = false) { + /// `polite`: the connection is idle and in command mode, so it gets + /// a best-effort `QUIT` before the close. + mutating func beginClosing(_ connection: SMTPConnection, isEviction: Bool = false, polite: Bool = false) { closing += 1 if isEviction { evicting += 1 } - queuedCloses.append((connection, isEviction)) + queuedCloses.append((connection, isEviction, polite)) } /// Every open connection the pool is responsible for: checked out, /// being dialed (a reserved slot), idle, or still closing. @@ -336,7 +338,7 @@ public final class SMTPConnectionPool: Sendable { let effects = lockedEffects { state, effects in state.isShutDown = true for (_, entries) in state.idle { - for entry in entries { state.beginClosing(entry.connection) } + for entry in entries { state.beginClosing(entry.connection, polite: true) } } state.idle.removeAll() for (_, list) in state.waiters { @@ -369,9 +371,13 @@ public final class SMTPConnectionPool: Sendable { /// lock. When each close completes, its slot is freed and parked /// waiters are admitted. private func run(_ effects: Effects) { - for (connection, isEviction) in effects.closes { - connection.channel.close(promise: nil) + for (connection, isEviction, polite) in effects.closes { connection.channel.closeFuture.whenComplete { _ in self.closeFinished(wasEviction: isEviction) } + if polite { + Task { await connection.quitAndClose() } + } else { + connection.channel.close(promise: nil) + } } for (continuation, result) in effects.resumes { continuation.resume(with: result) } } @@ -507,7 +513,7 @@ public final class SMTPConnectionPool: Sendable { } } guard let key = oldest?.key, var list = state.idle[key] else { return false } - state.beginClosing(list.removeFirst().connection, isEviction: true) + state.beginClosing(list.removeFirst().connection, isEviction: true, polite: true) state.idle[key] = list.isEmpty ? nil : list return true } @@ -520,7 +526,7 @@ public final class SMTPConnectionPool: Sendable { let ageNanoseconds = DispatchTime.now().uptimeNanoseconds &- entry.returnedAt.uptimeNanoseconds let ageSeconds = TimeInterval(ageNanoseconds) / 1_000_000_000 if ageSeconds > configuration.idleTimeout || !entry.connection.channel.isActive { - state.beginClosing(entry.connection) + state.beginClosing(entry.connection, polite: entry.connection.channel.isActive) continue } result = entry.connection @@ -571,7 +577,7 @@ public final class SMTPConnectionPool: Sendable { // socket) for the lifetime of the process. Close it immediately // instead. guard !state.isShutDown else { - state.beginClosing(connection) + state.beginClosing(connection, polite: healthy) return } if healthy, connection.channel.isActive { diff --git a/Tests/PerfectSMTPTests/SMTPConnectionPoolQuitTests.swift b/Tests/PerfectSMTPTests/SMTPConnectionPoolQuitTests.swift new file mode 100644 index 0000000..ee7d097 --- /dev/null +++ b/Tests/PerfectSMTPTests/SMTPConnectionPoolQuitTests.swift @@ -0,0 +1,33 @@ +// +// SMTPConnectionPoolQuitTests.swift +// PerfectSMTPTests +// +// Release review nit: retiring an idle connection sends a best-effort +// QUIT before closing, instead of just dropping the socket. +// + +import NIOConcurrencyHelpers +import NIOCore +import NIOEmbedded +import Testing +@testable import PerfectSMTP + +struct SMTPConnectionPoolQuitTests { + @Test func shutdownSendsQUITToIdleConnections() async throws { + let channels = NIOLockedValueBox<[NIOAsyncTestingChannel]>([]) + let pool = SMTPConnectionPool(configuration: .init(maxPerHost: 1), group: NIOAsyncTestingEventLoop()) { _ in + let (connection, channel) = try await ConnectionHarness.make() + try await channel.connect(to: SocketAddress(ipAddress: "127.0.0.1", port: 25)) + channels.withLockedValue { $0.append(channel) } + return connection + } + let key = SMTPConnectionPool.Key(host: "relay.example", port: 25, tls: .none) + try await pool.withConnection(to: key) { _ in () } // dialed, then idle + await pool.shutdown() + + let channel = try #require(channels.withLockedValue { $0.first }) + try await channel.closeFuture.get() + var written = try #require(try await channel.readOutbound(as: ByteBuffer.self)) + #expect(written.readString(length: written.readableBytes) == "QUIT\r\n") + } +} From 3503552d9cc9ed4739258881d4da49d14b87e5a0 Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Fri, 23 Oct 2026 10:53:33 -0500 Subject: [PATCH 22/39] Require one msg-id in Message-ID, In-Reply-To and References entries A caller value such as ' ' passed the control-character check and went out as two IDs in one field. Each value must now be a single '<...>' with no whitespace or brackets inside. Inline content IDs, which are wrapped in <...>, may not contain brackets or whitespace ('a> --- Sources/PerfectSMTPCore/MIMEComposer.swift | 16 ++++++++++++ .../AddressSyntaxTests.swift | 26 +++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/Sources/PerfectSMTPCore/MIMEComposer.swift b/Sources/PerfectSMTPCore/MIMEComposer.swift index 921afbc..d5297b8 100644 --- a/Sources/PerfectSMTPCore/MIMEComposer.swift +++ b/Sources/PerfectSMTPCore/MIMEComposer.swift @@ -197,6 +197,15 @@ public struct MIMEComposer: Sendable { "perfect-smtp-\(UUID().uuidString)" } + /// A single RFC 5322 §3.6.4 `msg-id`: `<`, one or more characters + /// other than whitespace and angle brackets, `>`. " " (two + /// IDs in one field) is refused. + private static func requireMessageID(_ value: String, field: String) throws { + guard value.count > 2, value.first == "<", value.last == ">", + !value.dropFirst().dropLast().contains(where: { $0 == "<" || $0 == ">" || $0.isWhitespace }) + else { throw ComposerError.invalidHeaderValue(field) } + } + /// RFC 5322 §3.6.8 `field-name = 1*ftext`, `ftext` = printable ASCII /// 33–126 except `:`. static func isValidFieldName(_ name: String) -> Bool { @@ -268,6 +277,7 @@ public struct MIMEComposer: Sendable { // validated above via the "From" header's `encodeAddress` // call, so it can't carry an injection payload. try Self.requireNoInjection(messageID, field: "messageID") + try Self.requireMessageID(messageID, field: "messageID") headers.append(("Message-ID", messageID)) } else { headers.append(("Message-ID", synthesizeMessageID())) @@ -275,11 +285,13 @@ public struct MIMEComposer: Sendable { if let inReplyTo = message.inReplyTo { try Self.requireNoInjection(inReplyTo, field: "inReplyTo") + try Self.requireMessageID(inReplyTo, field: "inReplyTo") headers.append(("In-Reply-To", inReplyTo)) } if !message.references.isEmpty { for reference in message.references { try Self.requireNoInjection(reference, field: "references") + try Self.requireMessageID(reference, field: "references") } headers.append(("References", message.references.joined(separator: " "))) } @@ -687,6 +699,10 @@ public struct MIMEComposer: Sendable { // a CR/LF in it would inject headers (or, with a blank line, body // content) into the inline part. try Self.requireNoInjection(resource.contentID, field: "inlineResource.contentID") + // It's wrapped in <...> below: no brackets or whitespace inside. + guard !resource.contentID.isEmpty, !resource.contentID.contains(where: { $0 == "<" || $0 == ">" || $0.isWhitespace }) else { + throw ComposerError.invalidHeaderValue("inlineResource.contentID") + } var headers: [(name: String, value: String)] = [] if let filename = resource.filename { let name = sanitizedFilename(filename) diff --git a/Tests/PerfectSMTPCoreTests/AddressSyntaxTests.swift b/Tests/PerfectSMTPCoreTests/AddressSyntaxTests.swift index 289cbd3..d80e807 100644 --- a/Tests/PerfectSMTPCoreTests/AddressSyntaxTests.swift +++ b/Tests/PerfectSMTPCoreTests/AddressSyntaxTests.swift @@ -113,3 +113,29 @@ struct AddressSyntaxTests { } } } + +struct MessageIDSyntaxTests { + private func base() -> EmailMessage { + var message = EmailMessage(from: EmailAddress(address: "ops@example.com")) + message.textBody = "hi" + return message + } + + @Test(arguments: [" ", "a@b", "<>", "", ""]) + func aCallerMessageIDMustBeOneMsgID(id: String) { + var message = base(); message.messageID = id + #expect(throws: MIMEComposer.ComposerError.invalidHeaderValue("messageID")) { _ = try MIMEComposer(message).compose() } + message = base(); message.inReplyTo = id + #expect(throws: MIMEComposer.ComposerError.invalidHeaderValue("inReplyTo")) { _ = try MIMEComposer(message).compose() } + message = base(); message.references = ["", id] + #expect(throws: MIMEComposer.ComposerError.invalidHeaderValue("references")) { _ = try MIMEComposer(message).compose() } + } + + @Test(arguments: ["a> ", ""]) + func aContentIDWithBracketsOrSpacesIsRejected(id: String) { + var message = base() + message.htmlBody = "

x

" + message.inlineImages = [InlineResource(contentID: id, contentType: "image/png", data: Data([1]))] + #expect(throws: MIMEComposer.ComposerError.invalidHeaderValue("inlineResource.contentID")) { _ = try MIMEComposer(message).compose() } + } +} From 5099147f48ada46bfce9da56d550cfa36f394805 Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Fri, 23 Oct 2026 10:56:20 -0500 Subject: [PATCH 23/39] Docs and CI for the review fixes README security defaults now describe what the code does: - the NIOSSL name check, and the IP-literal-only no-SNI rule; - the exact STARTTLS fallback rule (opportunistic falls back after 454, a failed handshake or certificate, or no STARTTLS; never after injection, with the breaker open, under .fixed(.startTLS) or under MTA-STS enforce); - the full special-purpose address filter; - the mailbox, hostname and header syntax rules (no SMTPUTF8 yet); - folding; - the streaming 64 KiB MTA-STS cap; - DirectMX retry classification; - that the retry queue is in memory only. The user guide is updated to match (opportunistic TLS, enforce now temporary 451 4.7.5, .failed vs queued). README.zh_CN.md drops the dead perfect.org banners and gains the NIOPosix note and an updated security paragraph. CI gains linux-offline: dependencies are resolved in a networked container, then the build and tests run under 'docker run --network none' (a job container can't drop its own network). Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 20 +++++++++- Documentation/user-guide.md | 77 ++++++++++++++++++++++++------------- README.md | 77 +++++++++++++++++++++++++++---------- README.zh_CN.md | 45 ++++++++-------------- 4 files changed, 141 insertions(+), 78 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 546f716..fd76ea5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,10 +37,28 @@ jobs: swift build --build-tests $flags # Hermetic: every test runs against in-process fakes on 127.0.0.1, with no DNS, - # internet or external SMTP server. + # internet or external SMTP server (linux-offline below enforces it). - name: Test run: swift test $FLAGS + # The suite must not need the network. A job container can't drop its + # network, so this runs docker directly: resolve with the network, then + # build and test in a container that has none. + linux-offline: + name: Linux (Swift 6.4, no network) + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - uses: actions/checkout@v7 + + - name: Resolve dependencies (networked) + run: docker run --rm -v "$PWD:/src" -w /src swift:6.4-noble swift package resolve + + - name: Build and test with --network none + run: | + docker run --rm --network none -e SWIFT_BACKTRACE=enable=no -v "$PWD:/src" -w /src swift:6.4-noble \ + bash -c "swift build --build-tests -Xswiftc -warnings-as-errors && swift test --skip-build" + macos: name: macOS (Xcode ${{ matrix.xcode }}, ${{ matrix.configuration }}) runs-on: macos-26 diff --git a/Documentation/user-guide.md b/Documentation/user-guide.md index 2dab408..c0531a4 100644 --- a/Documentation/user-guide.md +++ b/Documentation/user-guide.md @@ -92,6 +92,12 @@ A few things worth calling out: (`jane@dest.com`), stored separately so the header encoder can apply RFC 2047 encoding to the display name without ever touching the address itself. + The address has to be a plain mailbox (`local@domain`: a dot-atom local + part, and an LDH domain or an `[address literal]`), checked by + `AddressSyntax`. Quoted local parts, `<`, `>`, whitespace and control + characters are refused, and so are non-ASCII addresses: SMTPUTF8 isn't + implemented yet, so convert an internationalized domain to its A-label + (`xn--...`) form first. - **`bcc:` is a parameter of `send`, not a field on `EmailMessage`.** This is a deliberate structural fix (see [Migrating](#migrating-from-the-old-perfect-smtp)): Bcc addresses become @@ -312,20 +318,20 @@ doesn't apply to them. **`DirectMXTLSPolicy`** controls what TLS `DirectMXTransport` attempts against each resolved host: -- **`.opportunistic`** (the default): try `.startTLS` first — a real, - mandatory-verified handshake, same as `.startTLS` always is in this - library. If the server's EHLO simply doesn't advertise STARTTLS at all, - retry the *same host* in plaintext rather than treating it as - unreachable. Important nuance: a **genuine certificate/handshake - failure does not fall back to plaintext** — this library's STARTTLS - buffer-discipline fencing (the defense against the CVE-2026-41319-class - injection attack) deliberately can't distinguish "an attacker injected - bytes during the upgrade" from "the certificate is merely - misconfigured," so both are treated as the more dangerous case and the - connection to that host fails outright. The plaintext fallback only - ever fires when the peer doesn't offer STARTTLS at all. +- **`.opportunistic`** (the default): try `.startTLS` first, with full + certificate verification. If that fails, retry the *same host* in + plaintext: when the server doesn't advertise STARTTLS, answers it with + an error such as `454`, the handshake fails, or the certificate doesn't + verify (`SMTPError.tlsCertificateInvalid`). Someone able to break TLS on + the path could force plaintext anyway by stripping STARTTLS, so this + costs nothing and keeps mail flowing to the many MX hosts with + self-signed certificates. There is **no** plaintext retry after detected + STARTTLS injection (`.starttlsInjection`) or with the host's circuit + breaker open (it might have opened because of injections); delivery + moves on to the next MX host instead. - **`.fixed(TLSMode)`**: every host gets exactly the `TLSMode` you specify, - uniformly, with no opportunistic fallback and (crucially) **no MTA-STS + uniformly, with no opportunistic fallback (`.fixed(.startTLS)` is how to + require TLS for every host) and (crucially) **no MTA-STS involvement at all**, even for a domain that publishes an `enforce` policy. This is the explicit escape hatch for full manual control. @@ -352,12 +358,14 @@ policy per domain (in-memory only — nothing here persists across process restarts), and `DirectMXTransport` applies it automatically: - **`mode: enforce`** — only MX hosts matching the policy's `mx:` patterns - are ever dialed, mandatory STARTTLS only. If none match, or every - matching host fails, the whole domain hard-fails immediately (a - `550 5.7.1` permanent failure) — it never silently falls back to a - non-matching host or to plaintext. -- **`mode: testing`** — the same policy-matched, mandatory-TLS attempt runs - first, but a failure never blocks delivery: it falls through to the + are ever dialed, mandatory STARTTLS only, and the certificate must carry + a DNS subjectAltName for the MX host (an IP-address SAN alone isn't + accepted). If none match, or every matching host fails, nothing is + delivered now: the recipients get a temporary `451 4.7.5` and are queued + for retry (RFC 8461 §5), never a fallback to a non-matching host or to + plaintext. +- **`mode: testing`** — the same policy-matched, mandatory-TLS, + DNS-name-checked attempt runs first, but a failure never blocks delivery: it falls through to the ordinary opportunistic path across all resolved hosts (RFC 8461 §5 — testing mode must never block delivery). - **No policy for the domain, or `mode: none`** — identical to not having @@ -496,12 +504,14 @@ Every `DeliveryResult.Outcome` case, in plain language: never auto-retried (retrying here risks double delivery); surfaced so you can decide what's appropriate for your application (log it, alert on it, leave it for manual review). -- **`.failed(any Error & Sendable)`** — a transport-level or pre-flight - failure with no `SMTPReply` at all: a connection/timeout error, a - DKIM/compose failure, a circuit-breaker rejection, or (for the batch - `send` overloads specifically) one message's own thrown failure, mapped - to this case per its would-be recipients so it doesn't take down the - rest of the batch. +- **`.failed(any Error & Sendable)`** — a failure with no `SMTPReply` that + retrying won't fix: a DKIM/compose failure, an invalid address, a host + name that doesn't exist, addresses filtered as private, or (for the + batch `send` overloads specifically) one message's own thrown failure, + mapped to this case per its would-be recipients so it doesn't take down + the rest of the batch. `RelayTransport` and `LocalMTATransport` also + report connection errors this way; `DirectMXTransport` classifies those + as temporary (below). **Automatic retry is a `DirectMXTransport`-only feature.** Every transport classifies replies into these outcomes the same way (via the shared @@ -516,7 +526,19 @@ yourself (re-call `mailer.send` for the `.queuedForRetry` recipients after `.queuedForRetry` result from a `send` call is automatically enqueued and redelivered in the background on the classified backoff schedule, up to that queue's configured expiry/attempt ceiling (at which point the -recipient's final outcome becomes `.expired`, not silently dropped). If you +recipient's final outcome becomes `.expired`, not silently dropped). + +Following RFC 5321 §4.5.4.1, `DirectMXTransport` treats failing to reach a +domain as temporary: refused or reset connections, connect and reply +timeouts, a peer hanging up, an open circuit breaker, TLS failures, +DNS timeouts or SERVFAIL, and MTA-STS `enforce` violations all become +`.queuedForRetry` (a synthesized `451 4.4.1` or `451 4.7.5` reply carries +the cause). A host name that doesn't exist (NXDOMAIN), a null MX, and 5yz +replies are final. + +**The retry queue lives in memory only.** Pending retries are lost when the +process exits; if you need durability, record `.queuedForRetry` results +yourself (or deliver through a local MTA with its own spool). If you want visibility into a background retry's eventual outcome, pass `onTerminalRetryOutcome` to `DirectMXTransport`'s initializer; you can also inspect what's still pending via `pendingRetryEntries()`. @@ -528,7 +550,8 @@ dependencies and zero environment variables — including tests that exercise a real STARTTLS handshake, a full `DirectMXTransport` delivery, the DNS resolver and the MTA-STS fetcher against in-process fake servers bound to `127.0.0.1`. None of it needs the internet or DNS: the 6.0.0 -release checks ran it in Linux containers with no network beyond loopback. +release checks ran it in Linux containers with no network beyond loopback, +and CI's `linux-offline` job does the same on every push. **There is no built-in live-integration test tier against a real SMTP server**, despite one being described in the original rewrite plan diff --git a/README.md b/README.md index 6a076a6..6a8b7a3 100644 --- a/README.md +++ b/README.md @@ -150,35 +150,69 @@ You get these without configuring anything: - **TLS certificates are verified.** Every TLS connection (`.startTLS`, `.implicit`, and `DirectMXTransport`'s STARTTLS attempts) uses - `TLSConfiguration.makeClientConfiguration()`: full chain and hostname - verification. + `TLSConfiguration.makeClientConfiguration()`: full chain verification + plus a name check. The name check follows NIOSSL: the certificate must + name the host, or carry an IP-address SAN equal to the address + dialed. Only a real IP-literal host is dialed without SNI; any other + name that isn't a valid host name fails before connecting. A failed + certificate is reported as `SMTPError.tlsCertificateInvalid`. - **`.startTLS` never downgrades.** If the server doesn't advertise - `STARTTLS`, the connection fails with `SMTPError.starttlsRequired` - instead of continuing in plaintext. Bytes injected around the upgrade - (the CVE-2026-41319 class) fail the connection with - `.starttlsInjection`. + `STARTTLS`, or answers it with an error such as `454`, the connection + fails instead of continuing in plaintext. Bytes injected around the + upgrade (the CVE-2026-41319 class) fail it with `.starttlsInjection`. - **No AUTH in cleartext.** A `RelayConfig` with credentials and `tls: .none` throws `RelayTransportError.authenticationRequiresTLS` before dialing. Set `allowsAuthenticationWithoutTLS: true` only for a relay you trust, such as a loopback MTA or a test server. -- **No header or command injection.** Addresses, the EHLO hostname, extra - headers, message IDs, content types and content IDs are rejected if they - contain CR, LF or other control characters; subjects and display names - have them stripped and are RFC 2047-encoded. The DATA payload is - dot-stuffed. Bcc addresses only ever go into `RCPT TO`, never a header. +- **Strict syntax wherever text reaches the wire.** + - Addresses (MAIL FROM, RCPT TO, From/To/Cc/Reply-To/Sender) must be + plain `local@domain` mailboxes: a dot-atom local part, and an LDH + domain or an `[address literal]`. No quoted local parts, `<`, `>`, + whitespace or control characters, so nothing can smuggle an ESMTP + parameter or a second mailbox. Non-ASCII addresses are rejected + (SMTPUTF8 isn't implemented); pass IDN domains in A-label form. + - The EHLO name, TLS server name, DKIM `d=`/`s=` and `messageIDDomain` + must be LDH host names. + - Extra-header names must be RFC 5322 field names, and the + routing-critical names (`Bcc`, `To`, `From`...) are refused. + - Header values, message IDs, content types and content IDs are + refused if they contain CR, LF, other C0 or C1 controls, or + U+2028/U+2029. Subjects and display names have those stripped and + are RFC 2047-encoded. + - Headers are folded to 78 octets (refused if they can't be kept under + 998). The DATA payload has its line endings normalized to CRLF and + is dot-stuffed. Bcc addresses only ever go into `RCPT TO`. - **Every network step has a timeout:** TCP connect (30 s), each SMTP reply including the greeting, EHLO, STARTTLS and the TLS handshake (300 s), the - final reply after DATA (600 s), each DNS query (5 s), the MTA-STS policy - fetch (60 s), and the `sendmail` process (60 s). All are configurable. + final reply after DATA (600 s), each DNS query (5 s), the whole MTA-STS + policy fetch (60 s, and at most 64 KiB of body, enforced while reading), + and the `sendmail` process (60 s). All are configurable. - **No secrets in logs.** The library logs only the DKIM alignment warning and MTA-STS decisions (domains, hosts, errors), never message bodies or - credentials. `RelayConfig.Auth`, `SASLPlain`, `SASLLogin` and - `SigningKey` print redacted descriptions. -- **Direct MX delivery** refuses private, loopback and link-local - addresses unless `allowPrivateAddresses` is set, uses opportunistic - STARTTLS that falls back to plaintext only when a server doesn't offer - STARTTLS (never after a failed handshake), and honors MTA-STS `enforce` - policies when you configure a policy provider. + credentials. `RelayConfig.Auth`, `SASLPlain`, `SASLLogin`, `XOAuth2` and + `SigningKey` print redacted descriptions, and their mirrors (`dump`, the + debugger) hide the secret too. +- **Direct MX delivery:** + - It only dials globally reachable unicast addresses. Every IANA + special-purpose IPv4 and IPv6 range is refused (loopback, private, + `0.0.0.0/8`, link-local, CGNAT, documentation, benchmarking, + multicast, reserved), including IPv4 addresses embedded in mapped, + NAT64 and 6to4 IPv6 addresses, unless `allowPrivateAddresses` is set. + - DNS answers must match the question, and only records for the asked + name count. MX exchanges must be LDH host names, and a null MX + anywhere in the set means "no mail". + - STARTTLS is opportunistic with no MTA-STS policy: a host that doesn't + offer it, answers `454`, or fails the handshake or certificate is + retried in plaintext. It is never retried in plaintext after detected + injection or with its circuit breaker open, nor under + `.fixed(.startTLS)` or an MTA-STS `enforce` policy. + - Under an MTA-STS policy (`testing` or `enforce`), the certificate must + also carry a DNS subjectAltName for the MX host. + - Unreachable hosts, timeouts, TLS failures and DNS timeouts or SERVFAIL + are temporary and queued for retry (RFC 5321 §4.5.4.1), as is an + MTA-STS `enforce` violation. NXDOMAIN, null MX and 5yz replies are + final. The retry queue is in memory only: pending retries are lost if + the process exits. ## Migrating from 5.x @@ -219,7 +253,8 @@ The whole suite is hermetic: no environment variables, no internet, no DNS and no external mail server. Tests that need sockets (a real STARTTLS handshake, a full DirectMX delivery, the DNS resolver, the MTA-STS fetcher) run against in-process fake servers on `127.0.0.1`. CI runs it on -Linux and macOS in debug and release with `-warnings-as-errors`. +Linux and macOS in debug and release with `-warnings-as-errors`, plus a +Linux job in a container started with `--network none`. There is no live-integration tier. To check your own setup against a real SMTP server, point a `RelayTransport` at a local diff --git a/README.zh_CN.md b/README.zh_CN.md index 021e1db..3422c76 100644 --- a/README.zh_CN.md +++ b/README.zh_CN.md @@ -1,26 +1,5 @@ # Perfect - SMTP 简单邮件协议 [English](README.md) -

- - Get Involed with Perfect! - -

- -

- - Star Perfect On Github - - - Chat on Gitter - - - Follow Perfect on Twitter - - - Join the Perfect Slack - -

-

Swift 6.2+ @@ -31,9 +10,6 @@ License Apache 2.0 - - PerfectlySoft Twitter -

> **翻译说明:** 本文档由机器翻译并经过人工审阅,但尚未经过以 SMTP/DKIM 等协议 @@ -144,11 +120,22 @@ await transport.shutdown() try await group.shutdownGracefully() ``` -**安全默认值:** 所有 TLS 连接都会完整校验证书;`.startTLS` 在服务器未提供 -STARTTLS 时直接失败,绝不降级为明文;当 `tls: .none` 且配置了认证信息时, -`send` 会抛出 `RelayTransportError.authenticationRequiresTLS`,除非显式设置 -`allowsAuthenticationWithoutTLS: true`。详见英文 README 的 -[Security defaults](README.md#security-defaults) 一节。 +`MultiThreadedEventLoopGroup` 来自 SwiftNIO 的 `NIOPosix`,PerfectSMTP 已经依赖 +它;如果你的构建要求每个导入的模块都是显式声明的依赖,请添加 +`.package(url: "https://github.com/apple/swift-nio.git", from: "2.86.0")` 和 +`.product(name: "NIOPosix", package: "swift-nio")`。 + +**安全默认值:** 所有 TLS 连接都会完整校验证书(失败时报告 +`SMTPError.tlsCertificateInvalid`);`.startTLS` 在服务器未提供 STARTTLS +或返回 `454` 等错误时直接失败,绝不降级为明文;当 `tls: .none` 且配置了认证 +信息时,`send` 会抛出 `RelayTransportError.authenticationRequiresTLS`,除非 +显式设置 `allowsAuthenticationWithoutTLS: true`。地址必须是普通的 +`local@domain` 邮箱(不支持带引号的本地部分和非 ASCII 地址,国际化域名请先转换为 +`xn--` 形式),头字段名、值、EHLO 主机名等都会做严格校验,头字段按 78 字节折行。 +直连 MX 投递只连接全局单播地址;无 MTA-STS 策略时 STARTTLS 为机会性的,但在检测到 +注入、断路器打开、`.fixed(.startTLS)` 或 MTA-STS `enforce` 下绝不回退到明文; +连接失败、超时、TLS 失败和 DNS 超时会进入(仅保存在内存中的)重试队列。详见英文 +README 的 [Security defaults](README.md#security-defaults) 一节。 基础用法就是这些。关于 DKIM 签名、直连 MX 投递、认证方式、批量发送以及 送达率相关头字段,请参阅**[用户指南](Documentation/user-guide.md)**。 From 215b7e72a7e18e3a5b64bbd802a8e3a07c8d9074 Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Fri, 23 Oct 2026 10:59:30 -0500 Subject: [PATCH 24/39] Correct the Gmail/Yahoo bulk-sender requirement date to February 2024 Co-Authored-By: Claude Opus 5.5 --- Documentation/user-guide.md | 4 ++-- README.md | 2 +- README.zh_CN.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Documentation/user-guide.md b/Documentation/user-guide.md index c0531a4..eb13ec7 100644 --- a/Documentation/user-guide.md +++ b/Documentation/user-guide.md @@ -458,8 +458,8 @@ message.autoSubmitted = .autoGenerated ``` **`List-Unsubscribe`/`List-Unsubscribe-Post`** (RFC 8058) are no longer -optional for bulk mail: Gmail and Yahoo have enforced this — rejecting -mail outright that doesn't have it — for bulk senders since November 2025. +optional for bulk mail: Gmail and Yahoo have required them from bulk +senders since February 2024. `ListUnsubscribe.url`, if set, **must be `https://`** — RFC 8058 §3.1 requires it, and `MIMEComposer` enforces this unconditionally (not just when `postOneClick` is set), throwing diff --git a/README.md b/README.md index 6a8b7a3..9b93166 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ which uses it to implement its `email_send` tag. Gmail/Workspace, increasingly required by Microsoft 365). - **Deliverability headers** — `List-Unsubscribe`/`List-Unsubscribe-Post` (RFC 8058), `Precedence`, `Auto-Submitted` — the headers Gmail and Yahoo - have required for bulk senders since November 2025. + have required for bulk senders since February 2024. - **Bulk/list-server ready** — a bounded-concurrency batch `send` and an `AsyncSequence`-based streaming `send` for sending to millions of recipients without materializing them all in memory. diff --git a/README.zh_CN.md b/README.zh_CN.md index 3422c76..47edf13 100644 --- a/README.zh_CN.md +++ b/README.zh_CN.md @@ -46,7 +46,7 @@ libcurl 或其他邮件库的封装——它自己实现了 SMTP 线路协议, - **SASL 认证**——`PLAIN`、`LOGIN`、`XOAUTH2`(Gmail/Workspace 必需, Microsoft 365 也在逐步要求使用)。 - **送达率相关头字段**——`List-Unsubscribe`/`List-Unsubscribe-Post` - (RFC 8058)、`Precedence`、`Auto-Submitted`——自 2025 年 11 月起 + (RFC 8058)、`Precedence`、`Auto-Submitted`——自 2024 年 2 月起 Gmail 和 Yahoo 已对批量发件人强制要求这些头字段。 - **面向批量/列表服务器场景**——有并发上限的批量 `send`,以及基于 `AsyncSequence` 的流式 `send`,可以在不将全部收件人一次性载入内存的情况下 From a1881b77fff234d66ef476daca44094efe24c373 Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Fri, 23 Oct 2026 11:17:17 -0500 Subject: [PATCH 25/39] Stop the Linux MTA-STS transfer as soon as the size cap is hit On Linux the first version of the streaming cap failed every run. After '.cancel' or cancel(), corelibs Foundation kept reading the endless body until the test closed the server (10 GB on loopback). The fetch now fails the moment the cap (or an oversized Content-Length) is seen, then cancels the task and invalidates the session. The test now also checks that the client hangs up. The deadline test's trickling body is now declared under the cap, so the deadline, not the cap, is what ends it. Co-Authored-By: Claude Opus 5.5 --- .../MTASTS/MTASTSHTTPFetching.swift | 26 ++++++++++++----- .../MTASTSHTTPFetcherSizeLimitTests.swift | 28 +++++++++++++++---- .../MTASTSHTTPFetcherTimeoutTests.swift | 4 +-- 3 files changed, 43 insertions(+), 15 deletions(-) diff --git a/Sources/PerfectSMTP/MTASTS/MTASTSHTTPFetching.swift b/Sources/PerfectSMTP/MTASTS/MTASTSHTTPFetching.swift index 51568b4..eb517d1 100644 --- a/Sources/PerfectSMTP/MTASTS/MTASTSHTTPFetching.swift +++ b/Sources/PerfectSMTP/MTASTS/MTASTSHTTPFetching.swift @@ -318,15 +318,30 @@ private final class RedirectRefusingFetchDelegate: NSObject, URLSessionDataDeleg ) { // Reject a declared oversized body before reading any of it. guard response.expectedContentLength <= Int64(limit) else { - lock.lock() - tooLarge = true - lock.unlock() completionHandler(.cancel) + abandonTooLarge(dataTask, session: session) return } completionHandler(.allow) } + /// Fails the fetch with `.responseTooLarge` right away and tears the + /// transfer down. corelibs doesn't stop reading promptly on `.cancel` + /// or `cancel()` alone (on Linux the reviewer's endless body kept + /// arriving), so the session is invalidated too and the caller isn't + /// left waiting for `didCompleteWithError`. + private func abandonTooLarge(_ task: URLSessionTask, session: URLSession) { + lock.lock() + tooLarge = true + body = Data() + let continuation = self.continuation + self.continuation = nil + lock.unlock() + continuation?.resume(throwing: MTASTSHTTPFetchError.responseTooLarge(limit: limit)) + task.cancel() + session.invalidateAndCancel() + } + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { lock.lock() guard !tooLarge else { @@ -334,11 +349,8 @@ private final class RedirectRefusingFetchDelegate: NSObject, URLSessionDataDeleg return } if body.count + data.count > limit { - // Stop reading: drop what was buffered and cancel the task. - tooLarge = true - body = Data() lock.unlock() - dataTask.cancel() + abandonTooLarge(dataTask, session: session) return } body.append(data) diff --git a/Tests/PerfectSMTPTests/MTASTS/MTASTSHTTPFetcherSizeLimitTests.swift b/Tests/PerfectSMTPTests/MTASTS/MTASTSHTTPFetcherSizeLimitTests.swift index c4d830f..6d6240c 100644 --- a/Tests/PerfectSMTPTests/MTASTS/MTASTSHTTPFetcherSizeLimitTests.swift +++ b/Tests/PerfectSMTPTests/MTASTS/MTASTSHTTPFetcherSizeLimitTests.swift @@ -22,11 +22,12 @@ struct MTASTSHTTPFetcherSizeLimitTests { func anEndlessBodyIsCutOffAtTheCapWithoutBufferingIt(declareHugeLength: Bool) async throws { let group = MultiThreadedEventLoopGroup(numberOfThreads: 1) let sent = NIOLockedValueBox(0) + let closed = NIOLockedValueBox(false) let channel = try await ServerBootstrap(group: group) .childChannelInitializer { channel in channel.eventLoop.makeCompletedFuture { try channel.pipeline.syncOperations.addHandler( - EndlessBodyHTTPHandler(declareHugeLength: declareHugeLength, sent: sent) + EndlessBodyHTTPHandler(declareHugeLength: declareHugeLength, sent: sent, closed: closed) ) } } @@ -35,16 +36,24 @@ struct MTASTSHTTPFetcherSizeLimitTests { let fetcher = URLSessionMTASTSFetcher(timeout: 30) let url = try #require(URL(string: "http://127.0.0.1:\(port)/.well-known/mta-sts.txt")) + let started = Date() await #expect(throws: MTASTSHTTPFetchError.responseTooLarge(limit: MTASTSPolicyManager.maximumPolicyResponseSizeBytes)) { _ = try await fetcher.fetch(url: url) } + #expect(Date().timeIntervalSince(started) < 10) + // The client must hang up rather than keep draining the body: + // wait for the server to see the connection close, then check the + // server didn't get far ahead (socket buffers allow some slack). + let deadline = Date().addingTimeInterval(10) + while !closed.withLockedValue({ $0 }), Date() < deadline { + try await Task.sleep(nanoseconds: 10_000_000) + } + let total = sent.withLockedValue { $0 } try await channel.close() try await group.shutdownGracefully() - // Socket buffers let the server get some way ahead of the client, - // but nowhere near the unbounded amount an uncapped read would pull. - let total = sent.withLockedValue { $0 } - #expect(total < 64 * 1024 * 1024, "the server streamed \(total) bytes before the client stopped reading") + #expect(closed.withLockedValue { $0 }, "the client never closed the connection; the server streamed \(total) bytes") + #expect(total < 256 * 1024 * 1024, "the server streamed \(total) bytes before the client stopped reading") } @Test func aPolicyManagerTreatsAnOversizedFetchAsNoPolicy() async throws { @@ -69,12 +78,19 @@ private final class EndlessBodyHTTPHandler: ChannelInboundHandler, @unchecked Se // Event-loop confined. private let declareHugeLength: Bool private let sent: NIOLockedValueBox + private let closed: NIOLockedValueBox private var started = false private let chunk = ByteBuffer(repeating: UInt8(ascii: "#"), count: 64 * 1024) - init(declareHugeLength: Bool, sent: NIOLockedValueBox) { + init(declareHugeLength: Bool, sent: NIOLockedValueBox, closed: NIOLockedValueBox) { self.declareHugeLength = declareHugeLength self.sent = sent + self.closed = closed + } + + func channelInactive(context: ChannelHandlerContext) { + closed.withLockedValue { $0 = true } + context.fireChannelInactive() } func channelRead(context: ChannelHandlerContext, data: NIOAny) { diff --git a/Tests/PerfectSMTPTests/MTASTS/MTASTSHTTPFetcherTimeoutTests.swift b/Tests/PerfectSMTPTests/MTASTS/MTASTSHTTPFetcherTimeoutTests.swift index a24b3af..a8d8a75 100644 --- a/Tests/PerfectSMTPTests/MTASTS/MTASTSHTTPFetcherTimeoutTests.swift +++ b/Tests/PerfectSMTPTests/MTASTS/MTASTSHTTPFetcherTimeoutTests.swift @@ -42,7 +42,7 @@ struct MTASTSHTTPFetcherTimeoutTests { } } -/// Answers any request with a 200 whose 100 kB body arrives one byte every +/// Answers any request with a 200 whose 60 kB body (under the 64 KiB cap) arrives one byte every /// 100 ms, so bytes never stop for long enough to trip an idle timeout. private final class TricklingHTTPHandler: ChannelInboundHandler, @unchecked Sendable { typealias InboundIn = ByteBuffer @@ -55,7 +55,7 @@ private final class TricklingHTTPHandler: ChannelInboundHandler, @unchecked Send func channelRead(context: ChannelHandlerContext, data: NIOAny) { guard !started else { return } started = true - let head = "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 100000\r\n\r\n" + let head = "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 60000\r\n\r\n" context.writeAndFlush(Self.wrapOutboundOut(ByteBuffer(string: head)), promise: nil) let bound = NIOLoopBound(context, eventLoop: context.eventLoop) task = context.eventLoop.scheduleRepeatedTask(initialDelay: .milliseconds(100), delay: .milliseconds(100)) { _ in From cde9a1d1215b7c2dcd0d6f416b75f60519ff49da Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Fri, 23 Oct 2026 11:48:03 -0500 Subject: [PATCH 26/39] MTA-STS size-cap test: assert only the hang-up on Linux In loaded full-suite runs on Linux, corelibs Foundation sometimes kept reading the endless body for a few seconds after the cancel (up to 2.4 GB over loopback), so the byte-count bound failed. The bytes are dropped, not buffered: a standalone probe under 12 busy threads kept max RSS between 31 and 60 MB over ten endless-body fetches, each failing with responseTooLarge in under 50 ms. On Linux the test now checks that the fetch fails promptly and the client closes the connection; the byte bound stays on Darwin. Co-Authored-By: Claude Opus 5.5 --- .../MTASTS/MTASTSHTTPFetcherSizeLimitTests.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Tests/PerfectSMTPTests/MTASTS/MTASTSHTTPFetcherSizeLimitTests.swift b/Tests/PerfectSMTPTests/MTASTS/MTASTSHTTPFetcherSizeLimitTests.swift index 6d6240c..d2ec51a 100644 --- a/Tests/PerfectSMTPTests/MTASTS/MTASTSHTTPFetcherSizeLimitTests.swift +++ b/Tests/PerfectSMTPTests/MTASTS/MTASTSHTTPFetcherSizeLimitTests.swift @@ -53,7 +53,14 @@ struct MTASTSHTTPFetcherSizeLimitTests { try await channel.close() try await group.shutdownGracefully() #expect(closed.withLockedValue { $0 }, "the client never closed the connection; the server streamed \(total) bytes") + #if !canImport(FoundationNetworking) #expect(total < 256 * 1024 * 1024, "the server streamed \(total) bytes before the client stopped reading") + #endif + // On Linux, corelibs Foundation can keep draining the socket for a + // moment after the task is cancelled (once 2.4 GB over loopback in + // a loaded full-suite run). The bytes are dropped, not buffered: a + // standalone probe under CPU load kept max RSS at about 60 MB over + // ten endless-body fetches. So only the hang-up is asserted there. } @Test func aPolicyManagerTreatsAnOversizedFetchAsNoPolicy() async throws { From 229080ce361d12aeed53d5a46e07cc667a315b01 Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Fri, 23 Oct 2026 12:36:20 -0500 Subject: [PATCH 27/39] Fetch MTA-STS policies over SwiftNIO by default, capped on every platform The streaming cap in URLSessionMTASTSFetcher holds on Darwin, but on Linux corelibs Foundation keeps reading and queueing the body after the task is cancelled. In the endless-body test on linux/amd64 the test process reached 5.9 GB RSS and was OOM-killed (exit 137) in 6 of 15 runs; the redirect and deadline tests peaked at about 60 MB. New NIOMTASTSHTTPFetcher, now MTASTSPolicyManager's default: an HTTP/1.1 GET over SwiftNIO with NIOHTTP1 (part of swift-nio, so no new package): - full certificate verification, with SNI for host names; - no redirects followed; - a whole-fetch timeout; - a refused oversized Content-Length; - the connection closed as soon as the body passes 64 KiB. URLSessionMTASTSFetcher stays as an opt-in, documented as not memory-safe against an endless body on Linux. The size, deadline and redirect tests now run against both fetchers (the endless-body case only against the NIO one on Linux). New tests cover a real policy over HTTPS from the local test CA, chunked bodies, and an untrusted certificate. Co-Authored-By: Claude Opus 5.5 --- Documentation/user-guide.md | 15 +- Package.swift | 1 + .../MTASTS/MTASTSHTTPFetching.swift | 7 + .../MTASTS/MTASTSPolicyManager.swift | 6 +- .../MTASTS/NIOMTASTSHTTPFetcher.swift | 196 ++++++++++++++++++ .../MTASTSHTTPFetcherRedirectTests.swift | 5 +- .../MTASTSHTTPFetcherSizeLimitTests.swift | 116 ++++++++++- .../MTASTSHTTPFetcherTimeoutTests.swift | 6 +- 8 files changed, 329 insertions(+), 23 deletions(-) create mode 100644 Sources/PerfectSMTP/MTASTS/NIOMTASTSHTTPFetcher.swift diff --git a/Documentation/user-guide.md b/Documentation/user-guide.md index eb13ec7..03fd265 100644 --- a/Documentation/user-guide.md +++ b/Documentation/user-guide.md @@ -372,11 +372,16 @@ restarts), and `DirectMXTransport` applies it automatically: configured a provider at all: plain opportunistic delivery per `tlsPolicy`. -The policy fetch is bounded as a whole: `URLSessionMTASTSFetcher` (the -default `httpFetcher`) gives up after `timeout` seconds (default 60) from -connect to the last body byte and throws `MTASTSHTTPFetchError.timedOut`, -so a slow policy host can't hold up delivery to its domain indefinitely. -Redirects are never followed (RFC 8461 §3.3). +The policy fetch is bounded as a whole. `NIOMTASTSHTTPFetcher` (the +default `httpFetcher`, a small HTTP/1.1 client on SwiftNIO with full +certificate verification) gives up after `timeout` seconds (default 60) +from connect to the last body byte (`MTASTSHTTPFetchError.timedOut`), and +stops reading past 64 KiB of body (`.responseTooLarge`). A slow or endless +policy host therefore can't hold up delivery to its domain or exhaust +memory. Redirects are never followed (RFC 8461 §3.3). +`URLSessionMTASTSFetcher` is the alternative if you need `URLSession`'s +configuration (proxies, for example), but on Linux its size cap can't stop +corelibs Foundation from buffering an endless body. MTA-STS's SSRF-safety story mirrors `allowPrivateAddresses` above: the HTTPS policy-file fetch target is address-checked the same way when you diff --git a/Package.swift b/Package.swift index 5491826..d9d6a45 100644 --- a/Package.swift +++ b/Package.swift @@ -64,6 +64,7 @@ let package = Package( .product(name: "NIO", package: "swift-nio"), .product(name: "NIOConcurrencyHelpers", package: "swift-nio"), .product(name: "NIOCore", package: "swift-nio"), + .product(name: "NIOHTTP1", package: "swift-nio"), .product(name: "NIOPosix", package: "swift-nio"), .product(name: "NIOSSL", package: "swift-nio-ssl"), .product(name: "NIOTLS", package: "swift-nio"), diff --git a/Sources/PerfectSMTP/MTASTS/MTASTSHTTPFetching.swift b/Sources/PerfectSMTP/MTASTS/MTASTSHTTPFetching.swift index eb517d1..1a075c4 100644 --- a/Sources/PerfectSMTP/MTASTS/MTASTSHTTPFetching.swift +++ b/Sources/PerfectSMTP/MTASTS/MTASTSHTTPFetching.swift @@ -125,6 +125,13 @@ public enum MTASTSHTTPFetchError: Error, Sendable, Equatable { /// `RedirectRefusingFetchDelegate` as the *session* delegate driving a /// delegate-based data task; only the injected session's configuration is /// used there, not the session itself. +/// +/// **Not the default.** `MTASTSPolicyManager` uses `NIOMTASTSHTTPFetcher`. +/// This fetcher is kept for callers who want `URLSession`'s configuration +/// (proxies, for example). Its size cap holds on Darwin, but on Linux +/// corelibs Foundation keeps reading and queueing data after the task is +/// cancelled, so an endless body can still exhaust memory there (5.9 GB +/// RSS in the release review's linux/amd64 test). public struct URLSessionMTASTSFetcher: MTASTSHTTPFetching { private let session: URLSession private let timeout: TimeInterval diff --git a/Sources/PerfectSMTP/MTASTS/MTASTSPolicyManager.swift b/Sources/PerfectSMTP/MTASTS/MTASTSPolicyManager.swift index 948fb00..8713594 100644 --- a/Sources/PerfectSMTP/MTASTS/MTASTSPolicyManager.swift +++ b/Sources/PerfectSMTP/MTASTS/MTASTSPolicyManager.swift @@ -230,8 +230,8 @@ public actor MTASTSPolicyManager: MTASTSPolicyProviding { /// codebase for the same reason: a real MTA-STS policy file (a handful /// of `key: value` lines, at most a few dozen `mx:` patterns) is never /// remotely close to this size, so the cap is pure DoS hardening with - /// no legitimate-policy cost. `URLSessionMTASTSFetcher` enforces it - /// while streaming (and rejects a larger `Content-Length` up front), so + /// no legitimate-policy cost. The default `NIOMTASTSHTTPFetcher` + /// enforces it while streaming (and rejects a larger `Content-Length` up front), so /// an endless body is never buffered; `fetchAndParsePolicy(domain:)` /// checks it again for any other `MTASTSHTTPFetching` implementation. public static let maximumPolicyResponseSizeBytes = 64 * 1024 @@ -271,7 +271,7 @@ public actor MTASTSPolicyManager: MTASTSPolicyProviding { /// SSRF-class filtering of the MTA-STS HTTPS fetch target. public init( dnsResolver: any TXTResolving, - httpFetcher: any MTASTSHTTPFetching = URLSessionMTASTSFetcher(), + httpFetcher: any MTASTSHTTPFetching = NIOMTASTSHTTPFetcher(), addressResolver: (any MTASTSAddressResolving)? = nil, configuration: Configuration = .init() ) { diff --git a/Sources/PerfectSMTP/MTASTS/NIOMTASTSHTTPFetcher.swift b/Sources/PerfectSMTP/MTASTS/NIOMTASTSHTTPFetcher.swift new file mode 100644 index 0000000..ebaed5f --- /dev/null +++ b/Sources/PerfectSMTP/MTASTS/NIOMTASTSHTTPFetcher.swift @@ -0,0 +1,196 @@ +// +// NIOMTASTSHTTPFetcher.swift +// PerfectSMTP +// +// Release review: the MTA-STS policy fetch must not buffer an unbounded +// body. `URLSessionMTASTSFetcher` enforces the 64 KiB cap while +// streaming, which holds on Darwin, but on Linux corelibs Foundation keeps +// reading (and queueing) data after the task is cancelled: in the +// endless-body test on linux/amd64 the process reached 5.9 GB RSS and was +// OOM-killed in 6 of 15 runs. This fetcher speaks HTTP/1.1 itself over +// SwiftNIO (already a dependency; NIOHTTP1 is part of swift-nio), so it +// stops reading the moment the cap is hit, on every platform. It is +// `MTASTSPolicyManager`'s default fetcher. +// + +import Foundation +import NIOCore +import NIOHTTP1 +import NIOPosix +import NIOSSL + +/// Fetches MTA-STS policy files with a plain HTTP/1.1 GET over SwiftNIO. +/// +/// - TLS uses `tlsConfiguration` (default: full certificate verification +/// against the system trust store) with the URL's host as SNI and +/// expected name; an `http://` URL (tests, local tooling) is fetched in +/// the clear. +/// - Redirects are never followed (RFC 8461 §3.3): a 3xx is returned as +/// is, and `MTASTSPolicyManager` treats any non-200 as a failed fetch. +/// - `timeout` bounds the whole fetch, connect to last byte +/// (`MTASTSHTTPFetchError.timedOut`). +/// - At most `maximumBodySize` body bytes are read. A larger +/// `Content-Length` is refused before the body, and the connection is +/// closed as soon as the cap is passed +/// (`MTASTSHTTPFetchError.responseTooLarge`). +public struct NIOMTASTSHTTPFetcher: MTASTSHTTPFetching { + private let group: any EventLoopGroup + private let timeout: TimeInterval + private let maximumBodySize: Int + private let tlsConfiguration: TLSConfiguration + + public init( + group: any EventLoopGroup = MultiThreadedEventLoopGroup.singleton, + timeout: TimeInterval = 60, + maximumBodySize: Int = MTASTSPolicyManager.maximumPolicyResponseSizeBytes, + tlsConfiguration: TLSConfiguration = .makeClientConfiguration() + ) { + self.group = group + self.timeout = timeout + self.maximumBodySize = max(0, maximumBodySize) + self.tlsConfiguration = tlsConfiguration + } + + public enum FetchError: Error, Sendable, Equatable { + /// Only `http` and `https` URLs with a host are fetched. + case unsupportedURL(String) + /// The connection closed before a complete response arrived. + case incompleteResponse + } + + public func fetch(url: URL) async throws -> MTASTSHTTPResponse { + guard let scheme = url.scheme?.lowercased(), scheme == "https" || scheme == "http", + let host = url.host, !host.isEmpty + else { throw FetchError.unsupportedURL(url.absoluteString) } + let useTLS = scheme == "https" + let port = url.port ?? (useTLS ? 443 : 80) + let basePath = url.path.isEmpty ? "/" : url.path + let path = url.query.map { basePath + "?" + $0 } ?? basePath + + let limit = maximumBodySize + let sslContext = useTLS ? try NIOSSLContext(configuration: tlsConfiguration) : nil + let group = self.group + let connectTimeout = Self.timeAmount(timeout) + let promise = group.next().makePromise(of: MTASTSHTTPResponse.self) + + return try await Self.withDeadline(timeout, onTimeout: { promise.fail(MTASTSHTTPFetchError.timedOut) }) { + let bootstrap = ClientBootstrap(group: group) + .connectTimeout(connectTimeout) + .channelInitializer { channel in + channel.eventLoop.makeCompletedFuture { + if let sslContext { + let isIPLiteral = (try? SocketAddress(ipAddress: host, port: 0)) != nil + let tls = try NIOSSLClientHandler(context: sslContext, serverHostname: isIPLiteral ? nil : host) + try channel.pipeline.syncOperations.addHandler(tls) + } + try channel.pipeline.syncOperations.addHTTPClientHandlers() + try channel.pipeline.syncOperations.addHandler(CappedResponseCollector(limit: limit, promise: promise)) + } + } + let channel: any Channel + do { + channel = try await bootstrap.connect(host: host, port: port).get() + } catch { + promise.fail(error) + throw error + } + var headers = HTTPHeaders() + headers.add(name: "Host", value: url.port.map { "\(host):\($0)" } ?? host) + headers.add(name: "User-Agent", value: "PerfectSMTP-MTA-STS") + headers.add(name: "Accept", value: "text/plain") + headers.add(name: "Connection", value: "close") + let head = HTTPRequestHead(version: .http1_1, method: .GET, uri: path, headers: headers) + channel.write(HTTPClientRequestPart.head(head), promise: nil) + channel.writeAndFlush(HTTPClientRequestPart.end(nil), promise: nil) + do { + let response = try await promise.futureResult.get() + channel.close(promise: nil) + return response + } catch { + channel.close(promise: nil) + throw error + } + } + } + + private static func timeAmount(_ seconds: TimeInterval) -> TimeAmount { + .nanoseconds(Int64(min(max(0, seconds), 86_400) * 1_000_000_000)) + } + + private static func withDeadline( + _ seconds: TimeInterval, onTimeout: @escaping @Sendable () -> Void, _ operation: @escaping @Sendable () async throws -> T + ) async throws -> T { + try await withThrowingTaskGroup(of: T.self) { race in + race.addTask { try await operation() } + race.addTask { + try await Task.sleep(nanoseconds: UInt64(min(max(0, seconds), 86_400) * 1_000_000_000)) + onTimeout() + throw MTASTSHTTPFetchError.timedOut + } + defer { race.cancelAll() } + guard let result = try await race.next() else { throw MTASTSHTTPFetchError.timedOut } + return result + } + } +} + +/// Collects one HTTP response, failing (and closing) as soon as the body +/// would pass `limit`. Event-loop confined: all callbacks run on the +/// channel's loop, which is why `@unchecked Sendable` holds. +private final class CappedResponseCollector: ChannelInboundHandler, @unchecked Sendable { + typealias InboundIn = HTTPClientResponsePart + + private let limit: Int + private let promise: EventLoopPromise + private var status: Int? + private var contentType: String? + private var body: [UInt8] = [] + private var done = false + + init(limit: Int, promise: EventLoopPromise) { + self.limit = limit + self.promise = promise + } + + func channelRead(context: ChannelHandlerContext, data: NIOAny) { + guard !done else { return } + switch Self.unwrapInboundIn(data) { + case .head(let head): + // A 1xx interim response is followed by the real one. + guard head.status.code >= 200 else { return } + if let length = head.headers.first(name: "Content-Length").flatMap(Int.init), length > limit { + finish(context, .failure(MTASTSHTTPFetchError.responseTooLarge(limit: limit))) + return + } + status = Int(head.status.code) + contentType = head.headers.first(name: "Content-Type") + case .body(var buffer): + guard status != nil else { return } + if body.count + buffer.readableBytes > limit { + finish(context, .failure(MTASTSHTTPFetchError.responseTooLarge(limit: limit))) + return + } + body.append(contentsOf: buffer.readBytes(length: buffer.readableBytes) ?? []) + case .end: + guard let status else { return } + finish(context, .success(MTASTSHTTPResponse(statusCode: status, contentType: contentType, body: body))) + } + } + + func channelInactive(context: ChannelHandlerContext) { + finish(context, .failure(NIOMTASTSHTTPFetcher.FetchError.incompleteResponse)) + context.fireChannelInactive() + } + + func errorCaught(context: ChannelHandlerContext, error: any Error) { + finish(context, .failure(error)) + } + + private func finish(_ context: ChannelHandlerContext, _ result: Result) { + guard !done else { return } + done = true + body = [] + promise.completeWith(result) + context.close(promise: nil) + } +} diff --git a/Tests/PerfectSMTPTests/MTASTS/MTASTSHTTPFetcherRedirectTests.swift b/Tests/PerfectSMTPTests/MTASTS/MTASTSHTTPFetcherRedirectTests.swift index b2d143f..c233ed1 100644 --- a/Tests/PerfectSMTPTests/MTASTS/MTASTSHTTPFetcherRedirectTests.swift +++ b/Tests/PerfectSMTPTests/MTASTS/MTASTSHTTPFetcherRedirectTests.swift @@ -25,12 +25,13 @@ import Testing @testable import PerfectSMTP struct MTASTSHTTPFetcherRedirectTests { - @Test func aRedirectResponseIsTreatedAsAFetchFailureNotSilentlyFollowed() async throws { + @Test(arguments: FetcherKind.allCases) + func aRedirectResponseIsTreatedAsAFetchFailureNotSilentlyFollowed(kind: FetcherKind) async throws { let group = MultiThreadedEventLoopGroup(numberOfThreads: 1) let requestCount = RequestCounter() let server = try await RedirectingFakeHTTPServer.start(group: group, onRequest: { await requestCount.increment() }) - let fetcher = URLSessionMTASTSFetcher() + let fetcher = kind.make() let url = URL(string: "http://127.0.0.1:\(server.port)/.well-known/mta-sts.txt")! let response = try await fetcher.fetch(url: url) diff --git a/Tests/PerfectSMTPTests/MTASTS/MTASTSHTTPFetcherSizeLimitTests.swift b/Tests/PerfectSMTPTests/MTASTS/MTASTSHTTPFetcherSizeLimitTests.swift index d2ec51a..6d38e67 100644 --- a/Tests/PerfectSMTPTests/MTASTS/MTASTSHTTPFetcherSizeLimitTests.swift +++ b/Tests/PerfectSMTPTests/MTASTS/MTASTSHTTPFetcherSizeLimitTests.swift @@ -14,12 +14,36 @@ import Foundation import NIOConcurrencyHelpers import NIOCore import NIOPosix +import NIOSSL import Testing @testable import PerfectSMTP +/// The fetchers under test. On Linux, `URLSessionMTASTSFetcher` can't stop +/// corelibs Foundation from reading and queueing an endless body after +/// cancelling (5.9 GB RSS and an OOM kill on linux/amd64), so there only +/// the NIO fetcher, the default, is held to the cap. +enum FetcherKind: String, CaseIterable, Sendable { + case nio, urlSession + + static var cappedOnThisPlatform: [FetcherKind] { + #if canImport(FoundationNetworking) + [.nio] + #else + allCases + #endif + } + + func make(timeout: TimeInterval = 30) -> any MTASTSHTTPFetching { + switch self { + case .nio: NIOMTASTSHTTPFetcher(timeout: timeout) + case .urlSession: URLSessionMTASTSFetcher(timeout: timeout) + } + } +} + struct MTASTSHTTPFetcherSizeLimitTests { - @Test(.timeLimit(.minutes(1)), arguments: [false, true]) - func anEndlessBodyIsCutOffAtTheCapWithoutBufferingIt(declareHugeLength: Bool) async throws { + @Test(.timeLimit(.minutes(1)), arguments: FetcherKind.cappedOnThisPlatform, [false, true]) + func anEndlessBodyIsCutOffAtTheCapWithoutBufferingIt(kind: FetcherKind, declareHugeLength: Bool) async throws { let group = MultiThreadedEventLoopGroup(numberOfThreads: 1) let sent = NIOLockedValueBox(0) let closed = NIOLockedValueBox(false) @@ -34,7 +58,7 @@ struct MTASTSHTTPFetcherSizeLimitTests { .bind(host: "127.0.0.1", port: 0).get() let port = try #require(channel.localAddress?.port) - let fetcher = URLSessionMTASTSFetcher(timeout: 30) + let fetcher = kind.make() let url = try #require(URL(string: "http://127.0.0.1:\(port)/.well-known/mta-sts.txt")) let started = Date() await #expect(throws: MTASTSHTTPFetchError.responseTooLarge(limit: MTASTSPolicyManager.maximumPolicyResponseSizeBytes)) { @@ -53,14 +77,7 @@ struct MTASTSHTTPFetcherSizeLimitTests { try await channel.close() try await group.shutdownGracefully() #expect(closed.withLockedValue { $0 }, "the client never closed the connection; the server streamed \(total) bytes") - #if !canImport(FoundationNetworking) #expect(total < 256 * 1024 * 1024, "the server streamed \(total) bytes before the client stopped reading") - #endif - // On Linux, corelibs Foundation can keep draining the socket for a - // moment after the task is cancelled (once 2.4 GB over loopback in - // a loaded full-suite run). The bytes are dropped, not buffered: a - // standalone probe under CPU load kept max RSS at about 60 MB over - // ten endless-body fetches. So only the hang-up is asserted there. } @Test func aPolicyManagerTreatsAnOversizedFetchAsNoPolicy() async throws { @@ -125,3 +142,82 @@ private final class EndlessBodyHTTPHandler: ChannelInboundHandler, @unchecked Se context.close(promise: nil) } } + +/// The NIO fetcher end to end: a real policy over HTTPS (local test CA), +/// a chunked body, and an untrusted certificate. +struct NIOMTASTSHTTPFetcherTests { + private static let policy = "version: STSv1\r\nmode: enforce\r\nmx: mx.victim.test\r\nmax_age: 86400\r\n" + + private func serve(tls: Bool, chunked: Bool, group: any EventLoopGroup) async throws -> (Channel, Int) { + let context: NIOSSLContext? = tls ? try NIOSSLContext(configuration: .makeServerConfiguration( + certificateChain: [.certificate(try NIOSSLCertificate(bytes: Array(TestCertificates.namedCertificatePEM.utf8), format: .pem))], + privateKey: .privateKey(try NIOSSLPrivateKey(bytes: Array(TestCertificates.namedKeyPEM.utf8), format: .pem)) + )) : nil + let body = Self.policy + let bytes = Array(body.utf8) + let first = String(decoding: bytes[..<(bytes.count / 2)], as: UTF8.self) + let second = String(decoding: bytes[(bytes.count / 2)...], as: UTF8.self) + let response = chunked + ? "HTTP/1.1 200 OK\r\nContent-Type: text/plain; charset=utf-8\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n" + + "\(String(first.utf8.count, radix: 16))\r\n\(first)\r\n" + + "\(String(second.utf8.count, radix: 16))\r\n\(second)\r\n0\r\n\r\n" + : "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: \(body.utf8.count)\r\nConnection: close\r\n\r\n\(body)" + let channel = try await ServerBootstrap(group: group) + .childChannelInitializer { channel in + channel.eventLoop.makeCompletedFuture { + if let context { try channel.pipeline.syncOperations.addHandler(NIOSSLServerHandler(context: context)) } + try channel.pipeline.syncOperations.addHandler(FixedHTTPResponder(response: response)) + } + } + .bind(host: "127.0.0.1", port: 0).get() + return (channel, try #require(channel.localAddress?.port)) + } + + private func trustingTestCA() throws -> TLSConfiguration { + var configuration = TLSConfiguration.makeClientConfiguration() + configuration.trustRoots = .certificates([try NIOSSLCertificate(bytes: Array(TestCertificates.caPEM.utf8), format: .pem)]) + return configuration + } + + @Test(.timeLimit(.minutes(1)), arguments: [(false, false), (false, true), (true, false), (true, true)]) + func aPolicyIsFetched(tls: Bool, chunked: Bool) async throws { + let group = MultiThreadedEventLoopGroup(numberOfThreads: 1) + let (channel, port) = try await serve(tls: tls, chunked: chunked, group: group) + let fetcher = NIOMTASTSHTTPFetcher(group: group, timeout: 10, tlsConfiguration: try trustingTestCA()) + let url = try #require(URL(string: "\(tls ? "https" : "http")://127.0.0.1:\(port)/.well-known/mta-sts.txt")) + let response = try await fetcher.fetch(url: url) + try await channel.close() + try await group.shutdownGracefully() + #expect(response.statusCode == 200) + #expect(response.contentType?.hasPrefix("text/plain") == true) + #expect(String(decoding: response.body, as: UTF8.self) == Self.policy) + } + + @Test(.timeLimit(.minutes(1))) + func anUntrustedCertificateFailsTheFetch() async throws { + let group = MultiThreadedEventLoopGroup(numberOfThreads: 1) + let (channel, port) = try await serve(tls: true, chunked: false, group: group) + let fetcher = NIOMTASTSHTTPFetcher(group: group, timeout: 10) // system trust store + let url = try #require(URL(string: "https://127.0.0.1:\(port)/.well-known/mta-sts.txt")) + await #expect(throws: (any Error).self) { _ = try await fetcher.fetch(url: url) } + try await channel.close() + try await group.shutdownGracefully() + } +} + +private final class FixedHTTPResponder: ChannelInboundHandler, @unchecked Sendable { + typealias InboundIn = ByteBuffer + typealias OutboundOut = ByteBuffer + private let response: String + private var answered = false + init(response: String) { self.response = response } + + func channelRead(context: ChannelHandlerContext, data: NIOAny) { + guard !answered else { return } + answered = true + let channel = context.channel + context.writeAndFlush(Self.wrapOutboundOut(ByteBuffer(string: response))).whenComplete { _ in channel.close(promise: nil) } + } + + func errorCaught(context: ChannelHandlerContext, error: any Error) { context.close(promise: nil) } +} diff --git a/Tests/PerfectSMTPTests/MTASTS/MTASTSHTTPFetcherTimeoutTests.swift b/Tests/PerfectSMTPTests/MTASTS/MTASTSHTTPFetcherTimeoutTests.swift index a8d8a75..a7ec125 100644 --- a/Tests/PerfectSMTPTests/MTASTS/MTASTSHTTPFetcherTimeoutTests.swift +++ b/Tests/PerfectSMTPTests/MTASTS/MTASTSHTTPFetcherTimeoutTests.swift @@ -16,8 +16,8 @@ import Testing @testable import PerfectSMTP struct MTASTSHTTPFetcherTimeoutTests { - @Test(.timeLimit(.minutes(1))) - func aTricklingPolicyHostIsCutOffAtTheFetchersDeadline() async throws { + @Test(.timeLimit(.minutes(1)), arguments: FetcherKind.allCases) + func aTricklingPolicyHostIsCutOffAtTheFetchersDeadline(kind: FetcherKind) async throws { let group = MultiThreadedEventLoopGroup(numberOfThreads: 1) let channel = try await ServerBootstrap(group: group) .childChannelInitializer { channel in @@ -28,7 +28,7 @@ struct MTASTSHTTPFetcherTimeoutTests { .bind(host: "127.0.0.1", port: 0).get() let port = try #require(channel.localAddress?.port) - let fetcher = URLSessionMTASTSFetcher(timeout: 1) + let fetcher = kind.make(timeout: 1) let url = try #require(URL(string: "http://127.0.0.1:\(port)/.well-known/mta-sts.txt")) let started = Date() await #expect(throws: MTASTSHTTPFetchError.timedOut) { From 90a6288f41dbf1bd383002cbd5e74b1537ca9009 Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Fri, 23 Oct 2026 13:11:40 -0500 Subject: [PATCH 28/39] Pool eviction test: wait for the first held close Evicted idle connections now send QUIT before closing, so the close arrives slightly later. evictionIsCappedByTheWaitingHostsPerHostRoom checked the held-close count 20 ms after the waiters parked, and failed under TSan on Linux (2 of 2) and once on emulated amd64 debug. It now waits for the first close to be held, then gives any wrong extra evictions 50 ms to appear before asserting exactly one. Co-Authored-By: Claude Opus 5.5 --- Tests/PerfectSMTPTests/SMTPConnectionPoolTests.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Tests/PerfectSMTPTests/SMTPConnectionPoolTests.swift b/Tests/PerfectSMTPTests/SMTPConnectionPoolTests.swift index f315422..368c2f8 100644 --- a/Tests/PerfectSMTPTests/SMTPConnectionPoolTests.swift +++ b/Tests/PerfectSMTPTests/SMTPConnectionPoolTests.swift @@ -555,7 +555,11 @@ struct SMTPConnectionPoolTests { try await waitForPool(pool) { $0.active == 1 } let checkouts = (0..<3).map { _ in Task { try await pool.withConnection(to: key) { _ in () } } } try await waitForPool(pool) { $0.waiters == 3 } - try await Task.sleep(nanoseconds: 20_000_000) + // An evicted idle connection gets a best-effort QUIT before its + // close, so the close arrives a moment later: wait for the first + // one, then give any (wrong) extra evictions the same time. + try await waitUntilHeld(holder, count: 1) + try await Task.sleep(nanoseconds: 50_000_000) #expect(holder.heldCount == 1, "\(holder.heldCount) idle connections evicted; only 1 checkout for host A can be admitted") #expect(pool.snapshotForTesting().idle == 2) From ccd9fcaff7d9bacda3d7639a60ea8ebcbaaf5d23 Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Fri, 23 Oct 2026 14:09:59 -0500 Subject: [PATCH 29/39] Check DNS subjectAltNames with public API and swift-asn1, after the handshake The MTA-STS DNS-name check used NIOSSL's underscored _makeSSLClientHandler and _subjectAlternativeNames. It now runs once the handshake completes: - NIOSSL's public peerCertificate (first in nio-ssl 2.31.0; floor raised, 2.30.0 fails to build) gives the verified leaf; - toDERBytes() gives its DER; - a small parser on swift-asn1 reads the subjectAltName extension (2.5.29.17) dNSNames. swift-asn1 was already in the graph through swift-crypto 4 and is now declared directly at its 1.2.0 floor, so no new package. Matching reuses MXPatternMatcher: case-insensitive, with a wildcard covering exactly one label. The parser is tested against the test CA's certificates, hand-built DER, malformed DER and extensions, and 2000 random mutations. The full suite passes with every floor pinned exactly (nio 2.86.0, nio-ssl 2.31.0, asn1 1.2.0, log 1.5.4, crypto 4.0.0). isCertificateFailure matches CertificateNameError and NIOSSLExtraError by type. Only chain failures still need BoringSSL's reason text, because NIOSSL keeps the error codes private (documented). Co-Authored-By: Claude Opus 5.5 --- Package.swift | 8 +- Sources/PerfectSMTP/CertificateDNSNames.swift | 111 ++++++++++++++++ .../PerfectSMTP/SMTPBootstrapHandler.swift | 45 ++++--- .../CertificateDNSNamesTests.swift | 122 ++++++++++++++++++ 4 files changed, 262 insertions(+), 24 deletions(-) create mode 100644 Sources/PerfectSMTP/CertificateDNSNames.swift create mode 100644 Tests/PerfectSMTPTests/CertificateDNSNamesTests.swift diff --git a/Package.swift b/Package.swift index d9d6a45..4b3d767 100644 --- a/Package.swift +++ b/Package.swift @@ -15,7 +15,12 @@ let package = Package( // .removeHandler(context:)`); the tests need 2.86.0, the first // release whose `EmbeddedChannel` accepts the `autoRead` option. .package(url: "https://github.com/apple/swift-nio.git", from: "2.86.0"), - .package(url: "https://github.com/apple/swift-nio-ssl.git", from: "2.27.0"), + // 2.31.0: the first release with the public `peerCertificate`, + // which the DNS-subjectAltName check reads after the handshake. + .package(url: "https://github.com/apple/swift-nio-ssl.git", from: "2.31.0"), + // Already in the graph through swift-crypto 4 (which requires + // 1.2.0); declared for the subjectAltName parser. + .package(url: "https://github.com/apple/swift-asn1.git", from: "1.2.0"), // 1.5.4: 1.5.0-1.5.3 don't compile on Linux with Swift 6.2+ // (`localtime` returns an optional there). .package(url: "https://github.com/apple/swift-log.git", from: "1.5.4"), @@ -67,6 +72,7 @@ let package = Package( .product(name: "NIOHTTP1", package: "swift-nio"), .product(name: "NIOPosix", package: "swift-nio"), .product(name: "NIOSSL", package: "swift-nio-ssl"), + .product(name: "SwiftASN1", package: "swift-asn1"), .product(name: "NIOTLS", package: "swift-nio"), .product(name: "Logging", package: "swift-log"), ], diff --git a/Sources/PerfectSMTP/CertificateDNSNames.swift b/Sources/PerfectSMTP/CertificateDNSNames.swift new file mode 100644 index 0000000..9acb76f --- /dev/null +++ b/Sources/PerfectSMTP/CertificateDNSNames.swift @@ -0,0 +1,111 @@ +// +// CertificateDNSNames.swift +// PerfectSMTP +// +// The DNS-name check behind `requireCertificateDNSName`. NIOSSL's own +// hostname check also accepts an iPAddress subjectAltName equal to the +// dialed address, so a certificate issued for an IP can stand in for a +// host name. Where a name is required (relay TLS, MTA-STS delivery, the +// MTA-STS policy fetch), the peer certificate must carry a dNSName that +// matches the host. This runs after the handshake, on the certificate +// NIOSSL has already verified, through public API only: NIOSSL's +// `peerCertificate` and `toDERBytes()`, then a small subjectAltName parser +// on swift-asn1 (already in the graph through swift-crypto). +// + +import NIOCore +import NIOSSL +import SwiftASN1 + +/// The certificate verified as a chain but didn't name the host as +/// required. +public enum CertificateNameError: Error, Sendable, Equatable { + /// No DNS subjectAltName matches `host`. + case noDNSName(host: String) + /// The handshake completed without a peer certificate to check. + case noPeerCertificate + /// The certificate's DER couldn't be parsed far enough to read its + /// subjectAltName extension. + case malformedCertificate +} + +enum CertificateDNSNames { + /// The subjectAltName extension, RFC 5280 §4.2.1.6. + private static let subjectAltNameOID: [UInt] = [2, 5, 29, 17] + + /// Every dNSName in the certificate's subjectAltName extension(s), as + /// written. Throws `CertificateNameError.malformedCertificate` if the + /// DER doesn't have the X.509 shape up to and including the + /// extensions. A certificate without a subjectAltName returns `[]`. + static func dnsNames(fromDER der: [UInt8]) throws -> [String] { + do { + let certificate = try children(of: DER.parse(der), universal: 0x10) + guard let tbsNode = certificate.first else { throw CertificateNameError.malformedCertificate } + let tbs = try children(of: tbsNode, universal: 0x10) + // extensions [3] EXPLICIT Extensions (RFC 5280 §4.1). + guard let wrapper = tbs.first(where: { $0.identifier.tagClass == .contextSpecific && $0.identifier.tagNumber == 3 }) else { + return [] + } + let wrapped = try children(of: wrapper) + guard wrapped.count == 1 else { throw CertificateNameError.malformedCertificate } + var names: [String] = [] + for extensionNode in try children(of: wrapped[0], universal: 0x10) { + // Extension ::= SEQUENCE { extnID, critical BOOLEAN DEFAULT FALSE, extnValue OCTET STRING } + let parts = try children(of: extensionNode, universal: 0x10) + guard (2...3).contains(parts.count) else { throw CertificateNameError.malformedCertificate } + let oid = try ASN1ObjectIdentifier(derEncoded: parts[0]) + guard oid.oidComponents == subjectAltNameOID else { continue } + let value = try primitive(parts[parts.count - 1], universal: 0x04) + // GeneralNames ::= SEQUENCE OF GeneralName; dNSName [2] IMPLICIT IA5String. + for general in try children(of: DER.parse(value), universal: 0x10) + where general.identifier.tagClass == .contextSpecific && general.identifier.tagNumber == 2 { + guard case .primitive(let bytes) = general.content else { throw CertificateNameError.malformedCertificate } + // IA5String: ASCII only. Anything else can't name a host. + guard !bytes.isEmpty, bytes.allSatisfy({ (0x21...0x7E).contains($0) }) else { continue } + names.append(String(decoding: bytes, as: UTF8.self)) + } + } + return names + } catch let error as CertificateNameError { + throw error + } catch { + throw CertificateNameError.malformedCertificate + } + } + + /// Throws `CertificateNameError.noDNSName` unless one of the + /// certificate's dNSNames matches `host`: equal ignoring ASCII case, or + /// a `*.` wildcard covering exactly one leading label (the same rule as + /// MTA-STS `mx:` patterns, `MXPatternMatcher`). + static func requireMatch(certificateDER der: [UInt8], host: String) throws { + let names = try dnsNames(fromDER: der) + guard names.contains(where: { MXPatternMatcher.matches(pattern: $0, host: host) }) else { + throw CertificateNameError.noDNSName(host: host) + } + } + + /// Runs `requireMatch` on the certificate the TLS handler in + /// `pipeline` received. Call once the handshake has completed. + static func requireMatch(in pipeline: ChannelPipeline.SynchronousOperations, host: String) throws { + let handler = try pipeline.handler(type: NIOSSLClientHandler.self) + guard let certificate = handler.peerCertificate else { throw CertificateNameError.noPeerCertificate } + try requireMatch(certificateDER: certificate.toDERBytes(), host: host) + } + + private static func children(of node: ASN1Node, universal tag: UInt? = nil) throws -> [ASN1Node] { + if let tag { + guard node.identifier.tagClass == .universal, node.identifier.tagNumber == tag else { + throw CertificateNameError.malformedCertificate + } + } + guard case .constructed(let collection) = node.content else { throw CertificateNameError.malformedCertificate } + return Array(collection) + } + + private static func primitive(_ node: ASN1Node, universal tag: UInt) throws -> [UInt8] { + guard node.identifier.tagClass == .universal, node.identifier.tagNumber == tag, + case .primitive(let bytes) = node.content + else { throw CertificateNameError.malformedCertificate } + return Array(bytes) + } +} diff --git a/Sources/PerfectSMTP/SMTPBootstrapHandler.swift b/Sources/PerfectSMTP/SMTPBootstrapHandler.swift index 0ce052f..69499d6 100644 --- a/Sources/PerfectSMTP/SMTPBootstrapHandler.swift +++ b/Sources/PerfectSMTP/SMTPBootstrapHandler.swift @@ -75,12 +75,14 @@ public enum SMTPBootstrap { replyTimeout: TimeInterval = 300, tlsConfiguration: TLSConfiguration = .makeClientConfiguration(), ehloHostname: String = "localhost", + requireCertificateDNSName: Bool = false, group: any EventLoopGroup ) async throws -> NIOAsyncChannel { try await connect( target: .hostPort(host: host, port: port), sniHostname: host, tls: tls, connectTimeout: connectTimeout, replyTimeout: replyTimeout, - tlsConfiguration: tlsConfiguration, ehloHostname: ehloHostname, requireCertificateDNSName: false, group: group + tlsConfiguration: tlsConfiguration, ehloHostname: ehloHostname, + requireCertificateDNSName: requireCertificateDNSName, group: group ) } @@ -259,32 +261,26 @@ public enum SMTPBootstrap { /// passed. Any other name NIOSSL refuses now fails the connection. /// /// With `requireDNSName`, the certificate must additionally have a DNS - /// subjectAltName matching `host` (wildcards as in RFC 8461 §4.2's - /// `mx` matching); an IP literal can't satisfy that. + /// subjectAltName matching `host`; that is checked once the handshake + /// completes (`CertificateDNSNames`), since NIOSSL's own check also + /// accepts an IP SAN equal to the dialed address. An IP literal can't + /// satisfy it at all. static func makeSSLHandler(host: String, configuration: TLSConfiguration, requireDNSName: Bool = false) throws -> NIOSSLClientHandler { let context = try NIOSSLContext(configuration: configuration) if isIPAddressLiteral(host) { guard !requireDNSName else { throw SMTPError.tlsCertificateInvalid(underlying: CertificateNameError.noDNSName(host: host)) } return try NIOSSLClientHandler(context: context, serverHostname: nil) } - guard requireDNSName else { - return try NIOSSLClientHandler(context: context, serverHostname: host) - } - return try NIOSSLClientHandler._makeSSLClientHandler(context: context, serverHostname: host) { certificate, channel in - let names = certificate._subjectAlternativeNames().compactMap { name -> String? in - guard name.nameType == .dnsName else { return nil } - return String(decoding: name.contents, as: UTF8.self) - } - guard names.contains(where: { MXPatternMatcher.matches(pattern: $0, host: host) }) else { - return channel.eventLoop.makeFailedFuture(CertificateNameError.noDNSName(host: host)) - } - return channel.eventLoop.makeSucceededVoidFuture() - } + return try NIOSSLClientHandler(context: context, serverHostname: host) } /// Whether `error`, seen while TLS is being set up, means the peer's /// certificate failed verification (as opposed to a malformed TLS /// stream, which during STARTTLS is how injected plaintext shows up). + /// Name failures are matched by type. A chain that doesn't verify only + /// shows up as `NIOSSLError.handshakeFailed` wrapping BoringSSL's + /// queue, whose error codes NIOSSL keeps private, so that one case is + /// recognized by BoringSSL's `CERTIFICATE_VERIFY_FAILED` reason text. static func isCertificateFailure(_ error: any Error) -> Bool { if error is CertificateNameError { return true } if let extra = error as? NIOSSLExtraError, extra == .failedToValidateHostname { return true } @@ -295,13 +291,6 @@ public enum SMTPBootstrap { } } -/// The certificate verified as a chain but didn't name the host as -/// required. -public enum CertificateNameError: Error, Sendable, Equatable { - /// No DNS subjectAltName matches `host`. - case noDNSName(host: String) -} - /// Phase A's explicit state machine. `@unchecked Sendable`: constructed on, /// and every callback dispatched on, its channel's single event loop — /// never touched concurrently, matching this codebase's other @@ -429,6 +418,16 @@ final class SMTPBootstrapHandler: ChannelDuplexHandler, RemovableChannelHandler, func userInboundEventTriggered(context: ChannelHandlerContext, event: Any) { if let tlsEvent = event as? TLSUserEvent, case .handshakeCompleted = tlsEvent { + // The certificate has verified as a chain; where a DNS name is + // required, check it before any SMTP reply is processed. + if requireCertificateDNSName, state == .upgrading || tls == .implicit { + do { + try CertificateDNSNames.requireMatch(in: context.pipeline.syncOperations, host: host) + } catch { + fail(context: context, .tlsCertificateInvalid(underlying: error)) + return + } + } guard state == .upgrading else { // A handshake-completed event outside the upgrade window // (e.g. implicit TLS, where we never entered `.upgrading` diff --git a/Tests/PerfectSMTPTests/CertificateDNSNamesTests.swift b/Tests/PerfectSMTPTests/CertificateDNSNamesTests.swift new file mode 100644 index 0000000..039ce21 --- /dev/null +++ b/Tests/PerfectSMTPTests/CertificateDNSNamesTests.swift @@ -0,0 +1,122 @@ +// +// CertificateDNSNamesTests.swift +// PerfectSMTPTests +// +// The subjectAltName dNSName parser behind `requireCertificateDNSName`: +// the test CA's real certificates, hand-built DER, and malformed input. +// + +import NIOSSL +import Testing +@testable import PerfectSMTP + +struct CertificateDNSNamesTests { + private static func der(_ pem: String) throws -> [UInt8] { + try NIOSSLCertificate(bytes: Array(pem.utf8), format: .pem).toDERBytes() + } + + // Minimal DER builders (definite lengths, up to 65535 bytes). + private static func tlv(_ tag: UInt8, _ body: [UInt8]) -> [UInt8] { + let n = body.count + let length: [UInt8] = n < 0x80 ? [UInt8(n)] : n < 0x100 ? [0x81, UInt8(n)] : [0x82, UInt8(n >> 8), UInt8(n & 0xFF)] + return [tag] + length + body + } + private static func seq(_ items: [UInt8]...) -> [UInt8] { tlv(0x30, items.flatMap { $0 }) } + private static let sanOID: [UInt8] = [0x06, 0x03, 0x55, 0x1D, 0x11] + private static let keyUsageOID: [UInt8] = [0x06, 0x03, 0x55, 0x1D, 0x0F] + + /// Certificate { TBS { [0] version, serial, [3] { Extensions } } } with + /// just enough structure for the parser. + private static func certificate(extensions: [[UInt8]]) -> [UInt8] { + let tbs = seq(tlv(0xA0, tlv(0x02, [2])), tlv(0x02, [1]), tlv(0xA3, seq(extensions.flatMap { $0 }))) + return seq(tbs, seq(tlv(0x06, [0x2A])), tlv(0x03, [0])) + } + private static func sanExtension(_ generalNames: [[UInt8]]) -> [UInt8] { + seq(sanOID, tlv(0x04, seq(generalNames.flatMap { $0 }))) + } + private static func dnsName(_ name: String) -> [UInt8] { tlv(0x82, Array(name.utf8)) } + private static func ipAddress(_ bytes: [UInt8]) -> [UInt8] { tlv(0x87, bytes) } + + @Test func theTestCertificatesParse() throws { + #expect(try CertificateDNSNames.dnsNames(fromDER: Self.der(TestCertificates.namedCertificatePEM)) == ["mx.victim.test"]) + #expect(try CertificateDNSNames.dnsNames(fromDER: Self.der(TestCertificates.dnsOnlyCertificatePEM)) == ["mx.victim.test"]) + #expect(try CertificateDNSNames.dnsNames(fromDER: Self.der(TestCertificates.ipOnlyCertificatePEM)).isEmpty) + #expect(try CertificateDNSNames.dnsNames(fromDER: Self.der(TestCertificates.caPEM)).isEmpty) + } + + @Test func handBuiltNamesAndMatching() throws { + let der = Self.certificate(extensions: [ + Self.seq(Self.keyUsageOID, Self.tlv(0x01, [0xFF]), Self.tlv(0x04, Self.tlv(0x03, [0x05, 0xA0]))), + Self.sanExtension([Self.ipAddress([127, 0, 0, 1]), Self.dnsName("*.Victim.Test"), Self.dnsName("bücher.test"), Self.dnsName("mail.example.test")]), + ]) + #expect(try CertificateDNSNames.dnsNames(fromDER: der) == ["*.Victim.Test", "mail.example.test"]) + #expect(throws: Never.self) { try CertificateDNSNames.requireMatch(certificateDER: der, host: "mx.victim.test") } + #expect(throws: Never.self) { try CertificateDNSNames.requireMatch(certificateDER: der, host: "MAIL.example.TEST") } + // A wildcard covers exactly one label. + #expect(throws: CertificateNameError.noDNSName(host: "a.mx.victim.test")) { + try CertificateDNSNames.requireMatch(certificateDER: der, host: "a.mx.victim.test") + } + #expect(throws: CertificateNameError.noDNSName(host: "victim.test")) { + try CertificateDNSNames.requireMatch(certificateDER: der, host: "victim.test") + } + // An IP SAN never satisfies a name. + let ipOnly = Self.certificate(extensions: [Self.sanExtension([Self.ipAddress([127, 0, 0, 1])])]) + #expect(throws: CertificateNameError.noDNSName(host: "127.0.0.1")) { + try CertificateDNSNames.requireMatch(certificateDER: ipOnly, host: "127.0.0.1") + } + } + + @Test func noExtensionsMeansNoNames() throws { + let tbs = Self.seq(Self.tlv(0x02, [1])) + #expect(try CertificateDNSNames.dnsNames(fromDER: Self.seq(tbs)).isEmpty) + } + + @Test(arguments: [ + [], [0x30], [0x30, 0x05, 0x30], [0x02, 0x01, 0x01], [0x30, 0x03, 0x02, 0x01, 0x01], + [0x30, 0x84, 0xFF, 0xFF, 0xFF, 0xFF], Array(repeating: 0x30, count: 64), + ] as [[UInt8]]) + func malformedDERIsRejected(bytes: [UInt8]) { + #expect(throws: CertificateNameError.malformedCertificate) { _ = try CertificateDNSNames.dnsNames(fromDER: bytes) } + } + + @Test func malformedExtensionsAreRejected() { + let cases: [[UInt8]] = [ + // extnValue isn't DER. + Self.certificate(extensions: [Self.seq(Self.sanOID, Self.tlv(0x04, [0xFF, 0xFF, 0x00]))]), + // extnValue isn't an OCTET STRING. + Self.certificate(extensions: [Self.seq(Self.sanOID, Self.tlv(0x0C, Self.seq()))]), + // GeneralNames isn't a SEQUENCE. + Self.certificate(extensions: [Self.seq(Self.sanOID, Self.tlv(0x04, Self.tlv(0x31, [])))]), + // An extension with only an OID. + Self.certificate(extensions: [Self.seq(Self.sanOID)]), + // A constructed dNSName. + Self.certificate(extensions: [Self.sanExtension([Self.tlv(0xA2, Self.dnsName("x"))])]), + // [3] holding two things. + Self.seq(Self.seq(Self.tlv(0xA3, Self.seq() + Self.seq())), Self.seq(), Self.tlv(0x03, [0])), + ] + for der in cases { + #expect(throws: CertificateNameError.malformedCertificate) { _ = try CertificateDNSNames.dnsNames(fromDER: der) } + } + } + + /// Random mutations of a real certificate never crash; they either + /// parse or throw `.malformedCertificate`. + @Test func mutatedCertificatesNeverCrash() throws { + let base = try Self.der(TestCertificates.namedCertificatePEM) + var generator = SystemRandomNumberGenerator() + for _ in 0..<2000 { + var bytes = base + for _ in 0.. Date: Fri, 23 Oct 2026 14:09:59 -0500 Subject: [PATCH 30/39] Require a DNS SAN for relay TLS and the MTA-STS fetch; vet fetch addresses - RelayTransport: the pool's default dialer now requires a DNS subjectAltName for every .startTLS or .implicit connection to a host name. NIOSSL alone also accepts an IP SAN equal to the dialed address, so someone who could redirect the relay's DNS to an address they hold an IP certificate for could collect AUTH credentials. Tested by name ('localhost'): the IP-only test certificate connects without the check and fails with it. IP-literal relays still use the IP SAN. - NIOMTASTSHTTPFetcher (a regression from the URLSession default on Linux) accepted an IP-only certificate for the policy host. A host name now needs a matching DNS SAN; IP-only and wrong-name certificates are refused. - MTA-STS SSRF: the fetcher resolves the host once through the library's DNSResolver (or an injected MTASTSAddressResolving), filters every address with DNSAddress.isRoutable, and dials a vetted address with SNI and Host set to the name. This is on by default: allowPrivateAddresses is the opt-out, used by the local tests. Nothing permitted throws noPermittedAddress, which maps to addressFilteredAsPrivate. The stale URLSession comments in the policy manager are gone. - The Host header brackets IPv6 literals. Co-Authored-By: Claude Opus 5.5 --- .../MTASTS/MTASTSHTTPFetching.swift | 3 + .../MTASTS/MTASTSPolicyManager.swift | 54 ++++----- .../MTASTS/NIOMTASTSHTTPFetcher.swift | 110 +++++++++++++++--- Sources/PerfectSMTP/SMTPConnectionPool.swift | 17 ++- .../MTASTSHTTPFetcherSizeLimitTests.swift | 84 ++++++++++++- .../STARTTLSCertificateTests.swift | 47 ++++++++ 6 files changed, 263 insertions(+), 52 deletions(-) diff --git a/Sources/PerfectSMTP/MTASTS/MTASTSHTTPFetching.swift b/Sources/PerfectSMTP/MTASTS/MTASTSHTTPFetching.swift index 1a075c4..1989603 100644 --- a/Sources/PerfectSMTP/MTASTS/MTASTSHTTPFetching.swift +++ b/Sources/PerfectSMTP/MTASTS/MTASTSHTTPFetching.swift @@ -78,6 +78,9 @@ public enum MTASTSHTTPFetchError: Error, Sendable, Equatable { /// `maximumBodySize` bytes of body. The fetch stops reading at that /// point instead of buffering the rest. case responseTooLarge(limit: Int) + /// Every address `host` resolved to is private or otherwise not + /// globally routable (see `DNSAddress.isRoutable`); nothing was dialed. + case noPermittedAddress(host: String) } /// The production `MTASTSHTTPFetching` implementation: a plain `URLSession` diff --git a/Sources/PerfectSMTP/MTASTS/MTASTSPolicyManager.swift b/Sources/PerfectSMTP/MTASTS/MTASTSPolicyManager.swift index 8713594..7c44efa 100644 --- a/Sources/PerfectSMTP/MTASTS/MTASTSPolicyManager.swift +++ b/Sources/PerfectSMTP/MTASTS/MTASTSPolicyManager.swift @@ -200,8 +200,9 @@ public actor MTASTSPolicyManager: MTASTSPolicyProviding { /// `id` hasn't changed before continuing to trust the cached /// policy body. public var idRecheckInterval: TimeInterval - /// FIX D (protocol review, option (a)): mirrors `DirectMXConfig - /// .allowPrivateAddresses` for the MTA-STS HTTPS fetch target -- + /// Governs the optional `addressResolver` pre-check only; the + /// default `NIOMTASTSHTTPFetcher` has its own flag. Mirrors + /// `DirectMXConfig.allowPrivateAddresses` for the MTA-STS fetch -- /// `false` (filtering on) by default, since the same "attacker /// controls the recipient domain" threat model /// `DirectMXConfig`'s own doc comment describes applies equally to @@ -263,12 +264,15 @@ public actor MTASTSPolicyManager: MTASTSPolicyProviding { /// always uses `Date.init` (the real wall clock). private let now: @Sendable () -> Date - /// - Parameter addressResolver: FIX D (protocol review, option (a)) -- - /// see the stored property's own doc comment. `nil` by default - /// (purely additive opt-in, matching `DirectMXTransport - /// .mtaSTSPolicyProvider`'s identical shape) -- pass a `DNSResolver` - /// (or any other `MTASTSAddressResolving` conformance) to enable - /// SSRF-class filtering of the MTA-STS HTTPS fetch target. + /// - Parameter httpFetcher: Defaults to `NIOMTASTSHTTPFetcher()`, which + /// resolves the policy host itself, refuses non-routable addresses, + /// dials only a vetted address and caps the body. To allow private + /// addresses with it, pass `NIOMTASTSHTTPFetcher(allowPrivateAddresses: + /// true)`; `Configuration.allowPrivateAddresses` only governs the + /// pre-check below. + /// - Parameter addressResolver: An extra pre-check for custom fetchers + /// that can't vet addresses themselves (see the stored property's doc + /// comment). `nil` by default; the default fetcher doesn't need it. public init( dnsResolver: any TXTResolving, httpFetcher: any MTASTSHTTPFetching = NIOMTASTSHTTPFetcher(), @@ -512,20 +516,14 @@ public actor MTASTSPolicyManager: MTASTSPolicyProviding { throw MTASTSDiscoveryError.fetchFailed } - // FIX D (protocol review, option (a)): pre-check the fetch target's - // resolved addresses against `DNSAddress.isRoutable` before ever - // attempting the HTTPS fetch -- mirrors `DirectMXTransport - // .makeDialer`'s identical treatment of the direct-MX dial path. - // A no-op (as before this fix) when `addressResolver` is `nil` -- - // see that stored property's doc comment for why this is an - // opt-in dependency rather than a forced one. This is a refusal- - // to-attempt gate, not a pinned-address dial: it doesn't force - // `URLSession` to connect to a specific pre-resolved address (not - // practical to do cleanly with `URLSession`'s own connection - // establishment) -- it just declines to even try the fetch when - // every address this hostname resolves to is private/reserved, - // which closes most of the practical SSRF risk without fighting - // `URLSession` internals. + // Address vetting. The default `NIOMTASTSHTTPFetcher` resolves the + // policy host once, filters the addresses with + // `DNSAddress.isRoutable` and dials a vetted one itself, so the + // address checked is the address used. This optional pre-check is + // for a custom `httpFetcher` that can't do that: with an + // `addressResolver`, the fetch is refused when every address the + // host resolves to is private or reserved (it can't stop that + // fetcher from resolving differently afterwards). if let addressResolver, !configuration.allowPrivateAddresses { let resolvedAddresses: [DNSAddress] do { @@ -543,6 +541,8 @@ public actor MTASTSPolicyManager: MTASTSPolicyProviding { response = try await httpFetcher.fetch(url: url) } catch MTASTSHTTPFetchError.responseTooLarge { throw MTASTSDiscoveryError.responseTooLarge + } catch MTASTSHTTPFetchError.noPermittedAddress { + throw MTASTSDiscoveryError.addressFilteredAsPrivate } catch { throw MTASTSDiscoveryError.fetchFailed } @@ -550,13 +550,9 @@ public actor MTASTSPolicyManager: MTASTSPolicyProviding { guard let contentType = response.contentType, contentType.lowercased().hasPrefix("text/plain") else { throw MTASTSDiscoveryError.invalidContentType } - // FIX C (protocol review): RFC 8461 §3.3 SHOULD, 64KB -- see - // `maximumPolicyResponseSizeBytes`'s doc comment. Checked after the - // full body is already buffered by `httpFetcher.fetch(url:)` (see - // that property's doc comment for why this codebase doesn't try to - // abort `URLSession` mid-stream), but before the body is ever - // decoded/parsed/cached/acted upon, which is the protection that - // actually matters. + // RFC 8461 §3.3 SHOULD, 64 KiB (see `maximumPolicyResponseSizeBytes`). + // The default fetcher stops reading at the cap; this re-check covers + // custom fetchers before anything is parsed or cached. guard response.body.count <= Self.maximumPolicyResponseSizeBytes else { throw MTASTSDiscoveryError.responseTooLarge } diff --git a/Sources/PerfectSMTP/MTASTS/NIOMTASTSHTTPFetcher.swift b/Sources/PerfectSMTP/MTASTS/NIOMTASTSHTTPFetcher.swift index ebaed5f..6be65bf 100644 --- a/Sources/PerfectSMTP/MTASTS/NIOMTASTSHTTPFetcher.swift +++ b/Sources/PerfectSMTP/MTASTS/NIOMTASTSHTTPFetcher.swift @@ -18,16 +18,25 @@ import NIOCore import NIOHTTP1 import NIOPosix import NIOSSL +import NIOTLS /// Fetches MTA-STS policy files with a plain HTTP/1.1 GET over SwiftNIO. /// -/// - TLS uses `tlsConfiguration` (default: full certificate verification -/// against the system trust store) with the URL's host as SNI and -/// expected name; an `http://` URL (tests, local tooling) is fetched in -/// the clear. +/// - **Address vetting (SSRF):** the host is resolved once, through +/// `addressResolver` (default: this library's `DNSResolver`), every +/// address is checked with `DNSAddress.isRoutable`, and the connection is +/// made to a vetted address, so what was checked is what is dialed. The +/// name still goes in SNI and `Host`. With no permitted address the fetch +/// throws `MTASTSHTTPFetchError.noPermittedAddress` without connecting. +/// `allowPrivateAddresses` turns this off (tests, internal setups). +/// - **TLS** uses `tlsConfiguration` (default: full certificate +/// verification against the system trust store). For a host name, the +/// certificate must also carry a DNS subjectAltName for it: NIOSSL alone +/// accepts an IP SAN equal to the dialed address (release review). An +/// `http://` URL (tests, local tooling) is fetched in the clear. /// - Redirects are never followed (RFC 8461 §3.3): a 3xx is returned as /// is, and `MTASTSPolicyManager` treats any non-200 as a failed fetch. -/// - `timeout` bounds the whole fetch, connect to last byte +/// - `timeout` bounds the whole fetch, resolution to last byte /// (`MTASTSHTTPFetchError.timedOut`). /// - At most `maximumBodySize` body bytes are read. A larger /// `Content-Length` is refused before the body, and the connection is @@ -38,17 +47,23 @@ public struct NIOMTASTSHTTPFetcher: MTASTSHTTPFetching { private let timeout: TimeInterval private let maximumBodySize: Int private let tlsConfiguration: TLSConfiguration + private let addressResolver: any MTASTSAddressResolving + private let allowPrivateAddresses: Bool public init( group: any EventLoopGroup = MultiThreadedEventLoopGroup.singleton, timeout: TimeInterval = 60, maximumBodySize: Int = MTASTSPolicyManager.maximumPolicyResponseSizeBytes, - tlsConfiguration: TLSConfiguration = .makeClientConfiguration() + tlsConfiguration: TLSConfiguration = .makeClientConfiguration(), + addressResolver: (any MTASTSAddressResolving)? = nil, + allowPrivateAddresses: Bool = false ) { self.group = group self.timeout = timeout self.maximumBodySize = max(0, maximumBodySize) self.tlsConfiguration = tlsConfiguration + self.addressResolver = addressResolver ?? DNSResolver(group: group) + self.allowPrivateAddresses = allowPrivateAddresses } public enum FetchError: Error, Sendable, Equatable { @@ -60,42 +75,70 @@ public struct NIOMTASTSHTTPFetcher: MTASTSHTTPFetching { public func fetch(url: URL) async throws -> MTASTSHTTPResponse { guard let scheme = url.scheme?.lowercased(), scheme == "https" || scheme == "http", - let host = url.host, !host.isEmpty + let rawHost = url.host, !rawHost.isEmpty else { throw FetchError.unsupportedURL(url.absoluteString) } + let host = rawHost.hasPrefix("[") && rawHost.hasSuffix("]") ? String(rawHost.dropFirst().dropLast()) : rawHost let useTLS = scheme == "https" let port = url.port ?? (useTLS ? 443 : 80) let basePath = url.path.isEmpty ? "/" : url.path let path = url.query.map { basePath + "?" + $0 } ?? basePath + let literal = Self.addressLiteral(host) + // Host header: name or address, IPv6 in brackets, port if explicit. + let hostField = (literal.map { if case .v6 = $0 { return "[\(host)]" } else { return host } } ?? host) + + (url.port.map { ":\($0)" } ?? "") let limit = maximumBodySize let sslContext = useTLS ? try NIOSSLContext(configuration: tlsConfiguration) : nil let group = self.group let connectTimeout = Self.timeAmount(timeout) + let resolver = addressResolver + let allowPrivate = allowPrivateAddresses let promise = group.next().makePromise(of: MTASTSHTTPResponse.self) return try await Self.withDeadline(timeout, onTimeout: { promise.fail(MTASTSHTTPFetchError.timedOut) }) { + // Resolve once and vet; the vetted address is what gets dialed. + let resolved: [DNSAddress] + if let literal { + resolved = [literal] + } else { + resolved = try await resolver.resolveAddresses(hostname: host) + } + let permitted = allowPrivate ? resolved : resolved.filter(\.isRoutable) + guard !permitted.isEmpty else { + promise.fail(MTASTSHTTPFetchError.noPermittedAddress(host: host)) + throw MTASTSHTTPFetchError.noPermittedAddress(host: host) + } let bootstrap = ClientBootstrap(group: group) .connectTimeout(connectTimeout) .channelInitializer { channel in channel.eventLoop.makeCompletedFuture { if let sslContext { - let isIPLiteral = (try? SocketAddress(ipAddress: host, port: 0)) != nil - let tls = try NIOSSLClientHandler(context: sslContext, serverHostname: isIPLiteral ? nil : host) + let tls = try NIOSSLClientHandler(context: sslContext, serverHostname: literal == nil ? host : nil) try channel.pipeline.syncOperations.addHandler(tls) + if literal == nil { + try channel.pipeline.syncOperations.addHandler(PeerDNSNameCheck(host: host)) + } } try channel.pipeline.syncOperations.addHTTPClientHandlers() try channel.pipeline.syncOperations.addHandler(CappedResponseCollector(limit: limit, promise: promise)) } } - let channel: any Channel - do { - channel = try await bootstrap.connect(host: host, port: port).get() - } catch { - promise.fail(error) - throw error + var channel: (any Channel)? + var lastError: any Error = MTASTSHTTPFetchError.noPermittedAddress(host: host) + for address in permitted { + do { + channel = try await bootstrap.connect(to: SocketAddress(ipAddress: address.description, port: port)).get() + break + } catch { + lastError = error + } + } + guard let channel else { + promise.fail(lastError) + throw lastError } var headers = HTTPHeaders() - headers.add(name: "Host", value: url.port.map { "\(host):\($0)" } ?? host) + headers.add(name: "Host", value: hostField) headers.add(name: "User-Agent", value: "PerfectSMTP-MTA-STS") headers.add(name: "Accept", value: "text/plain") headers.add(name: "Connection", value: "close") @@ -113,6 +156,19 @@ public struct NIOMTASTSHTTPFetcher: MTASTSHTTPFetching { } } + /// `host` as an address if it is an IP literal. + static func addressLiteral(_ host: String) -> DNSAddress? { + guard let address = try? SocketAddress(ipAddress: host, port: 0) else { return nil } + switch address { + case .v4(let v4): + return .v4(withUnsafeBytes(of: v4.address.sin_addr) { Array($0) }) + case .v6(let v6): + return .v6(withUnsafeBytes(of: v6.address.sin6_addr) { Array($0) }) + default: + return nil + } + } + private static func timeAmount(_ seconds: TimeInterval) -> TimeAmount { .nanoseconds(Int64(min(max(0, seconds), 86_400) * 1_000_000_000)) } @@ -134,6 +190,28 @@ public struct NIOMTASTSHTTPFetcher: MTASTSHTTPFetching { } } +/// After the handshake, requires a DNS subjectAltName for `host` on the +/// verified certificate; on failure the error reaches the collector, which +/// fails the fetch and closes. Event-loop confined. +private final class PeerDNSNameCheck: ChannelInboundHandler, @unchecked Sendable { + typealias InboundIn = NIOAny + private let host: String + init(host: String) { self.host = host } + + func userInboundEventTriggered(context: ChannelHandlerContext, event: Any) { + if let tlsEvent = event as? TLSUserEvent, case .handshakeCompleted = tlsEvent { + do { + try CertificateDNSNames.requireMatch(in: context.pipeline.syncOperations, host: host) + } catch { + context.fireErrorCaught(error) + context.close(promise: nil) + return + } + } + context.fireUserInboundEventTriggered(event) + } +} + /// Collects one HTTP response, failing (and closing) as soon as the body /// would pass `limit`. Event-loop confined: all callbacks run on the /// channel's loop, which is why `@unchecked Sendable` holds. diff --git a/Sources/PerfectSMTP/SMTPConnectionPool.swift b/Sources/PerfectSMTP/SMTPConnectionPool.swift index 2b19d7f..6997fec 100644 --- a/Sources/PerfectSMTP/SMTPConnectionPool.swift +++ b/Sources/PerfectSMTP/SMTPConnectionPool.swift @@ -236,6 +236,13 @@ public final class SMTPConnectionPool: Sendable { DispatchTime(uptimeNanoseconds: DispatchTime.now().uptimeNanoseconds &+ UInt64(max(0, secondsFromNow) * 1_000_000_000)) } + /// Whether the default dialer requires a DNS subjectAltName for `key`: + /// always for a TLS connection to a host name, never for an IP literal + /// or plaintext. + static func requiresCertificateDNSName(_ key: Key) -> Bool { + key.tls != .none && (key.requireCertificateDNSName || !SMTPBootstrap.isIPAddressLiteral(key.host)) + } + public init(configuration: Configuration = .init(), ehloHostname: String = "localhost", group: any EventLoopGroup) { self.configuration = configuration self.ehloHostname = ehloHostname @@ -246,10 +253,18 @@ public final class SMTPConnectionPool: Sendable { let capturedReplyTimeout = configuration.replyTimeout let capturedDataTerminationTimeout = configuration.dataTerminationTimeout self.dialer = { key in + // Release review: a relay reached by name must present a + // certificate with a DNS subjectAltName for that name. NIOSSL + // alone also accepts an IP SAN equal to the dialed address, so + // someone able to redirect the relay's DNS to an address they + // hold an IP certificate for could otherwise collect AUTH + // credentials. An IP-literal relay is checked against its IP. let asyncChannel = try await SMTPBootstrap.connect( host: key.host, port: key.port, tls: key.tls, connectTimeout: capturedTimeout, replyTimeout: capturedReplyTimeout, - ehloHostname: capturedHostname, group: capturedGroup + ehloHostname: capturedHostname, + requireCertificateDNSName: Self.requiresCertificateDNSName(key), + group: capturedGroup ) let connection = SMTPConnection( asyncChannel: asyncChannel, diff --git a/Tests/PerfectSMTPTests/MTASTS/MTASTSHTTPFetcherSizeLimitTests.swift b/Tests/PerfectSMTPTests/MTASTS/MTASTSHTTPFetcherSizeLimitTests.swift index 6d38e67..f3ca2e8 100644 --- a/Tests/PerfectSMTPTests/MTASTS/MTASTSHTTPFetcherSizeLimitTests.swift +++ b/Tests/PerfectSMTPTests/MTASTS/MTASTSHTTPFetcherSizeLimitTests.swift @@ -35,7 +35,9 @@ enum FetcherKind: String, CaseIterable, Sendable { func make(timeout: TimeInterval = 30) -> any MTASTSHTTPFetching { switch self { - case .nio: NIOMTASTSHTTPFetcher(timeout: timeout) + // The local test servers are on 127.0.0.1, which the address + // vetting would (rightly) refuse. + case .nio: NIOMTASTSHTTPFetcher(timeout: timeout, allowPrivateAddresses: true) case .urlSession: URLSessionMTASTSFetcher(timeout: timeout) } } @@ -148,10 +150,20 @@ private final class EndlessBodyHTTPHandler: ChannelInboundHandler, @unchecked Se struct NIOMTASTSHTTPFetcherTests { private static let policy = "version: STSv1\r\nmode: enforce\r\nmx: mx.victim.test\r\nmax_age: 86400\r\n" - private func serve(tls: Bool, chunked: Bool, group: any EventLoopGroup) async throws -> (Channel, Int) { + private func serve(leaf: STARTTLSCertificateTests.Leaf, group: any EventLoopGroup) async throws -> (Channel, Int) { + try await serve(tls: true, chunked: false, leaf: leaf, group: group) + } + + private func serve(tls: Bool, chunked: Bool, leaf: STARTTLSCertificateTests.Leaf = .named, group: any EventLoopGroup) async throws -> (Channel, Int) { + let (certPEM, keyPEM): (String, String) + switch leaf { + case .ipOnly: (certPEM, keyPEM) = (TestCertificates.ipOnlyCertificatePEM, TestCertificates.ipOnlyKeyPEM) + case .named: (certPEM, keyPEM) = (TestCertificates.namedCertificatePEM, TestCertificates.namedKeyPEM) + case .dnsOnly: (certPEM, keyPEM) = (TestCertificates.dnsOnlyCertificatePEM, TestCertificates.dnsOnlyKeyPEM) + } let context: NIOSSLContext? = tls ? try NIOSSLContext(configuration: .makeServerConfiguration( - certificateChain: [.certificate(try NIOSSLCertificate(bytes: Array(TestCertificates.namedCertificatePEM.utf8), format: .pem))], - privateKey: .privateKey(try NIOSSLPrivateKey(bytes: Array(TestCertificates.namedKeyPEM.utf8), format: .pem)) + certificateChain: [.certificate(try NIOSSLCertificate(bytes: Array(certPEM.utf8), format: .pem))], + privateKey: .privateKey(try NIOSSLPrivateKey(bytes: Array(keyPEM.utf8), format: .pem)) )) : nil let body = Self.policy let bytes = Array(body.utf8) @@ -183,7 +195,7 @@ struct NIOMTASTSHTTPFetcherTests { func aPolicyIsFetched(tls: Bool, chunked: Bool) async throws { let group = MultiThreadedEventLoopGroup(numberOfThreads: 1) let (channel, port) = try await serve(tls: tls, chunked: chunked, group: group) - let fetcher = NIOMTASTSHTTPFetcher(group: group, timeout: 10, tlsConfiguration: try trustingTestCA()) + let fetcher = NIOMTASTSHTTPFetcher(group: group, timeout: 10, tlsConfiguration: try trustingTestCA(), allowPrivateAddresses: true) let url = try #require(URL(string: "\(tls ? "https" : "http")://127.0.0.1:\(port)/.well-known/mta-sts.txt")) let response = try await fetcher.fetch(url: url) try await channel.close() @@ -193,11 +205,71 @@ struct NIOMTASTSHTTPFetcherTests { #expect(String(decoding: response.body, as: UTF8.self) == Self.policy) } + private struct FixedResolver: MTASTSAddressResolving { + let addresses: [DNSAddress] + func resolveAddresses(hostname: String) async throws -> [DNSAddress] { addresses } + } + + /// Release review: NIOSSL accepts an IP SAN equal to the dialed address + /// even when a host name was asked for, so an IP-only certificate passed + /// for the policy host. A name now needs a matching DNS SAN. + @Test(.timeLimit(.minutes(1))) + func aPolicyHostNameNeedsADNSNameInTheCertificate() async throws { + let group = MultiThreadedEventLoopGroup(numberOfThreads: 1) + defer { Task { try? await group.shutdownGracefully() } } + let loopback = FixedResolver(addresses: [.v4([127, 0, 0, 1])]) + for (leaf, host, accepted) in [(STARTTLSCertificateTests.Leaf.ipOnly, "mta-sts.victim.test", false), + (.named, "mx.victim.test", true), (.dnsOnly, "other.victim.test", false)] { + let (channel, port) = try await serve(leaf: leaf, group: group) + let fetcher = NIOMTASTSHTTPFetcher( + group: group, timeout: 10, tlsConfiguration: try trustingTestCA(), + addressResolver: loopback, allowPrivateAddresses: true + ) + let url = try #require(URL(string: "https://\(host):\(port)/.well-known/mta-sts.txt")) + do { + let response = try await fetcher.fetch(url: url) + #expect(accepted && response.statusCode == 200, "\(leaf) for \(host) was accepted") + } catch { + #expect(!accepted, "\(leaf) for \(host): \(error)") + if leaf == .ipOnly { #expect(error as? CertificateNameError == .noDNSName(host: host)) } + } + try await channel.close() + } + } + + /// Release review (SSRF): the host is resolved once and only globally + /// routable addresses are dialed, by default. + @Test(.timeLimit(.minutes(1))) + func privateAddressesAreNeverDialedByDefault() async throws { + let group = MultiThreadedEventLoopGroup(numberOfThreads: 1) + defer { Task { try? await group.shutdownGracefully() } } + let (channel, port) = try await serve(tls: false, chunked: false, group: group) + for (resolver, url) in [ + (FixedResolver(addresses: [.v4([127, 0, 0, 1]), .v4([10, 0, 0, 1]), .v6(Array(repeating: 0, count: 15) + [1])]), "http://mta-sts.victim.test:\(port)/"), + (FixedResolver(addresses: []), "http://127.0.0.1:\(port)/"), + (FixedResolver(addresses: []), "http://[::1]:\(port)/"), + ] { + let fetcher = NIOMTASTSHTTPFetcher(group: group, timeout: 10, addressResolver: resolver) + await #expect(throws: MTASTSHTTPFetchError.self) { _ = try await fetcher.fetch(url: try #require(URL(string: url))) } + } + // The default resolver path, too: a private literal is refused. + await #expect(throws: MTASTSHTTPFetchError.noPermittedAddress(host: "127.0.0.1")) { + _ = try await NIOMTASTSHTTPFetcher(group: group, timeout: 10).fetch(url: try #require(URL(string: "http://127.0.0.1:\(port)/"))) + } + try await channel.close() + } + + @Test func anIPv6HostIsBracketedInTheHostHeader() { + #expect(NIOMTASTSHTTPFetcher.addressLiteral("::1") == .v6(Array(repeating: 0, count: 15) + [1])) + #expect(NIOMTASTSHTTPFetcher.addressLiteral("192.0.2.1") == .v4([192, 0, 2, 1])) + #expect(NIOMTASTSHTTPFetcher.addressLiteral("mta-sts.example.com") == nil) + } + @Test(.timeLimit(.minutes(1))) func anUntrustedCertificateFailsTheFetch() async throws { let group = MultiThreadedEventLoopGroup(numberOfThreads: 1) let (channel, port) = try await serve(tls: true, chunked: false, group: group) - let fetcher = NIOMTASTSHTTPFetcher(group: group, timeout: 10) // system trust store + let fetcher = NIOMTASTSHTTPFetcher(group: group, timeout: 10, allowPrivateAddresses: true) // system trust store let url = try #require(URL(string: "https://127.0.0.1:\(port)/.well-known/mta-sts.txt")) await #expect(throws: (any Error).self) { _ = try await fetcher.fetch(url: url) } try await channel.close() diff --git a/Tests/PerfectSMTPTests/STARTTLSCertificateTests.swift b/Tests/PerfectSMTPTests/STARTTLSCertificateTests.swift index bf339b1..b4f7598 100644 --- a/Tests/PerfectSMTPTests/STARTTLSCertificateTests.swift +++ b/Tests/PerfectSMTPTests/STARTTLSCertificateTests.swift @@ -217,3 +217,50 @@ private final class TLSFakeSMTPHandler: ChannelInboundHandler, @unchecked Sendab context.writeAndFlush(Self.wrapOutboundOut(ByteBuffer(string: line + "\r\n")), promise: nil) } } + +/// Release review: every relay TLS connection to a host name needs a DNS +/// subjectAltName, so a DNS attacker holding an IP certificate for the +/// address they redirect to can't collect AUTH credentials. +struct RelayCertificateNameTests { + @Test func theDefaultDialerRequiresADNSNameForTLSToAHostName() { + typealias Key = SMTPConnectionPool.Key + #expect(SMTPConnectionPool.requiresCertificateDNSName(Key(host: "smtp.example.com", port: 587, tls: .startTLS))) + #expect(SMTPConnectionPool.requiresCertificateDNSName(Key(host: "smtp.example.com", port: 465, tls: .implicit))) + #expect(!SMTPConnectionPool.requiresCertificateDNSName(Key(host: "192.0.2.10", port: 465, tls: .implicit))) + #expect(!SMTPConnectionPool.requiresCertificateDNSName(Key(host: "smtp.example.com", port: 25, tls: .none))) + } + + /// The attack itself, by name (localhost resolves without DNS): an + /// IP-only certificate for the dialed address passes NIOSSL's check, + /// and only the DNS-name requirement refuses it. + @Test(.timeLimit(.minutes(1)), arguments: [TLSMode.startTLS, .implicit]) + func anIPOnlyCertificateForARelayHostNameIsRefused(tls: TLSMode) async throws { + func connect(host: String = "localhost", require: Bool) async throws -> (any Error)? { + let group = MultiThreadedEventLoopGroup(numberOfThreads: 1) + let server = try await TLSFakeSMTPServer.start(leaf: .ipOnly, implicit: tls == .implicit, group: group) + var configuration = TLSConfiguration.makeClientConfiguration() + configuration.trustRoots = .certificates([try NIOSSLCertificate(bytes: Array(TestCertificates.caPEM.utf8), format: .pem)]) + var failure: (any Error)? + do { + let channel = try await SMTPBootstrap.connect( + host: host, port: server.port, tls: tls, replyTimeout: 10, + tlsConfiguration: configuration, requireCertificateDNSName: require, group: group + ) + try? await channel.channel.close() + } catch { failure = error } + try? await server.channel.close() + try await group.shutdownGracefully() + return failure + } + // NIOSSL alone accepts the IP certificate for the name... + #expect(try await connect(require: false) == nil) + // ...the DNS-name requirement refuses it... + let refused = try await connect(require: true) + guard case SMTPError.tlsCertificateInvalid? = refused else { + Issue.record("expected tlsCertificateInvalid, got \(String(describing: refused))") + return + } + // ...and an IP-literal relay is still checked against its IP SAN. + #expect(try await connect(host: "127.0.0.1", require: false) == nil) + } +} From ac4f6cabf25ac040a55890316c3374a87cdc31a4 Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Fri, 23 Oct 2026 14:12:58 -0500 Subject: [PATCH 31/39] Back off retries exponentially so they last the whole 5-day maxAge 451 4.4.1 and 4.7.5 classify as greylist-class, which was a flat 300 s, and maxAttempts was 10. A domain that stayed unreachable expired after about 50 minutes. RetryBackoffPolicy.delay(for:attempt:) now doubles from the class base (300 s greylist and connection failures, 900 s for 421, 120 s otherwise) up to maximumBackoff (4 h), with jitter (default +/-10%). The first greylisting retry is still 5 minutes. DirectMXRetryQueue applies the schedule from the entry's real attempt number, because the redelivery's own classification has no history and always says attempt 1. maxAttempts defaults to 50: about 35 attempts span 5 days (39 at jitter's low end), so maxAge is the real cutoff. Tests cover the exact schedule and cap, the jitter bounds, maxAge outlasting maxAttempts, and the queue actually lengthening its waits. The README and user guide document the schedule. Co-Authored-By: Claude Opus 5.5 --- Documentation/user-guide.md | 8 ++ README.md | 5 +- .../DirectMX/DirectMXRetryQueue.swift | 22 +++-- .../PerfectSMTPCore/RetryBackoffPolicy.swift | 52 ++++++++++-- .../DirectMX/DirectMXRetryQueueTests.swift | 85 ++++++++++++++++++- 5 files changed, 154 insertions(+), 18 deletions(-) diff --git a/Documentation/user-guide.md b/Documentation/user-guide.md index 03fd265..9fa447c 100644 --- a/Documentation/user-guide.md +++ b/Documentation/user-guide.md @@ -541,6 +541,14 @@ DNS timeouts or SERVFAIL, and MTA-STS `enforce` violations all become the cause). A host name that doesn't exist (NXDOMAIN), a null MX, and 5yz replies are final. +**The retry schedule** (`RetryBackoffPolicy`): each wait doubles from the +reply class's base delay (5 minutes for greylisting, `451 4.4.1` and +`4.7.5`; 15 minutes for `421`; 2 minutes for other 4yz) up to +`maximumBackoff` (4 hours), with ±10% jitter. A domain that stays down is +retried at about 5, 10, 20, 40 and 80 minutes, then every 4 hours, until +`DirectMXRetryQueue.Configuration.maxAge` (5 days); `maxAttempts` (50) is +only a backstop. The recipient then gets `.expired`. + **The retry queue lives in memory only.** Pending retries are lost when the process exits; if you need durability, record `.queuedForRetry` results yourself (or deliver through a local MTA with its own spool). If you diff --git a/README.md b/README.md index 9b93166..aab038b 100644 --- a/README.md +++ b/README.md @@ -211,8 +211,9 @@ You get these without configuring anything: - Unreachable hosts, timeouts, TLS failures and DNS timeouts or SERVFAIL are temporary and queued for retry (RFC 5321 §4.5.4.1), as is an MTA-STS `enforce` violation. NXDOMAIN, null MX and 5yz replies are - final. The retry queue is in memory only: pending retries are lost if - the process exits. + final. Retries back off exponentially (5 minutes doubling to a + 4-hour cap, with jitter) for up to 5 days. The retry queue is in memory + only: pending retries are lost if the process exits. ## Migrating from 5.x diff --git a/Sources/PerfectSMTP/DirectMX/DirectMXRetryQueue.swift b/Sources/PerfectSMTP/DirectMX/DirectMXRetryQueue.swift index 18605de..c204e03 100644 --- a/Sources/PerfectSMTP/DirectMX/DirectMXRetryQueue.swift +++ b/Sources/PerfectSMTP/DirectMX/DirectMXRetryQueue.swift @@ -85,9 +85,12 @@ public actor DirectMXRetryQueue { /// `DirectMXTransport.send` itself before anything is ever /// enqueued here, counts as attempt 1) before a still-transiently- /// failing entry becomes `.expired` instead of being rescheduled - /// again. Default 10 -- generous headroom given `maxAge` below is - /// the primary ceiling in practice (a 15-minute 421 backoff alone - /// would only reach ~2.5 hours after 10 attempts). + /// again. Default 50. Release review: the old default of 10, with a + /// flat 5-minute greylist-class delay, gave up after about 50 + /// minutes. With the exponential schedule (`RetryBackoffPolicy + /// .delay(for:attempt:)`, capped at 4 hours) about 35 attempts span + /// `maxAge`'s 5 days (about 39 with every delay at jitter's low end), so 50 + /// leaves `maxAge` as the real cutoff. public var maxAttempts: Int /// Maximum wall-clock age (measured from the *first* time a given /// recipient's delivery was queued for retry, not from the most @@ -119,7 +122,7 @@ public actor DirectMXRetryQueue { public init( backoff: RetryBackoffPolicy = .init(), - maxAttempts: Int = 10, + maxAttempts: Int = 50, maxAge: TimeInterval = 5 * 24 * 3600, maxTotalEntries: Int = 10_000 ) { @@ -548,12 +551,19 @@ public actor DirectMXRetryQueue { } else if configuration.isPastCeiling(attempt: nextAttemptNumber, firstQueuedAt: entry.firstQueuedAt) { await reportTerminal(DeliveryResult(recipient: result.recipient, outcome: .expired(attempts: nextAttemptNumber, last: last))) } else { + // The redelivery's own classification doesn't know the + // entry's history (it always says attempt 1), so the + // schedule is applied here, from the real attempt + // number. + _ = nextAttempt + let delay = configuration.backoff.delay(for: last, attempt: nextAttemptNumber) + let scheduled = Date().addingTimeInterval(delay) let rescheduled = Entry( id: UUID(), recipients: [result.recipient], mailFrom: entry.mailFrom, message: entry.message, - nextAttempt: nextAttempt, attempt: nextAttemptNumber, last: last, firstQueuedAt: entry.firstQueuedAt + nextAttempt: scheduled, attempt: nextAttemptNumber, last: last, firstQueuedAt: entry.firstQueuedAt ) entries[rescheduled.id] = rescheduled - nudgeLoop(forCandidate: nextAttempt) + nudgeLoop(forCandidate: scheduled) } } } diff --git a/Sources/PerfectSMTPCore/RetryBackoffPolicy.swift b/Sources/PerfectSMTPCore/RetryBackoffPolicy.swift index 1c2c3b4..d429f13 100644 --- a/Sources/PerfectSMTPCore/RetryBackoffPolicy.swift +++ b/Sources/PerfectSMTPCore/RetryBackoffPolicy.swift @@ -40,17 +40,59 @@ public struct RetryBackoffPolicy: Sendable { public var greylist: TimeInterval /// Backoff for any other `4yz` not covered above. public var defaultTransient: TimeInterval + /// The longest single wait. Release review: delays now double with + /// every attempt (`delay(for:attempt:)`), from the class's base delay + /// above up to this cap, so a destination that stays down is retried + /// across the retry queue's whole `maxAge` (5 days) rather than giving + /// up after ten flat 5-minute waits. Default 4 hours. + public var maximumBackoff: TimeInterval + /// Each delay is randomly spread by up to this fraction either way + /// (default 0.1, i.e. ±10%), so retries to one destination don't all + /// land at once. 0 makes the schedule exact. + public var jitter: Double public init( serviceUnavailable: TimeInterval = 900, greylist: TimeInterval = 300, - defaultTransient: TimeInterval = 120 + defaultTransient: TimeInterval = 120, + maximumBackoff: TimeInterval = 4 * 3600, + jitter: Double = 0.1 ) { self.serviceUnavailable = serviceUnavailable self.greylist = greylist self.defaultTransient = defaultTransient + self.maximumBackoff = maximumBackoff + self.jitter = min(max(0, jitter), 1) } + /// The base delay for `reply`'s class: `serviceUnavailable` for 421, + /// `greylist` for 450/451/452 (including the library's own synthesized + /// 451 4.4.1 "no usable connection" and 4.7.5 MTA-STS replies), + /// `defaultTransient` for any other 4yz. + public func baseDelay(for reply: SMTPReply) -> TimeInterval { + switch SMTPError.classify(reply) { + case .serviceUnavailable: return serviceUnavailable + case .greylisted: return greylist + default: return defaultTransient + } + } + + /// The wait before retrying after `reply` on attempt number `attempt` + /// (1 for the first send): `baseDelay × 2^(attempt-1)`, capped at + /// `maximumBackoff`, then spread by `jitter`. With the defaults a + /// greylist or connection failure waits about 5, 10, 20, 40 and 80 + /// minutes, then 2h40, then 4 hours each time. A real greylister lets + /// the first retry through, so greylisting still sees the familiar + /// 5-minute first retry. + public func delay(for reply: SMTPReply, attempt: Int) -> TimeInterval { + let base = baseDelay(for: reply) + let exponent = Double(max(0, min(attempt - 1, 30))) + let raw = min(maximumBackoff, base * pow(2, exponent)) + guard jitter > 0 else { return raw } + return raw * Double.random(in: (1 - jitter)...(1 + jitter)) + } + + /// Mechanically classifies a reply into a `DeliveryResult.Outcome`, /// exactly like `SMTPError.classify(_:)` classifies it into a /// `SMTPError` case -- `.permanentlyFailed` for `5yz`, @@ -60,12 +102,6 @@ public struct RetryBackoffPolicy: Sendable { /// correctly-backed-off retry hint either way. public func classify(_ reply: SMTPReply, attempt: Int) -> DeliveryResult.Outcome { guard reply.replyClass != .permanentNegative else { return .permanentlyFailed(reply) } - let backoff: TimeInterval - switch SMTPError.classify(reply) { - case .serviceUnavailable: backoff = serviceUnavailable - case .greylisted: backoff = greylist - default: backoff = defaultTransient - } - return .queuedForRetry(nextAttempt: Date().addingTimeInterval(backoff), attempt: attempt, last: reply) + return .queuedForRetry(nextAttempt: Date().addingTimeInterval(delay(for: reply, attempt: attempt)), attempt: attempt, last: reply) } } diff --git a/Tests/PerfectSMTPTests/DirectMX/DirectMXRetryQueueTests.swift b/Tests/PerfectSMTPTests/DirectMX/DirectMXRetryQueueTests.swift index a173c2e..c3d2e71 100644 --- a/Tests/PerfectSMTPTests/DirectMX/DirectMXRetryQueueTests.swift +++ b/Tests/PerfectSMTPTests/DirectMX/DirectMXRetryQueueTests.swift @@ -21,7 +21,8 @@ struct DirectMXRetryQueueTests { let policy = RetryBackoffPolicy( serviceUnavailable: 111, greylist: 22, - defaultTransient: 3 + defaultTransient: 3, + jitter: 0 ) let reply421 = SMTPReply(code: 421, lines: ["4.3.2 Service unavailable, closing channel"]) let reply450 = SMTPReply(code: 450, lines: ["4.2.0 Greylisted, try again later"]) @@ -511,7 +512,7 @@ struct DirectMXRetryQueueTests { #expect(failures == 0, "\(failures) of 40 queues lost track of their sleeping loop") } - private static func message() -> SignedMessage { + static func message() -> SignedMessage { SignedMessage(rfc5322: Array("Subject: hi\r\n\r\nbody".utf8)) } } @@ -574,3 +575,83 @@ actor OutcomeCollector { private enum OutcomeCollectorError: Error { case timedOut } + + +/// Release review: retries used a flat 5-minute delay for every +/// greylist-class reply (which includes the library's own 451 4.4.1 +/// connection failures) and gave up after 10 attempts, about 50 minutes. +struct RetryScheduleTests { + private static let connectionFailure = SMTPReply(code: 451, lines: ["4.4.1 No usable connection"]) + + @Test func delaysDoubleFromTheClassBaseUpToTheCap() { + let policy = RetryBackoffPolicy(jitter: 0) + let delays = (1...9).map { policy.delay(for: Self.connectionFailure, attempt: $0) } + #expect(delays == [300, 600, 1200, 2400, 4800, 9600, 14400, 14400, 14400]) + #expect(policy.delay(for: SMTPReply(code: 421, lines: ["busy"]), attempt: 1) == 900) + #expect(policy.delay(for: SMTPReply(code: 421, lines: ["busy"]), attempt: 3) == 3600) + #expect(policy.delay(for: SMTPReply(code: 441, lines: ["other"]), attempt: 2) == 240) + #expect(policy.delay(for: Self.connectionFailure, attempt: 1000) == 14400) // no overflow + } + + @Test func jitterStaysWithinItsFraction() { + let policy = RetryBackoffPolicy() // ±10% + let samples = (0..<500).map { _ in policy.delay(for: Self.connectionFailure, attempt: 3) } + #expect(samples.allSatisfy { (1080...1320).contains($0) }) + #expect(Set(samples.map { Int($0) }).count > 10, "jitter should actually vary the delay") + } + + /// With the defaults, maxAge (5 days), not maxAttempts, ends retrying. + @Test func theDefaultScheduleRunsForTheWholeMaxAge() { + let configuration = DirectMXRetryQueue.Configuration(backoff: RetryBackoffPolicy(jitter: 0)) + var elapsed: TimeInterval = 0 + var attempt = 1 + while elapsed < configuration.maxAge { + elapsed += configuration.backoff.delay(for: Self.connectionFailure, attempt: attempt) + attempt += 1 + } + #expect(attempt <= configuration.maxAttempts, "\(attempt) attempts needed to span maxAge, maxAttempts is \(configuration.maxAttempts)") + // Even with jitter at its low end every attempt, maxAge still wins. + let low = DirectMXRetryQueue.Configuration() + var lowElapsed: TimeInterval = 0 + var lowAttempt = 1 + while lowElapsed < low.maxAge { + lowElapsed += RetryBackoffPolicy(jitter: 0).delay(for: Self.connectionFailure, attempt: lowAttempt) * (1 - low.backoff.jitter) + lowAttempt += 1 + } + #expect(lowAttempt <= low.maxAttempts) + } + + /// The queue applies the schedule from the entry's real attempt number, + /// not the redelivery's own (history-less) classification. + @Test(.timeLimit(.minutes(1))) + func theQueueReschedulesWithAGrowingDelay() async throws { + let configuration = DirectMXRetryQueue.Configuration( + backoff: .init(serviceUnavailable: 0.01, greylist: 0.01, defaultTransient: 0.01, maximumBackoff: 1000, jitter: 0), + maxAttempts: 100, maxAge: 3600 + ) + let attempts = CallCountBox() + let queue = DirectMXRetryQueue( + configuration: configuration, + redeliver: { envelope, _ in + await attempts.increment() + return envelope.recipients.map { DeliveryResult(recipient: $0, outcome: configuration.classify(Self.connectionFailure, attempt: 1)) } + }, + onTerminalOutcome: { _ in } + ) + await queue.enqueue( + recipients: ["r@example.com"], mailFrom: .address("f@example.com"), message: DirectMXRetryQueueTests.message(), + outcome: configuration.classify(Self.connectionFailure, attempt: 1) + ) + // 0.01 × (2 + 4 + 8 + 16 + 32 + 64) s ≈ 1.26 s for the first six + // redeliveries; a flat schedule would have done about 125 by then. + try await Task.sleep(nanoseconds: 1_500_000_000) + let count = await attempts.count + let pending = await queue.pendingEntriesSnapshot() + await queue.shutdown() + #expect((4...9).contains(count), "\(count) redeliveries in 1.5 s") + if let entry = pending.first { + let wait = entry.nextAttempt.timeIntervalSinceNow + #expect(wait > 0.1, "the next wait should have grown, got \(wait) s") + } + } +} From 57b5c536b2549be3484a150e7b9716b531082205 Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Fri, 23 Oct 2026 14:16:55 -0500 Subject: [PATCH 32/39] Never fold into a whitespace-only line; encode lookalike encoded words HeaderFolding could fold before trailing whitespace, or between two spaces, and leave a continuation line of nothing but whitespace. The reviewer's case is a Subject of "L"*70 + "a(c)end ". A fold is now made only where non-whitespace content both precedes it on the current line and follows it in the value. ASCII subjects and display names containing "=?" are now RFC 2047-encoded, so receivers don't decode a lookalike encoded word such as '=?utf-8?q?x?=' in plain text. The reviewer's random corpus is now clean. checkfold2.py reports no problems on 3000 + 5000 + 5000 messages (before: 45-49 whitespace-only lines and about 190 subject/From mismatches per 3000). checkfold.py finds no whitespace-only or over-998 lines. Its remaining categories are its email-module whitespace normalization and unbreakable tokens past 78 octets. dkimpy verifies all 24 signatures on the folded output. Co-Authored-By: Claude Opus 5.5 --- Sources/PerfectSMTPCore/HeaderEncoder.swift | 6 ++- Sources/PerfectSMTPCore/HeaderFolding.swift | 25 ++++++++++- .../HeaderFoldingTests.swift | 42 +++++++++++++++++++ 3 files changed, 69 insertions(+), 4 deletions(-) diff --git a/Sources/PerfectSMTPCore/HeaderEncoder.swift b/Sources/PerfectSMTPCore/HeaderEncoder.swift index e0200bb..46da781 100644 --- a/Sources/PerfectSMTPCore/HeaderEncoder.swift +++ b/Sources/PerfectSMTPCore/HeaderEncoder.swift @@ -55,7 +55,9 @@ public enum HeaderEncoder { // A run without whitespace can't be folded; past 900 octets it // would break the 998-octet line limit, so encode it instead // (encoded words are folded between words). - guard needsEncoding(sanitized) || longestUnbreakableRun(sanitized) > 900 else { return sanitized } + // Text containing "=?" is encoded too: a receiver would otherwise + // decode a lookalike encoded word in plain text ("=?utf-8?q?x?="). + guard needsEncoding(sanitized) || longestUnbreakableRun(sanitized) > 900 || sanitized.contains("=?") else { return sanitized } return encodedWords(for: sanitized).joined(separator: "\r\n ") } @@ -67,7 +69,7 @@ public enum HeaderEncoder { public static func encodePhrase(_ text: String) -> String { let sanitized = sanitizeForHeader(text) guard !sanitized.isEmpty else { return sanitized } - if sanitized.utf8.allSatisfy({ $0 < 0x80 }) { + if sanitized.utf8.allSatisfy({ $0 < 0x80 }), !sanitized.contains("=?") { return isPlainAtomsPhrase(sanitized) ? sanitized : quotedString(sanitized) } return encodedWords(for: sanitized).joined(separator: "\r\n ") diff --git a/Sources/PerfectSMTPCore/HeaderFolding.swift b/Sources/PerfectSMTPCore/HeaderFolding.swift index 7e6dc75..8a9eef9 100644 --- a/Sources/PerfectSMTPCore/HeaderFolding.swift +++ b/Sources/PerfectSMTPCore/HeaderFolding.swift @@ -33,6 +33,23 @@ public enum HeaderFolding { // line, and whether it's a WSP (fold before it) or an insertion // point (fold after the character, adding a space). var lastBreak: (index: Int, inserting: Bool)? + // Whether the current physical line has anything but whitespace + // yet (the header name counts). A fold is only made after content, + // so the line it ends is never whitespace-only either. + var lineHasContent = true + + // Release review: only fold where real content follows on the same + // line of the value. Folding before trailing whitespace produced a + // continuation line of nothing but whitespace (a Subject of + // "L"*70 + "a(c)end "), which RFC 5322 §3.2.2 forbids and some + // parsers read as the end of the header block. + var contentFollows = [Bool](repeating: false, count: bytes.count + 1) + var seen = false + for index in stride(from: bytes.count - 1, through: 0, by: -1) { + let byte = bytes[index] + if byte == cr || byte == lf { seen = false } else if byte != space && byte != tab { seen = true } + contentFollows[index] = seen + } var i = 0 while i < bytes.count { @@ -43,14 +60,17 @@ public enum HeaderFolding { i += 2 lineLength = 0 lastBreak = nil + lineHasContent = false continue } - if (byte == space || byte == tab), lineLength > 0 { + let isWSP = byte == space || byte == tab + if isWSP, lineHasContent, contentFollows[i] { lastBreak = (output.count, false) } output.append(byte) lineLength += 1 - if insertingBreaksAfter.contains(byte) { + if !isWSP { lineHasContent = true } + if insertingBreaksAfter.contains(byte), contentFollows[i + 1] { lastBreak = (output.count, true) } if lineLength > recommendedLineLength, let point = lastBreak, point.index < output.count { @@ -58,6 +78,7 @@ public enum HeaderFolding { output.insert(contentsOf: fold, at: point.index) lineLength = output.count - point.index - 2 lastBreak = nil + lineHasContent = output[(point.index + 2)...].contains { $0 != space && $0 != tab } } i += 1 } diff --git a/Tests/PerfectSMTPCoreTests/HeaderFoldingTests.swift b/Tests/PerfectSMTPCoreTests/HeaderFoldingTests.swift index e7e7c52..6ddc39a 100644 --- a/Tests/PerfectSMTPCoreTests/HeaderFoldingTests.swift +++ b/Tests/PerfectSMTPCoreTests/HeaderFoldingTests.swift @@ -70,3 +70,45 @@ struct HeaderFoldingTests { #expect(Self.longestHeaderLine(signed) <= 78) } } + +/// Release review: folding before trailing whitespace made a continuation +/// line of only whitespace. +struct HeaderFoldingWhitespaceTests { + private static func lines(_ name: String, _ value: String) -> [String] { + "\(name): \(HeaderFolding.fold(name: name, value: value))".components(separatedBy: "\r\n") + } + + @Test(arguments: [ + String(repeating: "L", count: 70) + "a(c)end ", + String(repeating: "L", count: 70) + "a(c)end ", + String(repeating: "word ", count: 40), + String(repeating: "x", count: 77) + " \t ", + "a" + String(repeating: " ", count: 120), + ]) + func noContinuationLineIsOnlyWhitespace(value: String) { + let folded = Self.lines("Subject", value) + #expect(!folded.dropFirst().contains { $0.allSatisfy { $0 == " " || $0 == "\t" } }, "\(folded)") + #expect(folded.joined() == "Subject: \(value)") + } + + @Test func theReviewersSubjectComposesWithoutAWhitespaceLine() throws { + var message = EmailMessage(from: EmailAddress(address: "sender@example.test")) + message.to = [EmailAddress(address: "rcpt@example.test")] + message.subject = String(repeating: "L", count: 70) + "a(c)end " + message.textBody = "hi" + let header = String(decoding: try MIMEComposer(message).compose().serialized(), as: UTF8.self).components(separatedBy: "\r\n\r\n")[0] + #expect(!header.components(separatedBy: "\r\n").contains { !$0.isEmpty && $0.allSatisfy { $0 == " " || $0 == "\t" } }) + } +} + +/// Release review nit: ASCII text containing "=?" is encoded, so a +/// lookalike encoded word isn't decoded by the receiver. +struct LookalikeEncodedWordTests { + @Test func subjectsAndDisplayNamesWithEqualsQuestionAreEncoded() { + let subject = HeaderEncoder.encodeUnstructured("price =?utf-8?q?FREE?= today") + #expect(subject.hasPrefix("=?utf-8?B?")) + let phrase = HeaderEncoder.encodePhrase("=?utf-8?q?Admin?=") + #expect(phrase.hasPrefix("=?utf-8?B?")) + #expect(HeaderEncoder.encodeUnstructured("plain = and ? apart") == "plain = and ? apart") + } +} From b70cd5025a279fe8bcbefccb1814e57eb3e794c3 Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Fri, 23 Oct 2026 14:18:09 -0500 Subject: [PATCH 33/39] Make SMTPEnvelope addresses read-only and validated; re-check in LocalMTA SMTPEnvelope.mailFrom and .recipients were public vars, so a caller could put an unvalidated address into a validated envelope. LocalMTATransport then passed it straight to the sendmail argv. The init also never checked the reverse path. I chose immutability: both are now public private(set), and the init validates the reverse path as well as the recipients (mailbox syntax, no leading '-', no control characters). A new SMTPEnvelope.validate() re-runs the same checks, and LocalMTATransport and SMTPConnection call it right before the addresses reach the argv or the wire. Callers that mutated an envelope must build a new one; this is a breaking change, allowed in 6.0.0. Co-Authored-By: Claude Opus 5.5 --- Sources/PerfectSMTP/LocalMTATransport.swift | 3 ++ Sources/PerfectSMTP/SMTPConnection.swift | 1 + Sources/PerfectSMTPCore/SMTPEnvelope.swift | 41 +++++++++++-------- .../RelayTransportAuthTLSTests.swift | 19 ++++----- 4 files changed, 37 insertions(+), 27 deletions(-) diff --git a/Sources/PerfectSMTP/LocalMTATransport.swift b/Sources/PerfectSMTP/LocalMTATransport.swift index 4297037..a21718a 100644 --- a/Sources/PerfectSMTP/LocalMTATransport.swift +++ b/Sources/PerfectSMTP/LocalMTATransport.swift @@ -88,6 +88,9 @@ public actor LocalMTATransport: SMTPTransport { } private func runProcess(envelope: SMTPEnvelope, message: SignedMessage) async throws { + // Release review: the addresses go straight into the `sendmail` + // argv, so check them here as the SMTP path does before RCPT TO. + try envelope.validate() var arguments = config.extraArguments switch envelope.mailFrom { case .address(let address): diff --git a/Sources/PerfectSMTP/SMTPConnection.swift b/Sources/PerfectSMTP/SMTPConnection.swift index 78c0a51..48a19f8 100644 --- a/Sources/PerfectSMTP/SMTPConnection.swift +++ b/Sources/PerfectSMTP/SMTPConnection.swift @@ -319,6 +319,7 @@ public final class SMTPConnection: @unchecked Sendable { /// degrades to lock-step (await each reply before the next write) when /// absent — the same command sequence either way. public func sendMessage(_ envelope: SMTPEnvelope, _ message: SignedMessage) async throws -> [DeliveryResult] { + try envelope.validate() var mailFromLine = try envelope.mailFrom.mailFromCommand if capabilities.sizeAdvertised { mailFromLine += " SIZE=\(message.estimatedSize)" diff --git a/Sources/PerfectSMTPCore/SMTPEnvelope.swift b/Sources/PerfectSMTPCore/SMTPEnvelope.swift index 583863e..922067c 100644 --- a/Sources/PerfectSMTPCore/SMTPEnvelope.swift +++ b/Sources/PerfectSMTPCore/SMTPEnvelope.swift @@ -101,23 +101,34 @@ public struct DSNRequest: Sendable, Hashable { /// destined for individual `RCPT TO` commands — never serialized into any /// header. public struct SMTPEnvelope: Sendable { - public var mailFrom: ReversePath - /// Destined for individual `RCPT TO` commands. No transport consumes - /// this in Phase 0, but the identical unenforced-raw-string pattern as - /// `ReversePath.address`'s addr-spec exists here too (milestone review - /// finding) — validated at construction time below so the gap isn't - /// carried forward silently into Phase 1. Note this is a mutable - /// stored property: a caller that mutates `recipients` after - /// construction bypasses this check, same as any other value-type - /// invariant enforced only in an `init` — Phase 1's transport layer, - /// which actually emits `RCPT TO` on the wire, is the place a - /// stronger guarantee (e.g. re-validating immediately before send) - /// belongs. - public var recipients: [String] + /// Validated by `init` (a plain mailbox with no leading `-`, or `.null`) + /// and read-only afterwards. Release review: these used to be public + /// `var`s, so a caller could swap in an unvalidated address after + /// construction and `LocalMTATransport` passed it straight to the + /// `sendmail` argv. Build a new envelope instead of mutating one. + public private(set) var mailFrom: ReversePath + /// Destined for individual `RCPT TO` commands (or the `sendmail` argv). + /// Validated by `init` like `mailFrom` and read-only afterwards. + public private(set) var recipients: [String] public var size: Int? public var dsn: DSNRequest? public init(mailFrom: ReversePath, recipients: [String], size: Int? = nil, dsn: DSNRequest? = nil) throws { + try Self.validate(mailFrom: mailFrom, recipients: recipients) + self.mailFrom = mailFrom + self.recipients = recipients + self.size = size + self.dsn = dsn + } + + /// Re-runs `init`'s checks. Transports call this right before an + /// address reaches the wire or a command line (defense in depth). + public func validate() throws { + try Self.validate(mailFrom: mailFrom, recipients: recipients) + } + + private static func validate(mailFrom: ReversePath, recipients: [String]) throws { + _ = try mailFrom.mailFromCommand for recipient in recipients { _ = try HeaderEncoder.rejectHeaderInjection(recipient, field: "RCPT TO address") // FIX #2 (security review, CWE-88): reject a leading `-` here @@ -127,9 +138,5 @@ public struct SMTPEnvelope: Sendable { try ReversePath.rejectLeadingHyphen(recipient, field: "RCPT TO address") try AddressSyntax.validateMailbox(recipient, field: "RCPT TO address") } - self.mailFrom = mailFrom - self.recipients = recipients - self.size = size - self.dsn = dsn } } diff --git a/Tests/PerfectSMTPTests/RelayTransportAuthTLSTests.swift b/Tests/PerfectSMTPTests/RelayTransportAuthTLSTests.swift index 4bb296e..4123d1d 100644 --- a/Tests/PerfectSMTPTests/RelayTransportAuthTLSTests.swift +++ b/Tests/PerfectSMTPTests/RelayTransportAuthTLSTests.swift @@ -131,15 +131,14 @@ struct WireInjectionTests { #expect(try await channel.readOutbound(as: ByteBuffer.self) == nil) } - /// `SMTPEnvelope.recipients` is mutable after its validating init, so - /// the connection re-checks: the reviewer's `NOTIFY=`/`ORCPT=` payload - /// must not reach the wire, and nothing (not even MAIL FROM) is sent. - @Test func aRecipientMutatedAfterValidationIsRejectedBeforeAnythingIsWritten() async throws { - let (connection, channel) = try await ConnectionHarness.make() - var envelope = try SMTPEnvelope(mailFrom: .address("from@sender.example"), recipients: ["ok@example.com"]) - envelope.recipients = ["victim@example.test> NOTIFY=SUCCESS,FAILURE,DELAY ORCPT=rfc822;spoof@evil.test"] - let message = SignedMessage(rfc5322: Array("Subject: hi\r\n\r\nbody".utf8)) - await #expect(throws: (any Error).self) { _ = try await connection.sendMessage(envelope, message) } - #expect(try await channel.readOutbound(as: ByteBuffer.self) == nil) + /// `SMTPEnvelope`'s addresses are read-only after its validating init, + /// and an invalid reverse path can no longer be wrapped in one: the + /// reviewer's `AUTH=<>` payload is refused at construction. + @Test func anEnvelopeRefusesAnInvalidReversePath() throws { + #expect(throws: (any Error).self) { + _ = try SMTPEnvelope(mailFrom: .address("s@example.test> AUTH="), recipients: ["ok@example.com"]) + } + let envelope = try SMTPEnvelope(mailFrom: .address("from@sender.example"), recipients: ["ok@example.com"]) + #expect(throws: Never.self) { try envelope.validate() } } } From 83816f1a2757f682d2bf93348b31dd0453aa5feb Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Fri, 23 Oct 2026 14:42:15 -0500 Subject: [PATCH 34/39] Review nits: address length and IPv6 literals, per-record DNS labels, QUIT 221, LOGIN - Addresses are capped at 254 octets (RFC 5321 4.5.3.1.3). The 'IPv6:' tag is case-insensitive, and the literal is validated as RFC 4291 text: '[IPv6:::::]', '1::2::3', 5-digit groups and wrong group counts are refused. - DNS labels with unusual bytes are decoded in zone-file presentation form (\DDD) rather than failing the whole message, so an odd name in another record no longer rejects a valid answer. Escaped names never equal a real host name, so they can't match an owner and AddressSyntax.isHostname drops them as exchanges or CNAME targets. - QUIT waits up to 0.5 s for the 221 before closing. The deadline closes the channel itself, which ends the pending read without relying on cancellation. - SASL LOGIN sends the password once; a third 334 now throws SASLError.unexpectedChallenge instead of re-sending it. The challenge-bound test uses XOAUTH2, which answers any number of 334s. Co-Authored-By: Claude Opus 5.5 --- Sources/PerfectSMTP/DNS/DNSWireFormat.swift | 39 ++++++++++----- Sources/PerfectSMTP/SASLMechanism.swift | 11 ++++- Sources/PerfectSMTP/SMTPConnection.swift | 19 ++++++-- Sources/PerfectSMTPCore/AddressSyntax.swift | 48 +++++++++++++++---- .../AddressSyntaxTests.swift | 24 ++++++++++ .../DNS/DNSHardeningTests.swift | 34 +++++++++++-- .../SMTPConnectionPoolQuitTests.swift | 24 ++++++++++ .../SMTPConnectionStateMachineTests.swift | 16 +++++-- 8 files changed, 183 insertions(+), 32 deletions(-) diff --git a/Sources/PerfectSMTP/DNS/DNSWireFormat.swift b/Sources/PerfectSMTP/DNS/DNSWireFormat.swift index f0d2abd..6477b1b 100644 --- a/Sources/PerfectSMTP/DNS/DNSWireFormat.swift +++ b/Sources/PerfectSMTP/DNS/DNSWireFormat.swift @@ -55,11 +55,9 @@ public enum DNSWireError: Error, Sendable, Equatable { /// An MX record's RDATA was too short to contain even a preference /// field and a (possibly root) exchange name. case invalidMXRecordData - /// A query name label was empty or exceeded 63 bytes (RFC 1035 §2.3.4), - /// or (release review) a decoded label contained a byte that can't be - /// part of a host or service name: NUL and other controls, space, `.`, - /// DEL or non-ASCII. `mx\0.victim.test` used to decode to a name that - /// matched `*.victim.test` but reached TLS and logs with a NUL in it. + /// A query name label was empty or exceeded 63 bytes (RFC 1035 §2.3.4). + /// (Decoded labels with unusual bytes are escaped instead; see + /// `DNSMessage.presentationForm`.) case invalidLabel /// FIX #4 (plan §9 Phase 3 milestone security review, decompression /// amplification): the total number of compression-pointer jumps @@ -463,12 +461,7 @@ public struct DNSMessage: Sendable, Equatable { offset += 1 guard offset + labelLength <= bytes.count else { throw DNSWireError.truncated } let label = bytes[offset..) -> String { + guard !label.allSatisfy({ (0x21...0x7E).contains($0) && $0 != 0x2E && $0 != 0x5C }) else { + return String(decoding: label, as: UTF8.self) + } + var text = "" + for byte in label { + if (0x21...0x7E).contains(byte), byte != 0x2E, byte != 0x5C { + text.unicodeScalars.append(Unicode.Scalar(byte)) + } else { + let digits = String(byte) + text += "\\" + String(repeating: "0", count: 3 - digits.count) + digits + } + } + return text + } + private static func readUInt16(_ bytes: [UInt8], at offset: Int) -> UInt16 { UInt16(bytes[offset]) << 8 | UInt16(bytes[offset + 1]) } diff --git a/Sources/PerfectSMTP/SASLMechanism.swift b/Sources/PerfectSMTP/SASLMechanism.swift index 657ec96..b059e73 100644 --- a/Sources/PerfectSMTP/SASLMechanism.swift +++ b/Sources/PerfectSMTP/SASLMechanism.swift @@ -60,6 +60,9 @@ public enum SASLError: Error, Sendable, Equatable { /// following fields (for example an XOAUTH2 username adding its own /// `auth=` part). case separatorInCredential(field: String) + /// The server sent more continuation challenges than the mechanism + /// has steps (LOGIN has two: username, then password). + case unexpectedChallenge } public struct SASLPlain: SASLMechanism { @@ -117,7 +120,13 @@ public struct SASLLogin: SASLMechanism { public mutating func respond(to challenge: [UInt8]) async throws -> [UInt8] { step += 1 - return step == 1 ? Array(username.utf8) : Array(password.utf8) + switch step { + case 1: return Array(username.utf8) + case 2: return Array(password.utf8) + // Release review: a third 334 used to get the password again. + // LOGIN has exactly two steps; anything more is refused. + default: throw SASLError.unexpectedChallenge + } } public var isComplete: Bool { step >= 2 } diff --git a/Sources/PerfectSMTP/SMTPConnection.swift b/Sources/PerfectSMTP/SMTPConnection.swift index 48a19f8..b1a0d51 100644 --- a/Sources/PerfectSMTP/SMTPConnection.swift +++ b/Sources/PerfectSMTP/SMTPConnection.swift @@ -145,14 +145,25 @@ public final class SMTPConnection: @unchecked Sendable { } } - /// Best effort: sends `QUIT` (bounded by `timeout`, without waiting for - /// the `221`), then closes the channel. Used when the pool retires an + /// Best effort: sends `QUIT` and waits up to `timeout` for the `221` + /// (RFC 5321 §4.1.1.10), then closes the channel. Used when the pool retires an /// idle connection, and by `RelayTransport.shutdown()` through it, so /// the server sees an orderly end of session rather than a dropped /// socket (RFC 5321 §4.1.1.10). - func quitAndClose(timeout: TimeInterval = 2) async { + func quitAndClose(timeout: TimeInterval = 0.5) async { if channel.isActive { - _ = try? await raceAgainstTimeout(timeout) { try await self.outbound.write(.line("QUIT")) } + // Closing the channel is what ends a pending read, so the + // deadline closes it rather than relying on task cancellation + // to interrupt `rawNextReply()`. + let channel = self.channel + let deadline = Task { + try? await Task.sleep(nanoseconds: UInt64(max(0, timeout) * 1_000_000_000)) + channel.close(promise: nil) + } + if (try? await raceAgainstTimeout(timeout) { try await self.outbound.write(.line("QUIT")) }) != nil { + _ = try? await rawNextReply() + } + deadline.cancel() } channel.close(promise: nil) } diff --git a/Sources/PerfectSMTPCore/AddressSyntax.swift b/Sources/PerfectSMTPCore/AddressSyntax.swift index 7dd5a5a..ad4b076 100644 --- a/Sources/PerfectSMTPCore/AddressSyntax.swift +++ b/Sources/PerfectSMTPCore/AddressSyntax.swift @@ -47,7 +47,9 @@ public enum AddressSyntax { public static func isMailbox(_ address: String) -> Bool { let bytes = Array(address.utf8) - guard bytes.allSatisfy({ $0 < 0x80 }) else { return false } + // RFC 5321 §4.5.3.1.3: a path is at most 256 octets including the + // angle brackets, so the address itself at most 254. + guard bytes.count <= 254, bytes.allSatisfy({ $0 < 0x80 }) else { return false } let parts = bytes.split(separator: UInt8(ascii: "@"), omittingEmptySubsequences: false) guard parts.count == 2 else { return false } return isDotAtom(Array(parts[0])) && parts[0].count <= 64 && isDomainOrLiteral(Array(parts[1])) @@ -86,21 +88,51 @@ public enum AddressSyntax { return isHostname(bytes) } - /// RFC 5321 §4.1.3: an IPv4 dotted quad, or `IPv6:` followed by an IPv6 - /// address. The IPv6 form is checked for its character set and shape, - /// not fully parsed. + /// RFC 5321 §4.1.3: an IPv4 dotted quad, or `IPv6:` (any case) + /// followed by an IPv6 address in RFC 4291 §2.2 text form. private static func isAddressLiteral(_ text: String) -> Bool { - if text.hasPrefix("IPv6:") { - let rest = text.dropFirst(5) - guard rest.contains(":"), rest.count <= 45 else { return false } - return rest.utf8.allSatisfy { isHexDigit($0) || $0 == UInt8(ascii: ":") || $0 == UInt8(ascii: ".") } + if text.utf8.count > 5, text.prefix(5).lowercased() == "ipv6:" { + return isIPv6(String(text.dropFirst(5))) } + return isIPv4(text) + } + + static func isIPv4(_ text: String) -> Bool { let octets = text.split(separator: ".", omittingEmptySubsequences: false) return octets.count == 4 && octets.allSatisfy { octet in (1...3).contains(octet.count) && octet.utf8.allSatisfy(isDigit) && Int(octet).map { $0 <= 255 } == true } } + /// Eight 1–4 digit hex groups, or fewer with exactly one `::` + /// standing for one or more zero groups; the last 32 bits may be a + /// dotted quad. `::::`, `1::2::3`, `12345::` and similar are refused. + static func isIPv6(_ text: String) -> Bool { + guard !text.isEmpty, text.utf8.count <= 45 else { return false } + let halves = text.components(separatedBy: "::") + guard halves.count <= 2 else { return false } + func groups(_ part: String, allowIPv4Tail: Bool) -> Int? { + if part.isEmpty { return 0 } + let pieces = part.split(separator: ":", omittingEmptySubsequences: false) + var count = 0 + for (index, piece) in pieces.enumerated() { + if allowIPv4Tail, index == pieces.count - 1, piece.contains(".") { + guard isIPv4(String(piece)) else { return nil } + count += 2 + } else { + guard (1...4).contains(piece.count), piece.utf8.allSatisfy(isHexDigit) else { return nil } + count += 1 + } + } + return count + } + if halves.count == 1 { + return groups(halves[0], allowIPv4Tail: true) == 8 + } + guard let head = groups(halves[0], allowIPv4Tail: false), let tail = groups(halves[1], allowIPv4Tail: true) else { return false } + return head + tail <= 7 + } + private static func isDotAtom(_ bytes: [UInt8]) -> Bool { guard !bytes.isEmpty else { return false } let atoms = bytes.split(separator: UInt8(ascii: "."), omittingEmptySubsequences: false) diff --git a/Tests/PerfectSMTPCoreTests/AddressSyntaxTests.swift b/Tests/PerfectSMTPCoreTests/AddressSyntaxTests.swift index d80e807..0cdcd34 100644 --- a/Tests/PerfectSMTPCoreTests/AddressSyntaxTests.swift +++ b/Tests/PerfectSMTPCoreTests/AddressSyntaxTests.swift @@ -139,3 +139,27 @@ struct MessageIDSyntaxTests { #expect(throws: MIMEComposer.ComposerError.invalidHeaderValue("inlineResource.contentID")) { _ = try MIMEComposer(message).compose() } } } + +/// Release review nits: total length, and IPv6 literals. +struct AddressLiteralAndLengthTests { + @Test func addressesAreCappedAt254Octets() { + let domain = Array(repeating: String(repeating: "d", count: 63), count: 3).joined(separator: ".") + "." + String(repeating: "e", count: 57) + let local62 = String(repeating: "l", count: 62) + #expect(AddressSyntax.isMailbox(local62 + "@" + domain) == ((local62 + "@" + domain).utf8.count <= 254)) + #expect(!AddressSyntax.isMailbox(String(repeating: "l", count: 64) + "@" + domain)) + #expect(AddressSyntax.isMailbox("a@" + domain)) + } + + @Test(arguments: ["a@[IPv6:2001:db8::1]", "a@[ipv6:2001:db8::1]", "a@[IPV6:::1]", "a@[IPv6:1:2:3:4:5:6:7:8]", + "a@[IPv6:::ffff:192.0.2.1]", "a@[IPv6:fe80::]", "a@[IPv6:1::]"]) + func validIPv6LiteralsAreAccepted(address: String) { + #expect(AddressSyntax.isMailbox(address)) + } + + @Test(arguments: ["a@[IPv6:::::]", "a@[IPv6:1::2::3]", "a@[IPv6:12345::1]", "a@[IPv6:1:2:3:4:5:6:7:8:9]", + "a@[IPv6:1:2:3:4:5:6:7]", "a@[IPv6::1]", "a@[IPv6:1:]", "a@[IPv6:]", "a@[IPv6:g::1]", + "a@[IPv6:::1.2.3]", "a@[IPv6:1.2.3.4::]", "a@[IPv6:1:2:3:4:5:6:7::8]"]) + func invalidIPv6LiteralsAreRejected(address: String) { + #expect(!AddressSyntax.isMailbox(address)) + } +} diff --git a/Tests/PerfectSMTPTests/DNS/DNSHardeningTests.swift b/Tests/PerfectSMTPTests/DNS/DNSHardeningTests.swift index 35d57b7..ade7aa8 100644 --- a/Tests/PerfectSMTPTests/DNS/DNSHardeningTests.swift +++ b/Tests/PerfectSMTPTests/DNS/DNSHardeningTests.swift @@ -25,18 +25,42 @@ struct DNSHardeningTests { /// The reviewer's packet: an MX whose exchange label is `mx\0`. It used /// to decode to "mx\u{0}.victim.test", which matches `*.victim.test`. - @Test func aLabelWithANULByteIsRejected() { + /// The label is now escaped, and the exchange is refused as a host name. + @Test func aLabelWithANULByteIsEscapedAndRefusedAsAnExchange() throws { var packet: [UInt8] = [0, 1, 0x81, 0x80, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 15, 0, 1, 0, 0, 0, 60] let rdata: [UInt8] = [0, 10, 3, 0x6D, 0x78, 0x00, 6] + Array("victim".utf8) + [4] + Array("test".utf8) + [0] packet += [0, UInt8(rdata.count)] + rdata - #expect(throws: DNSWireError.invalidLabel) { _ = try DNSMessage.decode(packet) } + let message = try DNSMessage.decode(packet) + guard case .mx(_, let exchange) = message.answers.first?.rdata else { + Issue.record("expected an MX record") + return + } + #expect(exchange == "mx\\000.victim.test") + // It still looks like one label under *.victim.test, but it is not a + // host name, so it is never dialed or used for SNI. + #expect(!AddressSyntax.isHostname(exchange)) + #expect(throws: DNSResolver.ResolveError.malformedResponse) { _ = try DNSResolver.processMXAnswers(message.answers) } } - @Test(arguments: [[0x61, 0x2E, 0x62], [0x61, 0x20], [0x61, 0x7F], [0xC3, 0xA9], [0x0A]] as [[UInt8]]) - func aLabelWithADotSpaceControlOrNonASCIIByteIsRejected(label: [UInt8]) { + @Test(arguments: [([0x61, 0x2E, 0x62], "a\\046b"), ([0x61, 0x20], "a\\032"), ([0x61, 0x7F], "a\\127"), ([0xC3, 0xA9], "\\195\\169"), ([0x0A], "\\010"), ([0x5C], "\\092")] as [([UInt8], String)]) + func unusualLabelBytesAreEscapedNotTrusted(label: [UInt8], expected: String) throws { var packet = TestDNSMessageBuilder.header(id: 1, isResponse: true, ancount: 0) packet += [UInt8(label.count)] + label + [0, 0, 1, 0, 1] - #expect(throws: DNSWireError.invalidLabel) { _ = try DNSMessage.decode(packet) } + let message = try DNSMessage.decode(packet) + #expect(message.questions.first?.name == expected) + #expect(!AddressSyntax.isHostname(expected)) + } + + /// An odd name in another record no longer rejects the whole response. + @Test func anOddNameElsewhereDoesNotRejectAValidAnswer() throws { + let odd: [UInt8] = [3, 0x6D, 0x00, 0x78, 0] // one label: "m", NUL, "x" + let bytes = TestDNSMessageBuilder.response(id: 7, qname: "mx.victim.test", qtype: 1, records: [ + odd + [0, 1, 0, 1, 0, 0, 0, 60, 0, 4, 127, 0, 0, 1], + Self.aRecord(owner: "mx.victim.test", address: [93, 184, 216, 34]), + ]) + let message = try DNSMessage.decode(bytes) + let (addresses, _) = DNSResolver.extractAddresses(from: message, type: .a, name: "mx.victim.test") + #expect(addresses == [.v4([93, 184, 216, 34])]) } @Test func aNameLongerThan255OctetsIsRejectedOnDecodeAndEncode() { diff --git a/Tests/PerfectSMTPTests/SMTPConnectionPoolQuitTests.swift b/Tests/PerfectSMTPTests/SMTPConnectionPoolQuitTests.swift index ee7d097..8563e25 100644 --- a/Tests/PerfectSMTPTests/SMTPConnectionPoolQuitTests.swift +++ b/Tests/PerfectSMTPTests/SMTPConnectionPoolQuitTests.swift @@ -7,6 +7,7 @@ // import NIOConcurrencyHelpers +import Foundation import NIOCore import NIOEmbedded import Testing @@ -31,3 +32,26 @@ struct SMTPConnectionPoolQuitTests { #expect(written.readString(length: written.readableBytes) == "QUIT\r\n") } } + +struct QuitWaitsFor221Tests { + @Test func quitWaitsForThe221ThenCloses() async throws { + let (connection, channel) = try await ConnectionHarness.make() + try await channel.connect(to: SocketAddress(ipAddress: "127.0.0.1", port: 25)) + let started = DispatchTime.now() + let quit = Task { await connection.quitAndClose(timeout: 5) } + #expect(try await expectClientLine(channel) == "QUIT") + #expect(channel.isActive, "closed before the 221") + try await serverSend(channel, "221 2.0.0 Bye") + await quit.value + try await channel.closeFuture.get() + let seconds = Double(DispatchTime.now().uptimeNanoseconds - started.uptimeNanoseconds) / 1e9 + #expect(seconds < 4, "closed \(seconds) s after QUIT; it should follow the 221, not the timeout") + } + + @Test func quitClosesAtTheDeadlineWithoutA221() async throws { + let (connection, channel) = try await ConnectionHarness.make() + try await channel.connect(to: SocketAddress(ipAddress: "127.0.0.1", port: 25)) + await connection.quitAndClose(timeout: 0.2) + try await channel.closeFuture.get() + } +} diff --git a/Tests/PerfectSMTPTests/SMTPConnectionStateMachineTests.swift b/Tests/PerfectSMTPTests/SMTPConnectionStateMachineTests.swift index 8c9c2cc..40967d7 100644 --- a/Tests/PerfectSMTPTests/SMTPConnectionStateMachineTests.swift +++ b/Tests/PerfectSMTPTests/SMTPConnectionStateMachineTests.swift @@ -456,11 +456,13 @@ struct SASLHardeningTests { async let negotiate: Capabilities = connection.negotiateCapabilities() _ = try await expectClientLine(channel) try await serverSend(channel, "250-smtp.example.com") - try await serverSend(channel, "250 AUTH LOGIN") + try await serverSend(channel, "250 AUTH XOAUTH2") _ = try await negotiate - let auth = Task { try await connection.authenticate(SASLLogin(username: "u", password: "p")) } - _ = try await expectClientLine(channel) // AUTH LOGIN + // XOAUTH2 answers any number of 334s with an empty response, so it + // exercises the connection's own bound. + let auth = Task { try await connection.authenticate(XOAuth2(username: "u@example.com") { "token" }) } + _ = try await expectClientLine(channel) // AUTH XOAUTH2 for _ in 0.. Date: Fri, 23 Oct 2026 14:42:39 -0500 Subject: [PATCH 35/39] Document the DNS-SAN requirement and default MTA-STS address vetting Co-Authored-By: Claude Opus 5.5 --- Documentation/user-guide.md | 19 ++++++++++++------- README.md | 14 +++++++++----- 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/Documentation/user-guide.md b/Documentation/user-guide.md index 9fa447c..9c7ff09 100644 --- a/Documentation/user-guide.md +++ b/Documentation/user-guide.md @@ -148,7 +148,10 @@ freshly-dialed connection once and reusing it across sends. `tls` is a requested: if the server doesn't advertise STARTTLS, the connection fails rather than silently continuing in plaintext), or `.implicit` (TLS from the first byte, typically port 465). Both TLS modes verify the server's -certificate chain and hostname. +certificate chain, and for a relay reached by name the certificate must +carry a DNS subjectAltName for it (an IP-address SAN for the dialed +address is not enough), so someone who can redirect the relay's DNS +can't collect credentials with an IP certificate. `tls: .none` together with `auth` is refused: `send` throws `RelayTransportError.authenticationRequiresTLS` before opening a @@ -383,12 +386,14 @@ memory. Redirects are never followed (RFC 8461 §3.3). configuration (proxies, for example), but on Linux its size cap can't stop corelibs Foundation from buffering an endless body. -MTA-STS's SSRF-safety story mirrors `allowPrivateAddresses` above: the -HTTPS policy-file fetch target is address-checked the same way when you -pass `addressResolver` to `MTASTSPolicyManager.init` (as in the example -above), since the fetch hostname (`mta-sts.`) is just as -attacker-influenceable as an MX record when the recipient domain isn't -fully trusted. +MTA-STS's SSRF-safety story mirrors `allowPrivateAddresses` above, and is +on by default: `NIOMTASTSHTTPFetcher` resolves `mta-sts.` once, +refuses non-routable addresses, and connects to a vetted address (with +the name in SNI and `Host`), since the fetch hostname is just as +attacker-influenceable as an MX record. The policy host's certificate +must carry a DNS subjectAltName for it. Passing `addressResolver` to +`MTASTSPolicyManager.init` (as in the example above) adds the same check +for a custom fetcher. DANE/TLSA is explicitly out of scope for this rewrite (deferred per the plan) — MTA-STS is the only policy mechanism implemented. diff --git a/README.md b/README.md index aab038b..72c40b7 100644 --- a/README.md +++ b/README.md @@ -151,11 +151,15 @@ You get these without configuring anything: - **TLS certificates are verified.** Every TLS connection (`.startTLS`, `.implicit`, and `DirectMXTransport`'s STARTTLS attempts) uses `TLSConfiguration.makeClientConfiguration()`: full chain verification - plus a name check. The name check follows NIOSSL: the certificate must - name the host, or carry an IP-address SAN equal to the address - dialed. Only a real IP-literal host is dialed without SNI; any other - name that isn't a valid host name fails before connecting. A failed - certificate is reported as `SMTPError.tlsCertificateInvalid`. + plus a name check. A relay reached by name, a direct-MX host under an + MTA-STS policy, and the MTA-STS policy host must present a DNS + subjectAltName for that name: an IP-address SAN alone, which NIOSSL + would accept for the dialed address, isn't enough. An IP-literal host is + checked against its IP SAN, and opportunistic direct MX without a policy + uses NIOSSL's check as is. Only a real IP literal is dialed without + SNI; any other name that isn't a valid host name fails before + connecting. A failed certificate is reported as + `SMTPError.tlsCertificateInvalid`. - **`.startTLS` never downgrades.** If the server doesn't advertise `STARTTLS`, or answers it with an error such as `454`, the connection fails instead of continuing in plaintext. Bytes injected around the From c64544a5928d9ac6f88588cb3c28592fb1110e95 Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Fri, 23 Oct 2026 15:12:34 -0500 Subject: [PATCH 36/39] Retry-schedule test: check the gaps between redeliveries, not one wait theQueueReschedulesWithAGrowingDelay read the pending entry's remaining wait after 1.5 s. Under 12 busy loops the entry was already due, so it failed once in 3 release runs. It now timestamps every redelivery and checks that each gap is at least the scheduled 0.01 x 2^k s (load can only make gaps longer), and that the count stays far below what a flat schedule would produce. 7/7 runs under the same load passed. Co-Authored-By: Claude Opus 5.5 --- .../DirectMX/DirectMXRetryQueueTests.swift | 30 ++++++++++++------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/Tests/PerfectSMTPTests/DirectMX/DirectMXRetryQueueTests.swift b/Tests/PerfectSMTPTests/DirectMX/DirectMXRetryQueueTests.swift index c3d2e71..11787d6 100644 --- a/Tests/PerfectSMTPTests/DirectMX/DirectMXRetryQueueTests.swift +++ b/Tests/PerfectSMTPTests/DirectMX/DirectMXRetryQueueTests.swift @@ -622,18 +622,21 @@ struct RetryScheduleTests { } /// The queue applies the schedule from the entry's real attempt number, - /// not the redelivery's own (history-less) classification. + /// not the redelivery's own (history-less) classification. Each gap + /// between redeliveries must be at least the scheduled delay (a busy + /// machine can only make them later), and the count stays far below + /// what a flat 10 ms schedule would produce. @Test(.timeLimit(.minutes(1))) func theQueueReschedulesWithAGrowingDelay() async throws { let configuration = DirectMXRetryQueue.Configuration( backoff: .init(serviceUnavailable: 0.01, greylist: 0.01, defaultTransient: 0.01, maximumBackoff: 1000, jitter: 0), maxAttempts: 100, maxAge: 3600 ) - let attempts = CallCountBox() + let times = TimestampLog() let queue = DirectMXRetryQueue( configuration: configuration, redeliver: { envelope, _ in - await attempts.increment() + await times.record() return envelope.recipients.map { DeliveryResult(recipient: $0, outcome: configuration.classify(Self.connectionFailure, attempt: 1)) } }, onTerminalOutcome: { _ in } @@ -642,16 +645,21 @@ struct RetryScheduleTests { recipients: ["r@example.com"], mailFrom: .address("f@example.com"), message: DirectMXRetryQueueTests.message(), outcome: configuration.classify(Self.connectionFailure, attempt: 1) ) - // 0.01 × (2 + 4 + 8 + 16 + 32 + 64) s ≈ 1.26 s for the first six - // redeliveries; a flat schedule would have done about 125 by then. try await Task.sleep(nanoseconds: 1_500_000_000) - let count = await attempts.count - let pending = await queue.pendingEntriesSnapshot() await queue.shutdown() - #expect((4...9).contains(count), "\(count) redeliveries in 1.5 s") - if let entry = pending.first { - let wait = entry.nextAttempt.timeIntervalSinceNow - #expect(wait > 0.1, "the next wait should have grown, got \(wait) s") + let stamps = await times.stamps + #expect(stamps.count >= 2 && stamps.count <= 9, "\(stamps.count) redeliveries in 1.5 s") + // Redelivery k (k = 2, 3, ...) follows redelivery k-1 by at least + // 0.01 × 2^(k-1) s (the failed attempt k sets the next wait). + for index in 1..= scheduled * 0.9, "gap \(index): \(gap) s, scheduled \(scheduled) s") } } } + +private actor TimestampLog { + private(set) var stamps: [Date] = [] + func record() { stamps.append(Date()) } +} From 8ea0fe4e448211a3de10491ffe155f8fd214c7dc Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Fri, 23 Oct 2026 15:37:22 -0500 Subject: [PATCH 37/39] Serialize the TLS-heavy test suites and trim the DER fuzz On emulated linux/amd64 debug, the new TLS handshake tests (some load the system trust store) and a 2000-iteration DER mutation test ran all at once. They starved the timing-sensitive tests beside them: the retry-queue deadline, the CNAME cycle under 1 s, and the pool idle window failed in 4 of 5 runs. The certificate, relay-name, NIO fetcher and parser suites now run serialized, and the fuzz does 400 mutations. linux/amd64 then passed 5/5 debug and 5/5 release. Co-Authored-By: Claude Opus 5.5 --- Tests/PerfectSMTPTests/CertificateDNSNamesTests.swift | 7 +++++-- .../MTASTS/MTASTSHTTPFetcherSizeLimitTests.swift | 1 + Tests/PerfectSMTPTests/STARTTLSCertificateTests.swift | 5 +++++ 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/Tests/PerfectSMTPTests/CertificateDNSNamesTests.swift b/Tests/PerfectSMTPTests/CertificateDNSNamesTests.swift index 039ce21..a160c04 100644 --- a/Tests/PerfectSMTPTests/CertificateDNSNamesTests.swift +++ b/Tests/PerfectSMTPTests/CertificateDNSNamesTests.swift @@ -10,6 +10,7 @@ import NIOSSL import Testing @testable import PerfectSMTP +@Suite(.serialized) struct CertificateDNSNamesTests { private static func der(_ pem: String) throws -> [UInt8] { try NIOSSLCertificate(bytes: Array(pem.utf8), format: .pem).toDERBytes() @@ -99,12 +100,14 @@ struct CertificateDNSNamesTests { } } - /// Random mutations of a real certificate never crash; they either + /// Random mutations (400; more just costs CPU that starves the timing + /// tests running beside it on slow machines) of a real certificate + /// never crash; they either /// parse or throw `.malformedCertificate`. @Test func mutatedCertificatesNeverCrash() throws { let base = try Self.der(TestCertificates.namedCertificatePEM) var generator = SystemRandomNumberGenerator() - for _ in 0..<2000 { + for _ in 0..<400 { var bytes = base for _ in 0.. Date: Fri, 23 Oct 2026 15:58:35 -0500 Subject: [PATCH 38/39] CNAME-cycle test: bound it against the query timeout, not one second Three local round trips took over a second under TSan on Linux (2 of 2 runs), so 'elapsed < 1 s' failed although the cycle guard worked. The resolver now uses a 10 s query timeout and the test requires under 8 s. Any path that waited on a timeout would take at least 10 s, so it still catches the regression the test exists for. Linux TSan then passed 2/2 with no race reports. Co-Authored-By: Claude Opus 5.5 --- .../DNS/DNSResolverCNAMEFollowingTests.swift | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/Tests/PerfectSMTPTests/DNS/DNSResolverCNAMEFollowingTests.swift b/Tests/PerfectSMTPTests/DNS/DNSResolverCNAMEFollowingTests.swift index 07b95af..7dcbc7d 100644 --- a/Tests/PerfectSMTPTests/DNS/DNSResolverCNAMEFollowingTests.swift +++ b/Tests/PerfectSMTPTests/DNS/DNSResolverCNAMEFollowingTests.swift @@ -55,7 +55,10 @@ struct DNSResolverCNAMEFollowingTests { let server = try await FakeDNSServer.start(group: group, udpResponses: responses) defer { Task { await server.shutdown() } } - let resolver = DNSResolver(nameservers: [server.nameserver], group: group, queryTimeout: .seconds(2)) + // A long query timeout, so any path that waits for one stands out + // clearly from three local round trips even under TSan or + // emulation (where those alone took over a second). + let resolver = DNSResolver(nameservers: [server.nameserver], group: group, queryTimeout: .seconds(10)) let start = DispatchTime.now() await #expect(throws: DNSResolver.ResolveError.cnameLoop) { @@ -64,10 +67,10 @@ struct DNSResolverCNAMEFollowingTests { let elapsedSeconds = TimeInterval(DispatchTime.now().uptimeNanoseconds &- start.uptimeNanoseconds) / 1_000_000_000 // The cycle is caught the moment "a.loop.test" is revisited (the // third hop: a -> b -> a) -- three fast local queries, not - // anywhere close to `queryTimeout`'s 2s budget. A regression that + // anywhere close to `queryTimeout`'s 10 s budget. A regression that // turned this into a hang (or fell through to exhausting the - // 8-hop bound via repeated timeouts) would blow well past this. - #expect(elapsedSeconds < 1, "cycle detection took \(elapsedSeconds)s -- expected it to be caught almost immediately via the visited-name guard, not by exhausting retries/hops") + // 8-hop bound via repeated timeouts) would take at least that. + #expect(elapsedSeconds < 8, "cycle detection took \(elapsedSeconds)s -- expected it to be caught almost immediately via the visited-name guard, not by exhausting retries/hops") } @Test func aChainLongerThanTheHopBoundIsRejectedWithoutACycle() async throws { From e093c92c6924b11a2fa169a633148265e92b313c Mon Sep 17 00:00:00 2001 From: Tim Taplin Date: Fri, 23 Oct 2026 17:00:55 -0500 Subject: [PATCH 39/39] Don't wait for QUIT's 221 on eviction; gate MTA-STS GET on the SAN check Pool evictions and idle-timeout closes now write QUIT and release the slot without waiting for the server's reply; only shutdown() waits, up to Configuration.quitReplyTimeout (0.5 s default). The pool tests' fake connections answer QUIT with 221, and a new test shows a checkout behind an eviction is not held up by a server that never replies. NIOMTASTSHTTPFetcher writes the request only after the TLS handshake has completed and the certificate's DNS SAN has matched (TLSReadyGate replaces PeerDNSNameCheck). The readiness promise is created per connect attempt on a pinned event loop instead of being looked up in the pipeline. A new test checks that a server presenting an IP-only certificate never receives a byte of the request (TLS 1.2 and 1.3). URLSessionMTASTSFetcher reports URLSession's own request timeout as MTASTSHTTPFetchError.timedOut. Both are set from the same `timeout`, and under TSan on a loaded host the idle timeout sometimes fired first. Co-Authored-By: Claude Opus 5.5 --- .../MTASTS/MTASTSHTTPFetching.swift | 17 ++-- .../MTASTS/NIOMTASTSHTTPFetcher.swift | 90 ++++++++++++++----- Sources/PerfectSMTP/SMTPConnection.swift | 15 ++-- Sources/PerfectSMTP/SMTPConnectionPool.swift | 15 +++- .../MTASTSHTTPFetcherSizeLimitTests.swift | 48 ++++++++++ .../SMTPConnectionPoolQuitTests.swift | 27 ++++++ .../SMTPConnectionPoolTests.swift | 14 +-- Tests/PerfectSMTPTests/TestHelpers.swift | 24 ++++- 8 files changed, 206 insertions(+), 44 deletions(-) diff --git a/Sources/PerfectSMTP/MTASTS/MTASTSHTTPFetching.swift b/Sources/PerfectSMTP/MTASTS/MTASTSHTTPFetching.swift index 1989603..0a89464 100644 --- a/Sources/PerfectSMTP/MTASTS/MTASTSHTTPFetching.swift +++ b/Sources/PerfectSMTP/MTASTS/MTASTSHTTPFetching.swift @@ -176,11 +176,18 @@ public struct URLSessionMTASTSFetcher: MTASTSHTTPFetching { let session = self.session let limit = maximumBodySize let (data, response) = try await Self.withDeadline(timeout) { [request] in - #if canImport(FoundationNetworking) - try await RedirectRefusingFetchDelegate.fetch(request, configuration: session.configuration, limit: limit) - #else - try await Self.cappedFetch(request, session: session, limit: limit) - #endif + do { + #if canImport(FoundationNetworking) + return try await RedirectRefusingFetchDelegate.fetch(request, configuration: session.configuration, limit: limit) + #else + return try await Self.cappedFetch(request, session: session, limit: limit) + #endif + } catch let error as URLError where error.code == .timedOut { + // The request's idle timeout is the same `timeout`, and on a + // loaded host it can fire just before the deadline; either + // way the fetcher's timeout elapsed, so report it as one. + throw MTASTSHTTPFetchError.timedOut + } } guard let http = response as? HTTPURLResponse else { throw MTASTSHTTPFetchError.notAnHTTPResponse diff --git a/Sources/PerfectSMTP/MTASTS/NIOMTASTSHTTPFetcher.swift b/Sources/PerfectSMTP/MTASTS/NIOMTASTSHTTPFetcher.swift index 6be65bf..34e36e0 100644 --- a/Sources/PerfectSMTP/MTASTS/NIOMTASTSHTTPFetcher.swift +++ b/Sources/PerfectSMTP/MTASTS/NIOMTASTSHTTPFetcher.swift @@ -108,35 +108,59 @@ public struct NIOMTASTSHTTPFetcher: MTASTSHTTPFetching { promise.fail(MTASTSHTTPFetchError.noPermittedAddress(host: host)) throw MTASTSHTTPFetchError.noPermittedAddress(host: host) } - let bootstrap = ClientBootstrap(group: group) - .connectTimeout(connectTimeout) - .channelInitializer { channel in - channel.eventLoop.makeCompletedFuture { - if let sslContext { - let tls = try NIOSSLClientHandler(context: sslContext, serverHostname: literal == nil ? host : nil) - try channel.pipeline.syncOperations.addHandler(tls) - if literal == nil { - try channel.pipeline.syncOperations.addHandler(PeerDNSNameCheck(host: host)) + // One event loop for every attempt, so each attempt's readiness + // promise is created up front and held here rather than looked up + // in a pipeline that may already have been torn down. + let loop = group.next() + func bootstrap(ready: EventLoopPromise) -> ClientBootstrap { + ClientBootstrap(group: loop) + .connectTimeout(connectTimeout) + .channelInitializer { channel in + channel.eventLoop.makeCompletedFuture { + if let sslContext { + let tls = try NIOSSLClientHandler(context: sslContext, serverHostname: literal == nil ? host : nil) + try channel.pipeline.syncOperations.addHandler(tls) + try channel.pipeline.syncOperations.addHandler( + TLSReadyGate(host: literal == nil ? host : nil, ready: ready) + ) } + try channel.pipeline.syncOperations.addHTTPClientHandlers() + try channel.pipeline.syncOperations.addHandler(CappedResponseCollector(limit: limit, promise: promise)) } - try channel.pipeline.syncOperations.addHTTPClientHandlers() - try channel.pipeline.syncOperations.addHandler(CappedResponseCollector(limit: limit, promise: promise)) } - } - var channel: (any Channel)? + } + var connected: (channel: any Channel, ready: EventLoopPromise)? var lastError: any Error = MTASTSHTTPFetchError.noPermittedAddress(host: host) for address in permitted { + let ready = loop.makePromise(of: Void.self) do { - channel = try await bootstrap.connect(to: SocketAddress(ipAddress: address.description, port: port)).get() + let channel = try await bootstrap(ready: ready) + .connect(to: SocketAddress(ipAddress: address.description, port: port)).get() + connected = (channel, ready) break } catch { + ready.fail(error) // no-op if the gate already failed it lastError = error } } - guard let channel else { + guard let (channel, ready) = connected else { promise.fail(lastError) throw lastError } + // Re-review: nothing is written until the handshake has + // completed and the certificate's DNS name has been checked, so + // a refused server never sees the request. + if useTLS { + do { + try await ready.futureResult.get() + } catch { + channel.close(promise: nil) + promise.fail(error) + throw error + } + } else { + ready.succeed(()) + } var headers = HTTPHeaders() headers.add(name: "Host", value: hostField) headers.add(name: "User-Agent", value: "PerfectSMTP-MTA-STS") @@ -190,19 +214,27 @@ public struct NIOMTASTSHTTPFetcher: MTASTSHTTPFetching { } } -/// After the handshake, requires a DNS subjectAltName for `host` on the -/// verified certificate; on failure the error reaches the collector, which -/// fails the fetch and closes. Event-loop confined. -private final class PeerDNSNameCheck: ChannelInboundHandler, @unchecked Sendable { +/// Completes `ready` once the TLS handshake is done and, for a host name, +/// the verified certificate carries a matching DNS subjectAltName; fails +/// it (and closes) otherwise. The request is written only after `ready` +/// succeeds. Event-loop confined. +private final class TLSReadyGate: ChannelInboundHandler, @unchecked Sendable { typealias InboundIn = NIOAny - private let host: String - init(host: String) { self.host = host } + private let host: String? + let ready: EventLoopPromise + + init(host: String?, ready: EventLoopPromise) { + self.host = host + self.ready = ready + } func userInboundEventTriggered(context: ChannelHandlerContext, event: Any) { if let tlsEvent = event as? TLSUserEvent, case .handshakeCompleted = tlsEvent { do { - try CertificateDNSNames.requireMatch(in: context.pipeline.syncOperations, host: host) + if let host { try CertificateDNSNames.requireMatch(in: context.pipeline.syncOperations, host: host) } + ready.succeed(()) } catch { + ready.fail(error) context.fireErrorCaught(error) context.close(promise: nil) return @@ -210,6 +242,20 @@ private final class PeerDNSNameCheck: ChannelInboundHandler, @unchecked Sendable } context.fireUserInboundEventTriggered(event) } + + func errorCaught(context: ChannelHandlerContext, error: any Error) { + ready.fail(error) + context.fireErrorCaught(error) + } + + func channelInactive(context: ChannelHandlerContext) { + ready.fail(NIOMTASTSHTTPFetcher.FetchError.incompleteResponse) + context.fireChannelInactive() + } + + func handlerRemoved(context: ChannelHandlerContext) { + ready.fail(NIOMTASTSHTTPFetcher.FetchError.incompleteResponse) + } } /// Collects one HTTP response, failing (and closing) as soon as the body diff --git a/Sources/PerfectSMTP/SMTPConnection.swift b/Sources/PerfectSMTP/SMTPConnection.swift index b1a0d51..0f2eeee 100644 --- a/Sources/PerfectSMTP/SMTPConnection.swift +++ b/Sources/PerfectSMTP/SMTPConnection.swift @@ -145,12 +145,13 @@ public final class SMTPConnection: @unchecked Sendable { } } - /// Best effort: sends `QUIT` and waits up to `timeout` for the `221` - /// (RFC 5321 §4.1.1.10), then closes the channel. Used when the pool retires an - /// idle connection, and by `RelayTransport.shutdown()` through it, so - /// the server sees an orderly end of session rather than a dropped - /// socket (RFC 5321 §4.1.1.10). - func quitAndClose(timeout: TimeInterval = 0.5) async { + /// Best effort: sends `QUIT` (RFC 5321 §4.1.1.10), then closes the + /// channel, so the server sees an orderly end of session rather than a + /// dropped socket. With `waitForReply` it first waits up to `timeout` + /// for the `221`; the pool asks for that only on shutdown. An eviction + /// or idle-timeout close doesn't wait, because the pool frees the slot + /// when the close completes and a checkout may be waiting for it. + func quitAndClose(waitForReply: Bool = true, timeout: TimeInterval = 0.5) async { if channel.isActive { // Closing the channel is what ends a pending read, so the // deadline closes it rather than relying on task cancellation @@ -160,7 +161,7 @@ public final class SMTPConnection: @unchecked Sendable { try? await Task.sleep(nanoseconds: UInt64(max(0, timeout) * 1_000_000_000)) channel.close(promise: nil) } - if (try? await raceAgainstTimeout(timeout) { try await self.outbound.write(.line("QUIT")) }) != nil { + if (try? await raceAgainstTimeout(timeout) { try await self.outbound.write(.line("QUIT")) }) != nil, waitForReply { _ = try? await rawNextReply() } deadline.cancel() diff --git a/Sources/PerfectSMTP/SMTPConnectionPool.swift b/Sources/PerfectSMTP/SMTPConnectionPool.swift index 6997fec..a487f10 100644 --- a/Sources/PerfectSMTP/SMTPConnectionPool.swift +++ b/Sources/PerfectSMTP/SMTPConnectionPool.swift @@ -112,6 +112,10 @@ public final class SMTPConnectionPool: Sendable { /// terminator, where the server may legitimately be doing real /// work (spooling/scanning a large message). public var dataTerminationTimeout: TimeInterval + /// How long `shutdown()` waits for each idle connection's `221` + /// reply to `QUIT` before closing it (default 0.5 s). Evictions and + /// idle-timeout closes never wait: they send `QUIT` and close. + public var quitReplyTimeout: TimeInterval public init( maxPerHost: Int = 4, @@ -121,7 +125,8 @@ public final class SMTPConnectionPool: Sendable { circuitBreakerThreshold: Int = 5, circuitBreakerResetTimeout: TimeInterval = 30, replyTimeout: TimeInterval = 300, - dataTerminationTimeout: TimeInterval = 600 + dataTerminationTimeout: TimeInterval = 600, + quitReplyTimeout: TimeInterval = 0.5 ) { self.maxPerHost = maxPerHost self.maxTotal = maxTotal @@ -131,6 +136,7 @@ public final class SMTPConnectionPool: Sendable { self.circuitBreakerResetTimeout = circuitBreakerResetTimeout self.replyTimeout = replyTimeout self.dataTerminationTimeout = dataTerminationTimeout + self.quitReplyTimeout = quitReplyTimeout } } @@ -386,10 +392,15 @@ public final class SMTPConnectionPool: Sendable { /// lock. When each close completes, its slot is freed and parked /// waiters are admitted. private func run(_ effects: Effects) { + // Only a shutdown waits for the 221: an eviction or idle-timeout + // close holds a maxTotal slot until it completes, and a checkout may + // be parked waiting for that slot. + let shuttingDown = effects.closes.contains(where: \.polite) && state.withLockedValue { $0.isShutDown } for (connection, isEviction, polite) in effects.closes { connection.channel.closeFuture.whenComplete { _ in self.closeFinished(wasEviction: isEviction) } if polite { - Task { await connection.quitAndClose() } + let timeout = configuration.quitReplyTimeout + Task { await connection.quitAndClose(waitForReply: shuttingDown, timeout: timeout) } } else { connection.channel.close(promise: nil) } diff --git a/Tests/PerfectSMTPTests/MTASTS/MTASTSHTTPFetcherSizeLimitTests.swift b/Tests/PerfectSMTPTests/MTASTS/MTASTSHTTPFetcherSizeLimitTests.swift index 8137382..eef96b1 100644 --- a/Tests/PerfectSMTPTests/MTASTS/MTASTSHTTPFetcherSizeLimitTests.swift +++ b/Tests/PerfectSMTPTests/MTASTS/MTASTSHTTPFetcherSizeLimitTests.swift @@ -238,6 +238,42 @@ struct NIOMTASTSHTTPFetcherTests { } } + /// Re-review: the GET used to be written into NIOSSL before the + /// handshake finished, so a server whose certificate the DNS-name check + /// then refused still received the request. It must see nothing. + @Test(.timeLimit(.minutes(1)), arguments: [TLSVersion.tlsv12, .tlsv13]) + func aServerWhoseCertificateIsRefusedNeverReceivesTheRequest(version: TLSVersion) async throws { + let group = MultiThreadedEventLoopGroup(numberOfThreads: 1) + defer { Task { try? await group.shutdownGracefully() } } + let received = NIOLockedValueBox(0) + var serverConfiguration = TLSConfiguration.makeServerConfiguration( + certificateChain: [.certificate(try NIOSSLCertificate(bytes: Array(TestCertificates.ipOnlyCertificatePEM.utf8), format: .pem))], + privateKey: .privateKey(try NIOSSLPrivateKey(bytes: Array(TestCertificates.ipOnlyKeyPEM.utf8), format: .pem)) + ) + serverConfiguration.minimumTLSVersion = version + serverConfiguration.maximumTLSVersion = version + let context = try NIOSSLContext(configuration: serverConfiguration) + let server = try await ServerBootstrap(group: group) + .childChannelInitializer { channel in + channel.eventLoop.makeCompletedFuture { + try channel.pipeline.syncOperations.addHandler(NIOSSLServerHandler(context: context)) + try channel.pipeline.syncOperations.addHandler(RequestRecorder(received: received)) + } + } + .bind(host: "127.0.0.1", port: 0).get() + let port = try #require(server.localAddress?.port) + let fetcher = NIOMTASTSHTTPFetcher( + group: group, timeout: 10, tlsConfiguration: try trustingTestCA(), + addressResolver: FixedResolver(addresses: [.v4([127, 0, 0, 1])]), allowPrivateAddresses: true + ) + await #expect(throws: CertificateNameError.noDNSName(host: "mta-sts.victim.test")) { + _ = try await fetcher.fetch(url: try #require(URL(string: "https://mta-sts.victim.test:\(port)/.well-known/mta-sts.txt"))) + } + try await Task.sleep(nanoseconds: 200_000_000) // let anything in flight arrive + try await server.close() + #expect(received.withLockedValue { $0 } == 0, "the server received \(received.withLockedValue { $0 }) request bytes") + } + /// Release review (SSRF): the host is resolved once and only globally /// routable addresses are dialed, by default. @Test(.timeLimit(.minutes(1))) @@ -294,3 +330,15 @@ private final class FixedHTTPResponder: ChannelInboundHandler, @unchecked Sendab func errorCaught(context: ChannelHandlerContext, error: any Error) { context.close(promise: nil) } } + +/// Counts decrypted application bytes the client sends. Event-loop confined. +private final class RequestRecorder: ChannelInboundHandler, @unchecked Sendable { + typealias InboundIn = ByteBuffer + private let received: NIOLockedValueBox + init(received: NIOLockedValueBox) { self.received = received } + func channelRead(context: ChannelHandlerContext, data: NIOAny) { + let bytes = Self.unwrapInboundIn(data).readableBytes + received.withLockedValue { $0 += bytes } + } + func errorCaught(context: ChannelHandlerContext, error: any Error) { context.close(promise: nil) } +} diff --git a/Tests/PerfectSMTPTests/SMTPConnectionPoolQuitTests.swift b/Tests/PerfectSMTPTests/SMTPConnectionPoolQuitTests.swift index 8563e25..f1bab02 100644 --- a/Tests/PerfectSMTPTests/SMTPConnectionPoolQuitTests.swift +++ b/Tests/PerfectSMTPTests/SMTPConnectionPoolQuitTests.swift @@ -55,3 +55,30 @@ struct QuitWaitsFor221Tests { try await channel.closeFuture.get() } } + +/// Re-review: an eviction used to wait up to 0.5 s for the 221 before the +/// close freed the slot, delaying the checkout waiting for it. +struct EvictionDoesNotWaitForTheQUITReplyTests { + @Test(.timeLimit(.minutes(1))) + func aCheckoutWaitingOnAnEvictionIsNotHeldUpByASilentServer() async throws { + // The server never answers QUIT (answersQUIT: false), and the QUIT + // reply timeout is 30 s: had the eviction waited for a 221, the + // checkout would take 30 s. Robust on slow machines, unlike a tight + // bound against the 0.5 s default. + let pool = SMTPConnectionPool( + configuration: .init(maxPerHost: 1, maxTotal: 1, quitReplyTimeout: 30), group: NIOAsyncTestingEventLoop() + ) { _ in + let (connection, channel) = try await ConnectionHarness.make() + try await channel.connect(to: SocketAddress(ipAddress: "127.0.0.1", port: 25)) + return connection + } + let hostA = SMTPConnectionPool.Key(host: "a.example.com", port: 25, tls: .none) + let hostB = SMTPConnectionPool.Key(host: "b.example.com", port: 25, tls: .none) + try await pool.withConnection(to: hostA) { _ in () } // A is now idle and holds the only slot + let started = DispatchTime.now() + try await pool.withConnection(to: hostB) { _ in () } // evicts A + let seconds = Double(DispatchTime.now().uptimeNanoseconds - started.uptimeNanoseconds) / 1e9 + await pool.shutdown() + #expect(seconds < 10, "the checkout waited \(seconds) s for the evicted connection's QUIT reply") + } +} diff --git a/Tests/PerfectSMTPTests/SMTPConnectionPoolTests.swift b/Tests/PerfectSMTPTests/SMTPConnectionPoolTests.swift index 368c2f8..dd18870 100644 --- a/Tests/PerfectSMTPTests/SMTPConnectionPoolTests.swift +++ b/Tests/PerfectSMTPTests/SMTPConnectionPoolTests.swift @@ -32,7 +32,7 @@ struct SMTPConnectionPoolTests { group: NIOAsyncTestingEventLoop(), dialer: { _ in await onDial() - let (connection, _) = try await ConnectionHarness.make() + let (connection, _) = try await ConnectionHarness.make(answersQUIT: true) return connection } ) @@ -124,7 +124,7 @@ struct SMTPConnectionPoolTests { await firstDialGate.wait() throw SimulatedDialFailure() } - let (connection, _) = try await ConnectionHarness.make() + let (connection, _) = try await ConnectionHarness.make(answersQUIT: true) return connection } ) @@ -152,7 +152,7 @@ struct SMTPConnectionPoolTests { configuration: .init(maxPerHost: 4, maxTotal: 1), group: NIOAsyncTestingEventLoop(), dialer: { _ in - let (connection, _) = try await ConnectionHarness.make() + let (connection, _) = try await ConnectionHarness.make(answersQUIT: true) return connection } ) @@ -267,7 +267,7 @@ struct SMTPConnectionPoolTests { group: NIOAsyncTestingEventLoop(), dialer: { _ in await Task.yield() - let (connection, channel) = try await ConnectionHarness.make() + let (connection, channel) = try await ConnectionHarness.make(answersQUIT: true) try await channel.connect(to: SocketAddress(ipAddress: "127.0.0.1", port: 25)) sockets.opened(channel) return connection @@ -322,7 +322,7 @@ struct SMTPConnectionPoolTests { configuration: .init(maxPerHost: 4, maxTotal: 1), group: NIOAsyncTestingEventLoop(), dialer: { _ in - let (connection, channel) = try await ConnectionHarness.make() + let (connection, channel) = try await ConnectionHarness.make(answersQUIT: true) try await channel.connect(to: SocketAddress(ipAddress: "127.0.0.1", port: 25)) sockets.opened(channel) return connection @@ -352,7 +352,7 @@ struct SMTPConnectionPoolTests { configuration: .init(maxPerHost: 2, maxTotal: 2), group: NIOAsyncTestingEventLoop(), dialer: { _ in - let (connection, channel) = try await ConnectionHarness.make() + let (connection, channel) = try await ConnectionHarness.make(answersQUIT: true) try await channel.connect(to: SocketAddress(ipAddress: "127.0.0.1", port: 25)) sockets.opened(channel) return connection @@ -391,7 +391,7 @@ struct SMTPConnectionPoolTests { slowKeys: Set? = nil ) -> SMTPConnectionPool { SMTPConnectionPool(configuration: configuration, group: NIOAsyncTestingEventLoop(), dialer: { key in - let (connection, channel) = try await ConnectionHarness.make() + let (connection, channel) = try await ConnectionHarness.make(answersQUIT: true) if slowKeys?.contains(key) ?? true { try await channel.testingEventLoop.executeInContext { try channel.pipeline.syncOperations.addHandler(HoldCloseHandler(holder), position: .first) diff --git a/Tests/PerfectSMTPTests/TestHelpers.swift b/Tests/PerfectSMTPTests/TestHelpers.swift index cbd78bf..7ebe307 100644 --- a/Tests/PerfectSMTPTests/TestHelpers.swift +++ b/Tests/PerfectSMTPTests/TestHelpers.swift @@ -107,10 +107,14 @@ enum ConnectionHarness { static func make( replyTimeout: TimeInterval = 300, dataTerminationTimeout: TimeInterval = 600, - ehloHostname: String = "client.example.com" + ehloHostname: String = "client.example.com", + answersQUIT: Bool = false ) async throws -> (SMTPConnection, NIOAsyncTestingChannel) { let testingChannel = NIOAsyncTestingChannel() try await testingChannel.testingEventLoop.executeInContext { + if answersQUIT { + try testingChannel.pipeline.syncOperations.addHandler(QUITResponder(), position: .first) + } try testingChannel.pipeline.syncOperations.addHandler(ByteToMessageHandler(SMTPResponseDecoder())) try testingChannel.pipeline.syncOperations.addHandler(SMTPCommandEncoder()) } @@ -166,3 +170,21 @@ func trimCRLF(_ s: String) -> String { } return String(scalars) } + +/// Answers a `QUIT` written by the client with `221`, like a real server, +/// so pool tests don't sit out the QUIT reply timeout. Sits first in the +/// pipeline: it sees the encoded bytes on the way out and feeds the reply +/// back up through the decoder. Event-loop confined. +final class QUITResponder: ChannelOutboundHandler, @unchecked Sendable { + typealias OutboundIn = ByteBuffer + typealias OutboundOut = ByteBuffer + + func write(context: ChannelHandlerContext, data: NIOAny, promise: EventLoopPromise?) { + let buffer = Self.unwrapOutboundIn(data) + let isQUIT = buffer.getString(at: buffer.readerIndex, length: buffer.readableBytes) == "QUIT\r\n" + context.write(data, promise: promise) + if isQUIT { + context.fireChannelRead(NIOAny(ByteBuffer(string: "221 2.0.0 Bye\r\n"))) + } + } +}