Trust a stated mail scheme over the port guess when seeding encryption
MAIL_SCHEME=smtps or an smtps:// MAIL_URL on a non-465 port used to be downgraded to STARTTLS because mail.encryption was derived from the port alone. Resolve the scheme first (from MAIL_SCHEME directly, or from the URL's own scheme via the same ConfigurationUrlParser Laravel uses) and only fall back to the port heuristic when nothing was actually stated - which is also what keeps this machine's invalid MAIL_SCHEME=tls from silently steering the result.feat/mailboxes
parent
74ac406fc0
commit
40a6b3269d
|
|
@ -121,6 +121,50 @@ return new class extends Migration
|
||||||
return array_merge($config, (new ConfigurationUrlParser)->parseConfiguration($config));
|
return array_merge($config, (new ConfigurationUrlParser)->parseConfiguration($config));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The scheme an operator actually stated, or null if nobody did.
|
||||||
|
*
|
||||||
|
* Two different fields can carry this, and they are not interchangeable:
|
||||||
|
*
|
||||||
|
* - $smtp['scheme'] is config/mail.php's 'scheme' => env('MAIL_SCHEME'),
|
||||||
|
* Laravel's own single-purpose encryption knob. createSmtpTransport()
|
||||||
|
* reads it directly (MailManager.php:196) with no other meaning
|
||||||
|
* possible — an operator who set it MEANT it.
|
||||||
|
* - $smtp['driver'] exists only after resolveSmtpConfig() has run the URL
|
||||||
|
* through ConfigurationUrlParser, and only when MAIL_URL was set. It is
|
||||||
|
* NOT the same slot: getPrimaryOptions() maps a URL's scheme into
|
||||||
|
* 'driver' (verified directly against the installed class), because
|
||||||
|
* Laravel's MailManager::getConfig() reuses that same field to pick
|
||||||
|
* WHICH TRANSPORT to build ($config['transport'] = Arr::pull($config,
|
||||||
|
* 'driver')) — the URL scheme is doing double duty as a transport
|
||||||
|
* selector, not purely an encryption statement.
|
||||||
|
*
|
||||||
|
* That difference is why 'smtps' is trusted from either field but 'smtp'
|
||||||
|
* only from the first: 'smtps://' in a MAIL_URL is unambiguous — nothing
|
||||||
|
* else lets an operator say implicit TLS through a URL alone. A plain
|
||||||
|
* 'smtp://' is simply the ordinary, only real-world way to write this
|
||||||
|
* mailer's URL and says nothing about TLS either way; the port heuristic
|
||||||
|
* below already carries that intent, so it is deliberately NOT promoted
|
||||||
|
* to "none" here the way an explicit MAIL_SCHEME=smtp is.
|
||||||
|
*
|
||||||
|
* Anything else — including this machine's own MAIL_SCHEME=tls, which
|
||||||
|
* Symfony's EsmtpTransportFactory rejects outright (it supports only
|
||||||
|
* 'smtp' and 'smtps', see getSupportedSchemes()) — is not a real scheme
|
||||||
|
* and must not silently steer the result toward either 'ssl' or 'none'.
|
||||||
|
* Returning null for it here is what sends seed() to the port heuristic
|
||||||
|
* instead, the same place a genuinely absent MAIL_SCHEME already goes.
|
||||||
|
*/
|
||||||
|
private function resolveScheme(array $smtp): ?string
|
||||||
|
{
|
||||||
|
$scheme = $smtp['scheme'] ?? null;
|
||||||
|
|
||||||
|
if (in_array($scheme, ['smtp', 'smtps'], true)) {
|
||||||
|
return $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ($smtp['driver'] ?? null) === 'smtps' ? 'smtps' : null;
|
||||||
|
}
|
||||||
|
|
||||||
private function seed(): void
|
private function seed(): void
|
||||||
{
|
{
|
||||||
$smtp = $this->resolveSmtpConfig();
|
$smtp = $this->resolveSmtpConfig();
|
||||||
|
|
@ -135,15 +179,22 @@ return new class extends Migration
|
||||||
$port = $configuredPort === self::UNCONFIGURED_PORT ? 0 : $configuredPort;
|
$port = $configuredPort === self::UNCONFIGURED_PORT ? 0 : $configuredPort;
|
||||||
Settings::set('mail.port', $port);
|
Settings::set('mail.port', $port);
|
||||||
|
|
||||||
// NOT copied from MAIL_SCHEME. That variable currently reads "tls",
|
// Codex R15#4, P1a: a scheme actually STATED (MAIL_SCHEME=smtps, or an
|
||||||
// which is not a Symfony mailer scheme — Symfony knows smtp and smtps,
|
// smtps:// MAIL_URL) must be believed over the port — an implicit-TLS
|
||||||
// and Laravel 13's MailManager passes scheme straight through with no
|
// relay on a non-465 port must not be downgraded to STARTTLS just
|
||||||
// encryption fallback. It has never failed only because MAIL_MAILER is
|
// because this migration guessed from the port instead of the scheme
|
||||||
// log, so nothing has opened a real connection.
|
// that was already sitting there. Only when nobody stated one
|
||||||
//
|
// (resolveScheme() returns null — including for this machine's own
|
||||||
// Stored as a human-facing choice instead, translated to a scheme where
|
// invalid MAIL_SCHEME=tls, which is not a real Symfony scheme under
|
||||||
// it is used: 465 means implicit TLS, everything else means STARTTLS.
|
// either spelling) does the port heuristic from before still apply:
|
||||||
Settings::set('mail.encryption', $port === 465 ? 'ssl' : 'tls');
|
// 465 means implicit TLS, everything else means STARTTLS.
|
||||||
|
$scheme = $this->resolveScheme($smtp);
|
||||||
|
|
||||||
|
Settings::set('mail.encryption', match ($scheme) {
|
||||||
|
'smtps' => 'ssl',
|
||||||
|
'smtp' => 'none',
|
||||||
|
default => $port === 465 ? 'ssl' : 'tls',
|
||||||
|
});
|
||||||
|
|
||||||
$configuredUser = (string) ($smtp['username'] ?? '');
|
$configuredUser = (string) ($smtp['username'] ?? '');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,103 @@ it('derives ssl from port 465, regardless of what scheme is configured', functio
|
||||||
->and(Settings::get('mail.port'))->toBe(465);
|
->and(Settings::get('mail.port'))->toBe(465);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps implicit TLS when MAIL_SCHEME is smtps on a non-standard port — Codex R15#4, P1a', function () {
|
||||||
|
// The finding, first half: an operator who already has MAIL_SCHEME=smtps
|
||||||
|
// (Laravel's own dedicated, unambiguous encryption knob) must not have
|
||||||
|
// that downgraded to STARTTLS just because their submission port is not
|
||||||
|
// 465 — a relay speaking implicit TLS on, say, 587 stops accepting mail
|
||||||
|
// the moment this migration replaces a working config with a guess.
|
||||||
|
clearMailboxSeed();
|
||||||
|
config()->set('mail.mailers.smtp.scheme', 'smtps');
|
||||||
|
config()->set('mail.mailers.smtp.port', 587);
|
||||||
|
|
||||||
|
loadMailboxSeedMigration()->up();
|
||||||
|
|
||||||
|
expect(Settings::get('mail.encryption'))->toBe('ssl');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps implicit TLS when MAIL_URL uses the smtps scheme on a non-standard port', function () {
|
||||||
|
// The finding, second half: the same relay, described through MAIL_URL
|
||||||
|
// instead of MAIL_SCHEME. 'smtps' in the URL is resolved through the
|
||||||
|
// same Illuminate\Support\ConfigurationUrlParser Laravel itself uses —
|
||||||
|
// it surfaces on the merged config as the 'driver' key (parseConfiguration
|
||||||
|
// maps a URL scheme into 'driver', not 'scheme' — verified directly
|
||||||
|
// against the installed class), not re-derived by hand from the URL
|
||||||
|
// string a second time.
|
||||||
|
clearMailboxSeed();
|
||||||
|
config()->set('mail.mailers.smtp.scheme', null);
|
||||||
|
config()->set('mail.mailers.smtp.url', 'smtps://user:pass@smtp.clupilot.test:587');
|
||||||
|
|
||||||
|
loadMailboxSeedMigration()->up();
|
||||||
|
|
||||||
|
expect(Settings::get('mail.encryption'))->toBe('ssl');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats the invalid MAIL_SCHEME=tls value this machine\'s own .env carries as no scheme at all', function () {
|
||||||
|
// This container's real .env has exactly this value. Symfony's
|
||||||
|
// EsmtpTransportFactory::getSupportedSchemes() only knows 'smtp' and
|
||||||
|
// 'smtps' — 'tls' is not a real scheme under either spelling, so it must
|
||||||
|
// fall through to the port heuristic (587 -> the ENCRYPTION CHOICE
|
||||||
|
// named 'tls', a coincidence of spelling, not the scheme) exactly like
|
||||||
|
// no MAIL_SCHEME at all — reproducing this installation's actual
|
||||||
|
// behaviour unchanged. The point: an unrecognised scheme string must not
|
||||||
|
// silently steer the result toward 'ssl' or 'none' either.
|
||||||
|
clearMailboxSeed();
|
||||||
|
config()->set('mail.mailers.smtp.scheme', 'tls');
|
||||||
|
config()->set('mail.mailers.smtp.port', 587);
|
||||||
|
|
||||||
|
loadMailboxSeedMigration()->up();
|
||||||
|
|
||||||
|
expect(Settings::get('mail.encryption'))->toBe('tls');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('derives none from an explicit smtp MAIL_SCHEME, even on port 465', function () {
|
||||||
|
// The other explicit direction: MAIL_SCHEME=smtp is just as deliberate a
|
||||||
|
// statement as smtps, and must win over the port heuristic the same way
|
||||||
|
// — an operator who said "plain smtp" was never asking for implicit TLS
|
||||||
|
// just because their port happens to be 465.
|
||||||
|
clearMailboxSeed();
|
||||||
|
config()->set('mail.mailers.smtp.scheme', 'smtp');
|
||||||
|
config()->set('mail.mailers.smtp.port', 465);
|
||||||
|
|
||||||
|
loadMailboxSeedMigration()->up();
|
||||||
|
|
||||||
|
expect(Settings::get('mail.encryption'))->toBe('none');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not treat a plain smtp:// MAIL_URL scheme as a stated encryption choice', function () {
|
||||||
|
// Asymmetric with 'smtps://' on purpose: the URL's scheme slot is doing
|
||||||
|
// double duty in Laravel's own MailManager (it is also how "which
|
||||||
|
// transport" gets chosen) and 'smtp://' is the ordinary, only
|
||||||
|
// real-world spelling for this mailer's URL — not a deliberate "no
|
||||||
|
// encryption" statement. Only 'smtps://' is unambiguous enough to
|
||||||
|
// promote; 'smtp://' is left to the port heuristic below, same as no
|
||||||
|
// scheme at all — proven here by still reaching 'ssl' on port 465
|
||||||
|
// despite the explicit smtp:// scheme in the URL.
|
||||||
|
clearMailboxSeed();
|
||||||
|
config()->set('mail.mailers.smtp.scheme', null);
|
||||||
|
config()->set('mail.mailers.smtp.url', 'smtp://user:pass@smtp.clupilot.test:465');
|
||||||
|
|
||||||
|
loadMailboxSeedMigration()->up();
|
||||||
|
|
||||||
|
expect(Settings::get('mail.encryption'))->toBe('ssl');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prefers an explicit MAIL_SCHEME over a conflicting MAIL_URL scheme', function () {
|
||||||
|
// MAIL_SCHEME is Laravel's single-purpose encryption knob; the URL's
|
||||||
|
// scheme is doing double duty (see above). When both are present and
|
||||||
|
// disagree, the unambiguous one wins — matching createSmtpTransport()'s
|
||||||
|
// own precedence ($config['scheme'] ?? <port fallback>, checked before
|
||||||
|
// anything born from the URL even enters the picture).
|
||||||
|
clearMailboxSeed();
|
||||||
|
config()->set('mail.mailers.smtp.scheme', 'smtp');
|
||||||
|
config()->set('mail.mailers.smtp.url', 'smtps://user:pass@smtp.clupilot.test:587');
|
||||||
|
|
||||||
|
loadMailboxSeedMigration()->up();
|
||||||
|
|
||||||
|
expect(Settings::get('mail.encryption'))->toBe('none');
|
||||||
|
});
|
||||||
|
|
||||||
it('treats config/mail.php\'s own 127.0.0.1 / 2525 defaults as unset, not as a real relay', function () {
|
it('treats config/mail.php\'s own 127.0.0.1 / 2525 defaults as unset, not as a real relay', function () {
|
||||||
clearMailboxSeed();
|
clearMailboxSeed();
|
||||||
config()->set('mail.mailers.smtp.host', '127.0.0.1');
|
config()->set('mail.mailers.smtp.host', '127.0.0.1');
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue