Files
denkfabrik-li 9af0d643b1 Keep the mail and storage credentials out of the boot-config cache
MailConfigApplier and ExternalStorageConfigApplier read their settings
through the `encrypted` casts -- decrypted -- and wrote the result into
the cache store with rememberForever(). The SMTP password, the S3 secret
access key and the whole GCS service account key file, private key
included, went in as plain text under a key that never expires.

The cache store encrypts nothing. On the store INSTALL.md documents for a
manual install (CACHE_STORE=database) and config/cache.php defaults to,
that is the `cache` table of the same database whose dump the `encrypted`
cast exists to survive. On redis it is the redis dump.

The rule already exists, two files away. MailOAuthConnection states it:

  Transports read this row fresh at send time -- tokens must never travel
  through the boot-config cache (see MailConfigApplier, which caches only
  readiness and the account address).

MailConfigApplier's own cache-key comment says the same thing about the
same array: what is deliberately NOT in the cached shape is tokens,
because neither readiness nor an address is a credential. The SMTP
password was in it anyway. SocialSettings::available() names both classes
outright as making the mistake.

So the credentials are read the way the tokens already are: from the row,
at the point that uses them. The cached array keeps everything that is
not a credential, and each applier reads its secret inside the branch
that configures a transport -- an installation on OAuth, on cloud, or one
that has never opened the Email or Storage screen reads nothing extra.

BootSettingsCache grows a second entry point rather than the callers
restating its rule. The cached read already survives a database with no
tables, because booting must not require this application's own database;
an uncached credential read on the same path needs exactly that guarantee
and nothing else, since resolve() can hand back a warm "configured" from
a database that has since stopped answering.

Both cache keys are bumped, as their comments require on a shape change.

Tests: five for the absence, two of them against the database cache store
read as the raw rows an operator would find in a dump, since phpunit.xml
runs the suite on the array store and the cache path was structurally
invisible -- which is why GoogleCloudStorageTest could assert that the private
key is not in the column while it sat in the cache. All five were run
against the unfixed appliers and fail there. The three "still configures
what it no longer caches" tests deliberately pass either way: they pin the
behaviour the fix must not break.
2026-08-28 23:46:19 +02:00

