fix(security): make the SMTP password write-only, never sent to the browser (BUNYIP-432) #432
Loading…
Reference in a new issue
No description provided.
Delete branch "fix/BUNYIP-432-smtp-password-write-only"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
What and why
The admin email settings response carried
smtp_password_masked, built with the sharedmask_secrethelper, which reveals the last four characters of the real password (sk...-style prefix too). The server decrypted the stored SMTP password and sent a truncated form of it to the client on every page load, so the secret left the server and was partly recoverable from the page payload or a proxy. This is the PMS-342 defect class for a different credential.Fix
The field is now write-only:
has_smtp_password. The password field (smtp_password_masked) is removed from both the domainEmailConfigResponseand the web mirror, so no plaintext and no masked/last-4 form is serialized - it is structurally impossible to leak from this endpoint now.••••••••, a constant independent of the real length) driven solely byhas_smtp_password, so nothing about the secret is inferable from the display. When no password is set it shows a distinct non-secret placeholder.Storage review (acceptance criterion 4)
The SMTP password is already encrypted at rest with AES-256-GCM via the shared
EncryptionKeySet(smtp_passwordciphertext +smtp_password_nonce+key_version;EmailConfig::from_db_rowdecrypts, the update handler encrypts). The LC-164 approach is already in place - it is not recoverable in plaintext from the database or config, so no storage migration is needed. Only the read path was leaking it.Other secrets on the surface (acceptance criterion 5)
The email settings page has exactly one secret, the SMTP password, now write-only. No other secrets are on that surface.
Cross-surface finding (out of scope, flagged for follow-up): the same
mask_secret(last-4) helper is used by the Stripe config response (secret_key_masked/webhook_secret_masked) on the /admin/stripe surface, so those keys are still returned in the last-4 masked form. That surface is owned by PMS-342 (marked Done with the last-4 mask). Under this ticket's stricter write-only posture the same treatment would apply; recommend a follow-up rather than expanding this PR into the Stripe UI.Acceptance criteria
Verification
just check-containergreen (173 web tests, api handler tests, 312 domain; clippy-D warnings, fmt). New tests: the API email-config response body carries no password (the onlypasswordtoken ishas_smtp_password;smtp_password_maskedabsent), and the web password field renders the fixed-length mask when set and a non-secret placeholder when not. The guarantee is structural - the field no longer exists on either response type, so no endpoint can serialize it. I also confirmed no other endpoint returns the password: the test-connection response (BUNYIP-433) and the email-update audit (password_changedboolean) never carry it.🤖 Generated with Claude Code
https://claude.ai/code/session_018TXaT3P192nDsZzbzHETb9