Close the data-loss paths the review found in the mailbox takeover

The vault's mail.password row is no longer deleted by this migration at
all, in either direction: deleting it unconditionally destroyed a real
password whenever MAIL_USERNAME was empty (nowhere to carry it to), and
even on a successful carry-over a later rollback had nothing left to fall
back to. Reproduced both against real MariaDB, along with a migrations-
table drift that crashed a retry on the mailboxes.key unique constraint —
fixed with Mailbox::firstOrNew() and a transaction around each direction.
down() now goes through the new Settings::forget() instead of a raw
delete, so it stops leaving the settings cache holding what it just
removed. A skipped .env password (SECRETS_KEY still unset) now prints an
operator-visible line instead of migrating clean and failing silently
later. Host/port that resolve to config/mail.php's own placeholder
defaults are treated as unset rather than as a real relay.
feat/mailboxes
nexxo 2026-07-28 01:15:50 +02:00
parent 3e4d5472a7
commit a12ab148b3
4 changed files with 349 additions and 16 deletions

View File

@ -64,4 +64,22 @@ final class Settings
{ {
return (bool) self::get($key, $default); return (bool) self::get($key, $default);
} }
/**
* Remove a setting entirely, falling back to the caller's own default
* from then on.
*
* Not `set($key, null)`: that would still leave a row (and a cache
* entry) behind indistinguishable to `get()`, but a migration's
* `down()` undoing its own `up()` should leave no row, not a null one.
* Deletes the row before dropping the cache, the same order `set()`
* writes-then-invalidates in a reader between the two statements sees
* the OLD value from a cache that is about to be dropped, never a stale
* one that outlives the row it described.
*/
public static function forget(string $key): void
{
DB::table('app_settings')->where('key', $key)->delete();
Cache::forget(self::CACHE_PREFIX.$key);
}
} }

View File