169 lines
7.7 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Modules\Platform\Settings;
use App\Modules\Platform\Capabilities\Capability;
use App\Modules\Platform\Capabilities\CapabilityRegistry;
use App\Modules\Platform\Mail\MailOAuthConnection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Schema;
/**
* Overrides Laravel's mail config with the admin-configured
* MailProviderSettings row, when one exists — otherwise leaves
* config/mail.php + .env completely untouched (fresh installs, or any
* install that hasn't visited the Email settings page yet, behave
* exactly as before this feature existed).
*
* Transport (host/port/username/password/encryption) and sender identity
* (from address/name) are gated independently: transport only applies
* under Capability::EmailTransportConfigure (community edition — cloud
* operates its own relay and must never honor a stored host, even a
* stray one), while sender identity always applies in both editions —
* hosted customers still set their own From/reply-to.
*
* Called on every process boot (PlatformServiceProvider::boot(), so both
* web requests and a freshly (re)started queue worker pick it up) and
* once more immediately after a save, so the same request already
* reflects the new values (e.g. before clicking "Send test email").
*/
class MailConfigApplier
{
// Bumped (was 'platform.mail_provider_settings') when the resolved
// array's shape changed (transport/identity split) — a stale
// rememberForever value under the old key would otherwise crash every
// boot with "Undefined array key" (PlatformServiceProvider::boot()
// calls apply() unconditionally). Bump again on any future shape change.
// v3: OAuth provider fields added. Note what is deliberately NOT in
// the cached shape: tokens. Transports read those fresh from the
// connection row at send time — only readiness and the connected
// address are cheap enough to be worth caching, and neither is a
// credential.
// v4: the SMTP password left for the same reason the tokens never
// arrived. The cache store encrypts nothing and rememberForever never
// expires, so on the documented CACHE_STORE=database it wrote the
// password in clear into the same database whose dump the `encrypted`
// cast exists to survive. It is now read straight from the row, and
// only on the boot that actually configures an SMTP transport.
private const CACHE_KEY = 'platform.mail_provider_settings.v4';
public function __construct(
private readonly CapabilityRegistry $capabilities,
) {}
public function apply(): void
{
$resolved = $this->resolve();
if ($resolved['oauth_mailer'] !== null && $resolved['oauth_ready'] && $this->capabilities->has(Capability::EmailTransportConfigure)) {
Config::set('mail.default', $resolved['oauth_mailer']);
// Delegated Graph/Gmail can only send as the mailbox that
// consented, so the From address is pinned to it — a stored
// from_address from an earlier SMTP setup must not survive
// into a mode where the vendor would reject it (SendAsDenied).
if ($resolved['oauth_account'] !== null) {
Config::set('mail.from.address', $resolved['oauth_account']);
}
} elseif ($resolved['transport_configured'] && $this->capabilities->has(Capability::EmailTransportConfigure)) {
Config::set('mail.default', 'smtp');
Config::set('mail.mailers.smtp.host', $resolved['host']);
Config::set('mail.mailers.smtp.port', $resolved['port']);
Config::set('mail.mailers.smtp.username', $resolved['username']);
Config::set('mail.mailers.smtp.password', $this->password());
Config::set('mail.mailers.smtp.encryption', $resolved['encryption']);
}
if ($resolved['from_address'] !== null && ($resolved['oauth_mailer'] === null || ! $resolved['oauth_ready'])) {
Config::set('mail.from.address', $resolved['from_address']);
}
if ($resolved['from_name'] !== null) {
Config::set('mail.from.name', $resolved['from_name']);
}
}
public function flush(): void
{
Cache::forget(self::CACHE_KEY);
}
/**
* The SMTP password, read from the row rather than from the cache.
*
* The same rule MailOAuthConnection states for tokens — "must never
* travel through the boot-config cache" — applied to the credential
* this class configures itself. Reached only from the SMTP branch of
* apply(), so an installation using OAuth, or one that has never
* opened the Email screen, still boots without touching the table.
*
* Guarded like the cached read beside it: resolve() can hand back a
* warm "transport_configured" from a database that has since stopped
* answering, and booting must survive that.
*/
private function password(): ?string
{
return BootSettingsCache::read(
fn (): ?string => MailProviderSettings::current()->password,
);
}
/**
* @return array{transport_configured: bool, host: string|null, port: int|null, username: string|null, encryption: string|null, from_address: string|null, from_name: string|null, oauth_mailer: string|null, oauth_ready: bool, oauth_account: string|null}
*/
private function resolve(): array
{
$blank = [
'transport_configured' => false,
'host' => null, 'port' => null, 'username' => null, 'encryption' => null,
'from_address' => null, 'from_name' => null,
'oauth_mailer' => null, 'oauth_ready' => false, 'oauth_account' => null,
];
// Through BootSettingsCache, not Cache directly: this runs on every
// process boot, including the artisan commands that install the
// application, and must survive a database that has no tables yet
// (or none at all). See that class for the full story.
return BootSettingsCache::rememberForever(self::CACHE_KEY, function () use ($blank): array {
if (! Schema::hasTable('mail_provider_settings')) {
return $blank;
}
$settings = MailProviderSettings::current();
$hasHost = $settings->host !== null && $settings->host !== '';
$oauthMailer = null;
$oauthReady = false;
$oauthAccount = null;
// The table guard covers an install mid-upgrade, where this
// migration has not run yet but the settings row already
// names an OAuth provider (it can't, but a guard beats a
// boot-killing query on the ordering assumption).
if ($settings->provider->isOAuth() && Schema::hasTable('mail_oauth_connections')) {
$connection = MailOAuthConnection::for($settings->provider);
$oauthMailer = $settings->provider->oauthMailer();
$oauthReady = $connection->usable();
$oauthAccount = $connection->account_email;
}
return [
'transport_configured' => $hasHost && ! $settings->provider->isOAuth(),
'host' => $settings->host,
'port' => $settings->port,
'username' => $settings->username,
'encryption' => $settings->encryption === 'none' ? null : $settings->encryption,
'from_address' => $settings->from_address,
'from_name' => $settings->from_name,
'oauth_mailer' => $oauthMailer,
'oauth_ready' => $oauthReady,
'oauth_account' => $oauthAccount,
];
}, $blank);
}
}