diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..fd76ea5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,89 @@ +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 (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 + 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 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..9c7ff09 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 @@ -141,7 +147,17 @@ 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 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 +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 +245,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 @@ -299,20 +321,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. @@ -339,24 +361,39 @@ 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 configured a provider at all: plain opportunistic delivery per `tlsPolicy`. -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. +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, 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. @@ -431,8 +468,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 @@ -477,12 +514,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 @@ -497,28 +536,50 @@ 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 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 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()`. ## 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, +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 (`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/Package.swift b/Package.swift index 5c09d24..4b3d767 100644 --- a/Package.swift +++ b/Package.swift @@ -11,14 +11,28 @@ let package = Package( .library(name: "PerfectSMTP", targets: ["PerfectSMTP"]), ], dependencies: [ - .package(url: "https://github.com/apple/swift-nio.git", from: "2.65.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"), + // 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"), + // 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"), + // 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 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 @@ -55,8 +69,10 @@ 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: "SwiftASN1", package: "swift-asn1"), .product(name: "NIOTLS", package: "swift-nio"), .product(name: "Logging", package: "swift-log"), ], diff --git a/README.md b/README.md index 218d679..72c40b7 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 @@ -58,7 +51,7 @@ depend on it.) 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. @@ -71,21 +64,30 @@ 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; any 4.x release, so it +resolves alongside other packages using swift-crypto 4). ## 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 +104,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 +130,142 @@ 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 verification + 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 + 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. +- **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 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`, `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. 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 + +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`, 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 +[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..47edf13 100644 --- a/README.zh_CN.md +++ b/README.zh_CN.md @@ -1,39 +1,15 @@ # 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 + Swift 6.2+ - - Platforms macOS 26+ + + Platforms macOS 12+ | Linux License Apache 2.0 - - PerfectlySoft Twitter -

> **翻译说明:** 本文档由机器翻译并经过人工审阅,但尚未经过以 SMTP/DKIM 等协议 @@ -49,10 +25,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` 标签上。 ## 功能特性 @@ -69,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`,可以在不将全部收件人一次性载入内存的情况下 @@ -82,8 +59,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 +90,8 @@ libcurl 或其他邮件库的封装——它自己实现了 SMTP 线路协议, 发送一封邮件: ```swift -import PerfectSMTP import NIOPosix +import PerfectSMTP let group = MultiThreadedEventLoopGroup(numberOfThreads: 1) @@ -137,9 +116,27 @@ for result in results { print(result.recipient, result.outcome) } +await transport.shutdown() try await group.shutdownGracefully() ``` +`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)**。 @@ -149,15 +146,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)一节。 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/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/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..6477b1b 100644 --- a/Sources/PerfectSMTP/DNS/DNSWireFormat.swift +++ b/Sources/PerfectSMTP/DNS/DNSWireFormat.swift @@ -56,6 +56,8 @@ public enum DNSWireError: Error, Sendable, Equatable { /// field and a (possibly root) exchange name. case invalidMXRecordData /// 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 @@ -234,6 +236,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 +429,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 +460,11 @@ 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..) -> 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/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/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/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/MTASTS/MTASTSHTTPFetching.swift b/Sources/PerfectSMTP/MTASTS/MTASTSHTTPFetching.swift index d2da5e7..0a89464 100644 --- a/Sources/PerfectSMTP/MTASTS/MTASTSHTTPFetching.swift +++ b/Sources/PerfectSMTP/MTASTS/MTASTSHTTPFetching.swift @@ -71,6 +71,16 @@ 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 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) + /// 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` @@ -118,8 +128,17 @@ 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 + private let maximumBodySize: Int /// - Parameter session: On Darwin, the session every fetch runs on. On /// platforms that use `FoundationNetworking` (Linux), only its @@ -127,19 +146,49 @@ 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. + /// - 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 { 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 limit = maximumBodySize + let (data, response) = try await Self.withDeadline(timeout) { [request] in + 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 } @@ -149,6 +198,44 @@ public struct URLSessionMTASTSFetcher: MTASTSHTTPFetching { body: Array(data) ) } + + #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( + _ 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 @@ -182,9 +269,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 { @@ -236,8 +329,47 @@ 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 { + 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 { + lock.unlock() + return + } + if body.count + data.count > limit { + lock.unlock() + abandonTooLarge(dataTask, session: session) + return + } body.append(data) lock.unlock() } @@ -248,9 +380,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..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 @@ -230,13 +231,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. 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 private var cache: [String: CachedPolicy] = [:] private let dnsResolver: any TXTResolving @@ -265,15 +264,18 @@ 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 = URLSessionMTASTSFetcher(), + httpFetcher: any MTASTSHTTPFetching = NIOMTASTSHTTPFetcher(), addressResolver: (any MTASTSAddressResolving)? = nil, configuration: Configuration = .init() ) { @@ -514,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 +539,10 @@ public actor MTASTSPolicyManager: MTASTSPolicyProviding { let response: MTASTSHTTPResponse do { 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 new file mode 100644 index 0000000..34e36e0 --- /dev/null +++ b/Sources/PerfectSMTP/MTASTS/NIOMTASTSHTTPFetcher.swift @@ -0,0 +1,320 @@ +// +// 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 +import NIOTLS + +/// Fetches MTA-STS policy files with a plain HTTP/1.1 GET over SwiftNIO. +/// +/// - **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, 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 +/// 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 + 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(), + 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 { + /// 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 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) + } + // 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)) + } + } + } + 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 { + 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, 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") + 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 + } + } + } + + /// `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)) + } + + 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 + } + } +} + +/// 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? + 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 { + 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 + } + } + 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 +/// 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/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/SASLMechanism.swift b/Sources/PerfectSMTP/SASLMechanism.swift index 63b7fe9..b059e73 100644 --- a/Sources/PerfectSMTP/SASLMechanism.swift +++ b/Sources/PerfectSMTP/SASLMechanism.swift @@ -53,6 +53,18 @@ 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) + /// The server sent more continuation challenges than the mechanism + /// has steps (LOGIN has two: username, then password). + case unexpectedChallenge +} + public struct SASLPlain: SASLMechanism { public let name = "PLAIN" public let authzid: String @@ -67,6 +79,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)) @@ -105,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 } @@ -130,8 +151,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) } @@ -146,3 +169,58 @@ 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 } +} + +// 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/SMTPBootstrapHandler.swift b/Sources/PerfectSMTP/SMTPBootstrapHandler.swift index 6cef274..69499d6 100644 --- a/Sources/PerfectSMTP/SMTPBootstrapHandler.swift +++ b/Sources/PerfectSMTP/SMTPBootstrapHandler.swift @@ -74,12 +74,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: .hostPort(host: host, port: port), sniHostname: host, tls: tls, connectTimeout: connectTimeout, replyTimeout: replyTimeout, - tlsConfiguration: tlsConfiguration, group: group + tlsConfiguration: tlsConfiguration, ehloHostname: ehloHostname, + requireCertificateDNSName: requireCertificateDNSName, group: group ) } @@ -97,6 +100,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 +113,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 +141,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 +175,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,27 +245,49 @@ 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`; 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) - 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) } + 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 } + if case NIOSSLError.handshakeFailed(let underlying) = error { + return "\(underlying)".contains("CERTIFICATE_VERIFY_FAILED") + } + return false } } @@ -269,6 +318,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 +338,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 +347,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 @@ -362,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` @@ -380,6 +446,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 +493,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 +596,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 +642,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 +704,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/SMTPConnection.swift b/Sources/PerfectSMTP/SMTPConnection.swift index 87ad47c..0f2eeee 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. @@ -104,11 +107,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 @@ -141,6 +145,30 @@ public final class SMTPConnection: @unchecked Sendable { } } + /// 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 + // 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, waitForReply { + _ = try? await rawNextReply() + } + deadline.cancel() + } + 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. @@ -205,6 +233,12 @@ 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") + 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 { @@ -256,6 +290,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)" @@ -264,7 +302,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 { @@ -286,12 +331,16 @@ 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)" } 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)>" } @@ -371,6 +420,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). @@ -438,3 +502,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/Sources/PerfectSMTP/SMTPConnectionPool.swift b/Sources/PerfectSMTP/SMTPConnectionPool.swift index b77ca06..a487f10 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 } } @@ -105,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, @@ -114,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 @@ -124,6 +136,7 @@ public final class SMTPConnectionPool: Sendable { self.circuitBreakerResetTimeout = circuitBreakerResetTimeout self.replyTimeout = replyTimeout self.dataTerminationTimeout = dataTerminationTimeout + self.quitReplyTimeout = quitReplyTimeout } } @@ -165,7 +178,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 { @@ -188,14 +201,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. @@ -227,6 +242,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 @@ -237,9 +259,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, group: capturedGroup + connectTimeout: capturedTimeout, replyTimeout: capturedReplyTimeout, + ehloHostname: capturedHostname, + requireCertificateDNSName: Self.requiresCertificateDNSName(key), + group: capturedGroup ) let connection = SMTPConnection( asyncChannel: asyncChannel, @@ -328,7 +359,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 { @@ -361,9 +392,18 @@ 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) + // 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 { + let timeout = configuration.quitReplyTimeout + Task { await connection.quitAndClose(waitForReply: shuttingDown, timeout: timeout) } + } else { + connection.channel.close(promise: nil) + } } for (continuation, result) in effects.resumes { continuation.resume(with: result) } } @@ -499,7 +539,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 } @@ -512,7 +552,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 @@ -563,7 +603,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/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/Sources/PerfectSMTPCore/AddressSyntax.swift b/Sources/PerfectSMTPCore/AddressSyntax.swift new file mode 100644 index 0000000..ad4b076 --- /dev/null +++ b/Sources/PerfectSMTPCore/AddressSyntax.swift @@ -0,0 +1,158 @@ +// +// 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) + // 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])) + } + + /// 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:` (any case) + /// followed by an IPv6 address in RFC 4291 §2.2 text form. + private static func isAddressLiteral(_ text: String) -> Bool { + 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) + 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..262b477 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 @@ -262,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, @@ -271,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/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/Sources/PerfectSMTPCore/HeaderEncoder.swift b/Sources/PerfectSMTPCore/HeaderEncoder.swift index ea840a1..46da781 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,12 @@ 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). + // 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 ") } @@ -64,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 ") @@ -83,6 +88,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>, 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 @@ -164,6 +176,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..8a9eef9 --- /dev/null +++ b/Sources/PerfectSMTPCore/HeaderFolding.swift @@ -0,0 +1,93 @@ +// +// 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)? + // 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 { + 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 + lineHasContent = false + continue + } + let isWSP = byte == space || byte == tab + if isWSP, lineHasContent, contentFollows[i] { + lastBreak = (output.count, false) + } + output.append(byte) + lineLength += 1 + 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 { + 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 + lineHasContent = output[(point.index + 2)...].contains { $0 != space && $0 != tab } + } + 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 06283ec..d5297b8 100644 --- a/Sources/PerfectSMTPCore/MIMEComposer.swift +++ b/Sources/PerfectSMTPCore/MIMEComposer.swift @@ -37,6 +37,20 @@ 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) + /// `messageIDDomain` isn't an LDH hostname. + case invalidMessageIDDomain(String) + /// 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 @@ -183,6 +197,21 @@ 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 { + !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 +238,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) } @@ -217,6 +249,9 @@ public struct MIMEComposer: Sendable { guard message.textBody != nil || message.htmlBody != nil else { throw ComposerError.missingBody } + if let messageIDDomain, !AddressSyntax.isHostnameOrAddressLiteral(messageIDDomain) { + throw ComposerError.invalidMessageIDDomain(messageIDDomain) + } let top = try buildTopLevelPart() @@ -242,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())) @@ -249,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: " "))) } @@ -289,7 +327,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 @@ -430,7 +475,7 @@ public struct MIMEComposer: Sendable { let alternativeOrSingle: MIMEPart if bodyParts.count > 1 { - let boundary = boundaryGenerator() + let boundary = try makeBoundary() alternativeOrSingle = MIMEPart( headers: [("Content-Type", "multipart/alternative; boundary=\"\(boundary)\"")], body: .multipart(boundary: boundary, parts: bodyParts) @@ -441,7 +486,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( @@ -453,7 +498,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( @@ -650,6 +695,14 @@ 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") + // 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) @@ -674,7 +727,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 { @@ -805,6 +858,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/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/Sources/PerfectSMTPCore/SMTPEnvelope.swift b/Sources/PerfectSMTPCore/SMTPEnvelope.swift index e100be1..922067c 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:<>" @@ -100,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 @@ -124,10 +136,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 - self.size = size - self.dsn = dsn } } 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/PerfectSMTPCoreTests/AddressSyntaxTests.swift b/Tests/PerfectSMTPCoreTests/AddressSyntaxTests.swift new file mode 100644 index 0000000..0cdcd34 --- /dev/null +++ b/Tests/PerfectSMTPCoreTests/AddressSyntaxTests.swift @@ -0,0 +1,165 @@ +// +// 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]) + } + } +} + +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() } + } +} + +/// 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/PerfectSMTPCoreTests/BugRegressionTests.swift b/Tests/PerfectSMTPCoreTests/BugRegressionTests.swift index e0fc6ed..e2a7679 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 @@ -297,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() + } + } +} 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/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) + } +} diff --git a/Tests/PerfectSMTPCoreTests/HeaderFoldingTests.swift b/Tests/PerfectSMTPCoreTests/HeaderFoldingTests.swift new file mode 100644 index 0000000..6ddc39a --- /dev/null +++ b/Tests/PerfectSMTPCoreTests/HeaderFoldingTests.swift @@ -0,0 +1,114 @@ +// +// 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) + } +} + +/// 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") + } +} diff --git a/Tests/PerfectSMTPTests/CertificateDNSNamesTests.swift b/Tests/PerfectSMTPTests/CertificateDNSNamesTests.swift new file mode 100644 index 0000000..a160c04 --- /dev/null +++ b/Tests/PerfectSMTPTests/CertificateDNSNamesTests.swift @@ -0,0 +1,125 @@ +// +// 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 + +@Suite(.serialized) +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 (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..<400 { + var bytes = base + for _ in 0.. 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/DNS/DNSHardeningTests.swift b/Tests/PerfectSMTPTests/DNS/DNSHardeningTests.swift new file mode 100644 index 0000000..ade7aa8 --- /dev/null +++ b/Tests/PerfectSMTPTests/DNS/DNSHardeningTests.swift @@ -0,0 +1,144 @@ +// +// DNSHardeningTests.swift +// PerfectSMTPTests +// +// Release review: label bytes and name length on decode, question and +// owner-name matching, ignoring malformed or mismatched datagrams, and +// resolveAddresses reporting a timeout instead of "no records". Uses the +// local FakeDNSServer on 127.0.0.1. +// + +import NIOCore +import NIOPosix +import Testing +@testable import PerfectSMTP + +struct DNSHardeningTests { + private static func aRecord(owner: String, address: [UInt8]) -> [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`. + /// 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 + 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], "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] + 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() { + // 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/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 { 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] 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/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 diff --git a/Tests/PerfectSMTPTests/DirectMX/DirectMXRetryQueueTests.swift b/Tests/PerfectSMTPTests/DirectMX/DirectMXRetryQueueTests.swift index b2d16ee..11787d6 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"]) @@ -452,7 +453,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) @@ -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,91 @@ 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. 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 times = TimestampLog() + let queue = DirectMXRetryQueue( + configuration: configuration, + redeliver: { envelope, _ in + await times.record() + 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) + ) + try await Task.sleep(nanoseconds: 1_500_000_000) + await queue.shutdown() + 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()) } +} 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/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 new file mode 100644 index 0000000..eef96b1 --- /dev/null +++ b/Tests/PerfectSMTPTests/MTASTS/MTASTSHTTPFetcherSizeLimitTests.swift @@ -0,0 +1,344 @@ +// +// 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 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 { + // 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) + } + } +} + +struct MTASTSHTTPFetcherSizeLimitTests { + @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) + let channel = try await ServerBootstrap(group: group) + .childChannelInitializer { channel in + channel.eventLoop.makeCompletedFuture { + try channel.pipeline.syncOperations.addHandler( + EndlessBodyHTTPHandler(declareHugeLength: declareHugeLength, sent: sent, closed: closed) + ) + } + } + .bind(host: "127.0.0.1", port: 0).get() + let port = try #require(channel.localAddress?.port) + + 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)) { + _ = 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() + #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 { + 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 let closed: NIOLockedValueBox + private var started = false + private let chunk = ByteBuffer(repeating: UInt8(ascii: "#"), count: 64 * 1024) + + 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) { + 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) + } +} + +/// The NIO fetcher end to end: a real policy over HTTPS (local test CA), +/// a chunked body, and an untrusted certificate. +@Suite(.serialized) +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(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(certPEM.utf8), format: .pem))], + privateKey: .privateKey(try NIOSSLPrivateKey(bytes: Array(keyPEM.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(), 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() + 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) + } + + 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() + } + } + + /// 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))) + 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, 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() + 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) } +} + +/// 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/MTASTS/MTASTSHTTPFetcherTimeoutTests.swift b/Tests/PerfectSMTPTests/MTASTS/MTASTSHTTPFetcherTimeoutTests.swift new file mode 100644 index 0000000..a7ec125 --- /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)), arguments: FetcherKind.allCases) + func aTricklingPolicyHostIsCutOffAtTheFetchersDeadline(kind: FetcherKind) 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 = 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) { + _ = 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 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 + 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: 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 + bound.value.writeAndFlush(Self.wrapOutboundOut(ByteBuffer(string: "x")), promise: nil) + } + } + + func channelInactive(context: ChannelHandlerContext) { + task?.cancel() + context.fireChannelInactive() + } + + func handlerRemoved(context: ChannelHandlerContext) { + task?.cancel() + } +} diff --git a/Tests/PerfectSMTPTests/RelayTransportAuthTLSTests.swift b/Tests/PerfectSMTPTests/RelayTransportAuthTLSTests.swift new file mode 100644 index 0000000..4123d1d --- /dev/null +++ b/Tests/PerfectSMTPTests/RelayTransportAuthTLSTests.swift @@ -0,0 +1,144 @@ +// +// 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) + } +} + +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("")) + } + + @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 { + @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`'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() } + } +} diff --git a/Tests/PerfectSMTPTests/SMTPConnectionPoolQuitTests.swift b/Tests/PerfectSMTPTests/SMTPConnectionPoolQuitTests.swift new file mode 100644 index 0000000..f1bab02 --- /dev/null +++ b/Tests/PerfectSMTPTests/SMTPConnectionPoolQuitTests.swift @@ -0,0 +1,84 @@ +// +// 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 Foundation +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") + } +} + +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() + } +} + +/// 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 1e2ff9f..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) @@ -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) @@ -625,14 +629,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 +650,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/SMTPConnectionStateMachineTests.swift b/Tests/PerfectSMTPTests/SMTPConnectionStateMachineTests.swift index a81cc23..40967d7 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"]) @@ -404,3 +449,45 @@ 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 XOAUTH2") + _ = try await negotiate + + // 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.. (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() +} 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 { diff --git a/Tests/PerfectSMTPTests/STARTTLSCertificateTests.swift b/Tests/PerfectSMTPTests/STARTTLSCertificateTests.swift new file mode 100644 index 0000000..41c04cc --- /dev/null +++ b/Tests/PerfectSMTPTests/STARTTLSCertificateTests.swift @@ -0,0 +1,271 @@ +// +// 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 + +// Serialized: every case is a TLS handshake (and some load the system +// trust store). Run all at once on a slow machine (emulated amd64 debug) +// they starve the timing-sensitive tests running alongside. +@Suite(.serialized) +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) + } +} + +/// 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. +@Suite(.serialized) +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) + } +} 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----- +""" +} diff --git a/Tests/PerfectSMTPTests/TestHelpers.swift b/Tests/PerfectSMTPTests/TestHelpers.swift index 4973c82..7ebe307 100644 --- a/Tests/PerfectSMTPTests/TestHelpers.swift +++ b/Tests/PerfectSMTPTests/TestHelpers.swift @@ -106,10 +106,15 @@ 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", + 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()) } @@ -118,7 +123,7 @@ enum ConnectionHarness { } let connection = SMTPConnection( asyncChannel: asyncChannel, - ehloHostname: "client.example.com", + ehloHostname: ehloHostname, replyTimeout: replyTimeout, dataTerminationTimeout: dataTerminationTimeout ) @@ -165,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"))) + } + } +}