@ -13,6 +13,13 @@ use Illuminate\Support\Facades\DB;
* *
* Empty rather than absent: a page that lists what is expected tells the owner * Empty rather than absent: a page that lists what is expected tells the owner
* what is still missing. An empty list would only say "nothing here". * what is still missing. An empty list would only say "nothing here".
*
* Safe to run more than once: `Mailbox::firstOrNew()` rather than `new
* Mailbox()`, because a migration that fails partway leaves the migrations
* table not recording it, and a retry must update the rows already written
* rather than collide with them on the unique key. `up()` and `down()` are
* each wrapped in their own transaction for the same reason MariaDB does
* not get one automatically the way Postgres does.
*/ */
return new class extends Migration return new class extends Migration
{ {
@ -25,11 +32,49 @@ return new class extends Migration
'info' => ['no_reply' => false, 'purposes' => []], 'info' => ['no_reply' => false, 'purposes' => []],
]; ];
/**
* config/mail.php's own fallbacks when MAIL_HOST/MAIL_PORT are unset.
*
* config('mail.mailers.smtp.host') cannot tell "genuinely unset" apart
* from "deliberately set to 127.0.0.1" config/mail.php's own
* env('MAIL_HOST', '127.0.0.1') has already substituted by the time this
* migration reads it, and 127.0.0.1 is not '', so it would sail straight
* past MailboxTransport's "host is not configured" guard (Task 4) and
* quietly dial localhost instead of refusing loudly.
*
* Read raw env() instead? That returns null (correctly distinguishing
* "unset") ONLY as long as .env was actually loaded which Laravel skips
* entirely once `config:cache` has run (LoadEnvironmentVariables checks
* configurationIsCached() first). deploy/update.sh always migrates
* before it rebuilds the cache, but nothing enforces that order, and
* env() is also awkward to test (it does not go through config(), so
* Pest's usual config()->set() cannot drive it). Comparing against the
* placeholder keeps this correct under config:cache regardless of
* ordering, and testable the same way every other test in this suite
* already is. Trade-off: an install that deliberately runs its relay on
* 127.0.0.1:2525 reads as unset nobody does that for outbound mail.
*/
private const UNCONFIGURED_HOST = '127.0.0.1';
private const UNCONFIGURED_PORT = 2525;
public function up(): void public function up(): void
{ {
// The server, once. Whatever the .env already proved workable. DB::transaction(function () {
Settings::set('mail.host', (string) config('mail.mailers.smtp.host', '')); $this->seed();
$port = (int) config('mail.mailers.smtp.port', 587); });
}
private function seed(): void
{
// The server, once. Whatever .env already proved workable — but not
// config/mail.php's own placeholder (see UNCONFIGURED_HOST above).
$configuredHost = (string) config('mail.mailers.smtp.host', '');
$host = $configuredHost === self::UNCONFIGURED_HOST ? '' : $configuredHost;
Settings::set('mail.host', $host);
$configuredPort = (int) config('mail.mailers.smtp.port', 0);
$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", // NOT copied from MAIL_SCHEME. That variable currently reads "tls",
@ -47,8 +92,9 @@ return new class extends Migration
? substr($configuredUser, strpos($configuredUser, '@') + 1) ? substr($configuredUser, strpos($configuredUser, '@') + 1)
: 'clupilot.com'; : 'clupilot.com';
// A password may already be stored in the vault; it moves rather than // A password may already be stored in the vault. It is CARRIED, not
// being left behind in a registry entry that is about to disappear. // moved: the app_secrets row is deliberately left in place below
// rather than deleted, so this block only ever reads it.
$storedPassword = DB::table('app_secrets')->where('key', 'mail.password')->value('value'); $storedPassword = DB::table('app_secrets')->where('key', 'mail.password')->value('value');
$envPassword = (string) config('mail.mailers.smtp.password', ''); $envPassword = (string) config('mail.mailers.smtp.password', '');
@ -63,8 +109,8 @@ return new class extends Migration
foreach (self::SEED as $key => $meta) { foreach (self::SEED as $key => $meta) {
$isConfigured = $key === 'no-reply' && $configuredUser !== ''; $isConfigured = $key === 'no-reply' && $configuredUser !== '';
$box = new Mailbox([ $box = Mailbox::firstOrNew(['key' => $key]);
'key' => $key, $box->fill([
'address' => $isConfigured ? $configuredUser : $key.'@'.$domain, 'address' => $isConfigured ? $configuredUser : $key.'@'.$domain,
'display_name' => 'CluPilot', 'display_name' => 'CluPilot',
'no_reply' => $meta['no_reply'], 'no_reply' => $meta['no_reply'],
@ -72,8 +118,27 @@ return new class extends Migration
]); ]);
// Only the account that actually exists gets credentials. // Only the account that actually exists gets credentials.
if ($isConfigured && $envPassword !== '' && $canEncrypt) { if ($isConfigured && $envPassword !== '') {
if ($canEncrypt) {
$box->password = $envPassword; $box->password = $envPassword;
} else {
// Skipping beats crashing the migration (that is what
// $canEncrypt guards above) — but a skip that LOOKS like
// success is its own failure mode: migrate prints DONE,
// MAIL_MAILER is still log today, and the first real send
// after it flips to smtp throws for every purpose,
// discovered by a customer rather than by this line.
// There is no console page for mailboxes yet, so stdout
// during migrate is the only place an operator can see
// this. echo, not fwrite(STDOUT, ...): both reach the
// real terminal on a plain `php artisan migrate`, but
// only echo goes through PHP's output buffer, which is
// what makes it possible to assert on in a test.
echo ' ! SECRETS_KEY is not set - the SMTP password configured in .env for '
.$configuredUser.' was NOT copied into the mailbox table. Generate SECRETS_KEY, '
.'then either set mailboxes.password directly or migrate:rollback and re-run '
."this migration.\n";
}
} }
$box->save(); $box->save();
@ -89,17 +154,45 @@ return new class extends Migration
} }
} }
DB::table('app_secrets')->where('key', 'mail.password')->delete(); // mail.password's app_secrets row is deliberately left alone here —
// not deleted. Its SecretVault::REGISTRY entry is gone (this task's
// other half), so nothing can read or write it through the vault
// anymore, but destroying the only remaining copy of a real
// credential in the SAME migration that first introduces its new,
// less-proven home is backwards for what a stored SMTP password is.
// Two ways that unconditional delete used to lose it for real,
// reproduced against MariaDB (see the task report):
// - MAIL_USERNAME empty makes $isConfigured false for every key
// above, so the carry-over above never runs — deleting here
// regardless would destroy a rotated password with no copy left
// anywhere.
// - migrate, then migrate:rollback, then migrate again: down()
// deletes the mailbox rows, which would be the ONLY remaining
// copy once this row was already gone.
// Left in place, a rollback can never lose the credential — at worst
// it is an inert leftover row nothing can reach through
// SecretVault::assertKnown() any more. A later migration can remove
// it once the mailbox takeover has been proven on a live install.
} }
public function down(): void public function down(): void
{ {
DB::transaction(function () {
DB::table('mailboxes')->whereIn('key', array_keys(self::SEED))->delete(); DB::table('mailboxes')->whereIn('key', array_keys(self::SEED))->delete();
// Settings::forget(), not a raw DB::table(...)->delete(): the
// cache is Cache::forever()'d with no TTL, so bypassing it here
// would leave Settings::get() returning what this loop just
// deleted until something else happens to write the same key.
// Reproduced: immediately after a raw-delete rollback,
// Settings::get('mail.purpose.system') still answered 'no-reply'.
foreach (MailPurpose::ALL as $purpose) { foreach (MailPurpose::ALL as $purpose) {
DB::table('app_settings')->where('key', MailPurpose::settingKey($purpose))->delete(); Settings::forget(MailPurpose::settingKey($purpose));
} }
DB::table('app_settings')->whereIn('key', ['mail.host', 'mail.port', 'mail.encryption'])->delete(); foreach (['mail.host', 'mail.port', 'mail.encryption'] as $key) {
Settings::forget($key);
}
});
} }
}; };

View File

@ -0,0 +1,216 @@
<?php
use App\Models\Mailbox;
use App\Services\Secrets\SecretCipher;
use App\Support\Settings;
use Illuminate\Support\Facades\DB;
/**
* Loads a fresh instance of the seed migration's anonymous class every call.
* `require`, not `require_once`: PHP re-evaluates the `return new class ...`
* expression each time, so every call gets its own instance this is what
* lets a test call ->up() with config the REAL migrate run (already applied
* once by RefreshDatabase before any test's own beforeEach) could not have
* used, to reach branches nothing else exercises.
*/
function loadMailboxSeedMigration(): object
{
return require database_path('migrations/2026_07_28_100000_seed_mailboxes_from_environment.php');
}
beforeEach(function () {
config()->set('admin_access.secrets_key', 'base64:'.base64_encode(random_bytes(32)));
});
it('does not copy MAIL_SCHEME through — a Symfony transport only knows smtp and smtps', function () {
clearMailboxSeed();
config()->set('mail.mailers.smtp.scheme', 'not-a-symfony-scheme');
config()->set('mail.mailers.smtp.port', 587);
loadMailboxSeedMigration()->up();
expect(Settings::get('mail.encryption'))->toBe('tls');
});
it('derives ssl from port 465, regardless of what scheme is configured', function () {
clearMailboxSeed();
config()->set('mail.mailers.smtp.scheme', 'irrelevant');
config()->set('mail.mailers.smtp.port', 465);
loadMailboxSeedMigration()->up();
expect(Settings::get('mail.encryption'))->toBe('ssl')
->and(Settings::get('mail.port'))->toBe(465);
});
it('treats config/mail.php\'s own 127.0.0.1 / 2525 defaults as unset, not as a real relay', function () {
clearMailboxSeed();
config()->set('mail.mailers.smtp.host', '127.0.0.1');
config()->set('mail.mailers.smtp.port', 2525);
loadMailboxSeedMigration()->up();
// '' / 0 is exactly what MailboxTransport's guards treat as "not
// configured" — 127.0.0.1 is not '', so it would have sailed straight
// past the Task 4 guard and quietly dialled localhost instead.
expect(Settings::get('mail.host'))->toBe('')
->and(Settings::get('mail.port'))->toBe(0);
});
it('still stores a deliberately configured host and port untouched', function () {
clearMailboxSeed();
config()->set('mail.mailers.smtp.host', 'smtp.example.test');
config()->set('mail.mailers.smtp.port', 587);
loadMailboxSeedMigration()->up();
expect(Settings::get('mail.host'))->toBe('smtp.example.test')
->and(Settings::get('mail.port'))->toBe(587);
});
it('carries the vault password across as ciphertext, never decrypting and re-encrypting it', function () {
clearMailboxSeed();
config()->set('mail.mailers.smtp.username', 'no-reply@clupilot.com');
$ciphertext = app(SecretCipher::class)->encrypt('rotated-through-the-console');
DB::table('app_secrets')->insert([
'key' => 'mail.password', 'value' => $ciphertext, 'created_at' => now(), 'updated_at' => now(),
]);
loadMailboxSeedMigration()->up();
$stored = DB::table('mailboxes')->where('key', 'no-reply')->value('password');
// Byte-for-byte, not just "decrypts to the same plaintext": AES-CBC's
// random IV means re-encrypting the SAME plaintext produces a DIFFERENT
// ciphertext string, so this is what actually discriminates "carried
// as-is" from "decrypted and re-encrypted" — the migration's own claim,
// never exercised by any test before this one.
expect($stored)->toBe($ciphertext)
->and(app(SecretCipher::class)->decrypt($stored))->toBe('rotated-through-the-console');
});
it('leaves the vault row in the database even after a normal, fully successful carry-over', function () {
clearMailboxSeed();
config()->set('mail.mailers.smtp.username', 'no-reply@clupilot.com');
$ciphertext = app(SecretCipher::class)->encrypt('rotated-through-the-console');
DB::table('app_secrets')->insert([
'key' => 'mail.password', 'value' => $ciphertext, 'created_at' => now(), 'updated_at' => now(),
]);
loadMailboxSeedMigration()->up();
expect(DB::table('app_secrets')->where('key', 'mail.password')->value('value'))->toBe($ciphertext);
});
it('does not destroy a stored vault password when MAIL_USERNAME is empty, because it never lands anywhere', function () {
// Reproduces the Critical finding: $isConfigured is false for every key
// when MAIL_USERNAME is empty, so the carry-over never runs — the old
// code deleted the vault row unconditionally regardless.
clearMailboxSeed();
config()->set('mail.mailers.smtp.username', '');
$ciphertext = app(SecretCipher::class)->encrypt('nowhere-to-go');
DB::table('app_secrets')->insert([
'key' => 'mail.password', 'value' => $ciphertext, 'created_at' => now(), 'updated_at' => now(),
]);
loadMailboxSeedMigration()->up();
expect(DB::table('app_secrets')->where('key', 'mail.password')->value('value'))->toBe($ciphertext)
->and(Mailbox::findByKey('no-reply')->getRawOriginal('password'))->toBeNull();
});
it('survives a migrate, rollback, migrate-again cycle without losing the credential', function () {
// Reproduces the other Critical finding: down() deletes the mailbox
// rows, which is where the ONLY copy of the password would end up if the
// vault row had been deleted (the old behaviour) during the first up().
clearMailboxSeed();
config()->set('mail.mailers.smtp.username', 'no-reply@clupilot.com');
$ciphertext = app(SecretCipher::class)->encrypt('rotated-through-the-console');
DB::table('app_secrets')->insert([
'key' => 'mail.password', 'value' => $ciphertext, 'created_at' => now(), 'updated_at' => now(),
]);
$migration = loadMailboxSeedMigration();
$migration->up();
$migration->down();
$migration->up();
expect(DB::table('mailboxes')->where('key', 'no-reply')->value('password'))->toBe($ciphertext);
});
it('is safe to run twice — a migrations-table drift must not crash on the unique key', function () {
// The real baseline from RefreshDatabase's own migrate is already there
// (nothing cleared it) — calling up() again must update those rows in
// place, not attempt a second INSERT under the same key.
//
// A plain try/catch, NOT expect(fn () => ...)->not->toThrow(Throwable::class):
// verified directly that the latter is not reliable here — a closure that
// unconditionally throws still passes ->not->toThrow(Throwable::class) in
// this Pest version, which would have made this test pass whether or not
// the migration actually crashed.
$threw = null;
try {
loadMailboxSeedMigration()->up();
} catch (Throwable $e) {
$threw = $e;
}
expect($threw)->toBeNull();
expect(Mailbox::query()->pluck('key')->sort()->values()->all())
->toBe(['billing', 'info', 'no-reply', 'office', 'support']);
});
it('un-seeds on rollback, and does not leave the settings cache holding what it just deleted', function () {
// Against the real baseline again — this is what a real rollback tears
// down. Warms the cache first the same way a real request would, so a
// migration that bypassed Settings::forget() (a raw DB delete) and left
// the Cache::forever() entry behind would still read back the old value.
expect(Settings::get('mail.purpose.system'))->toBe('no-reply');
loadMailboxSeedMigration()->down();
expect(Mailbox::query()->count())->toBe(0)
->and(Settings::get('mail.purpose.system'))->toBeNull()
->and(Settings::get('mail.host'))->toBeNull();
});
it('prints an operator-visible warning when SECRETS_KEY is missing and a .env password had to be skipped', function () {
clearMailboxSeed();
config()->set('admin_access.secrets_key', ''); // canEncrypt false
config()->set('mail.mailers.smtp.username', 'no-reply@clupilot.com');
config()->set('mail.mailers.smtp.password', 'dummy-nicht-echt');
ob_start();
loadMailboxSeedMigration()->up();
$output = ob_get_clean();
expect($output)->toContain('SECRETS_KEY')
->toContain('no-reply@clupilot.com');
});
it('stays silent when there is no .env password to skip in the first place', function () {
clearMailboxSeed();
config()->set('admin_access.secrets_key', ''); // canEncrypt false
config()->set('mail.mailers.smtp.username', 'no-reply@clupilot.com');
config()->set('mail.mailers.smtp.password', '');
ob_start();
loadMailboxSeedMigration()->up();
$output = ob_get_clean();
expect($output)->toBe('');
});
it('Settings::forget removes the row and does not leave a stale cache entry behind', function () {
Settings::set('probe.key', 'value');
expect(Settings::get('probe.key'))->toBe('value');
Settings::forget('probe.key');
expect(Settings::get('probe.key'))->toBeNull()
->and(DB::table('app_settings')->where('key', 'probe.key')->exists())->toBeFalse();
});

View File

@ -16,8 +16,14 @@ it('marks no-reply as unanswerable and the rest as answerable', function () {
}); });
it('points every purpose at a mailbox, so nothing sends from nowhere', function () { it('points every purpose at a mailbox, so nothing sends from nowhere', function () {
// Not just "the setting has a value" — a purpose pointing at a key with
// no matching row would still pass a not-null check on the setting while
// MailboxResolver::named() resolves it to nothing and every send throws.
foreach (MailPurpose::ALL as $purpose) { foreach (MailPurpose::ALL as $purpose) {
expect(Settings::get(MailPurpose::settingKey($purpose)))->not->toBeNull(); $key = Settings::get(MailPurpose::settingKey($purpose));
expect($key)->not->toBeNull()
->and(Mailbox::findByKey($key))->not->toBeNull();
} }
}); });