Add comprehensive email validation for blocked users
discourse/discourse@5f8a1302Golden Comments (2/2 found)
| # | Comment | Severity | Status | FriendlyReviewer Detail |
|---|---|---|---|---|
| #1 | BlockedEmail.should_block_email? method has side effects during a read operation - it updates statistics even when just checking if an email should be blocked. This could cause race conditions in concurrent environments and makes the method name misleading. |
MEDIUM | ✓ Found | "Side-effectful DB write inside the validator. BlockedEmail.should_block?(value) mutates the database (increments match_count, sets last_match_at, saves) as a side effect of every email validation — including for action_type == :do_nothing records and when the surrounding save will later fail (e.g. invalid username), so stats are inflated by failed attempts. Compounding this, users#create runs the validator twice per request (user.valid? at users_controller.rb:168 then user.save at :172), so a single POST for a blocked address performs two DB writes, and the action is unauthenticated/CSRF-free and only rate-limited by a static honeypot" (lib/validators/email_validator.rb L13) |
| #2 | Regex pattern @(#{domains}) only matches domain suffixes, not full domains. evil.example.com would match whitelist entry example.com. |
MEDIUM | ✓ Found | "Restriction-domain matching is substring-based and unanchored: Regexp.new("@(#{domains})", true) matches @domain anywhere in the address (e.g. foo@mailinator.com.evil.org). Carried over from the old User#email_in_restriction_setting? so not a regression, but in the shared validator consider parsing the host and anchoring the match, and note the Regexp.new is rebuilt on every validation" (lib/validators/email_validator.rb L21) |
Supplementary Findings (13 findings)
| Finding | File | Severity | Legitimate? |
|---|---|---|---|
Non-atomic read-modify-write on match_count: record.match_count += 1 followed by record.save is a classic race — two concurrent requests can read the same value, both increment, and both save, silently losing increments and racing on last_match_at |
app/models/blocked_email.rb:14 | HIGH | ✓ Real: a read-modify-write on a concurrent hot path; the suggested atomic update is the standard fix |
Blast radius: the new BlockedEmail gate now runs for every save! in create_for_email flows — invite redemption and DiscourseHub account creation — so a blocked email raises ActiveRecord::RecordInvalid where it previously succeeded, and callers do not appear to rescue it |
app/models/user.rb:47 | HIGH | ✓ Real: the blocklist silently extends to flows outside self-registration, with unhandled exceptions |
record.save runs the full validation suite on every stats bump and returns false on failure without signalling it; the method then returns based on action_type regardless of write success, so statistics can be dropped silently |
app/models/blocked_email.rb:16 | MEDIUM | ✓ Real: silently dropped stats; the return value of save is ignored |
The validator runs when the email is blank/empty (form posting an empty field), producing a spurious not_allowed error on top of the presence error and issuing a BlockedEmail.where(email: nil) query |
lib/validators/email_validator.rb:21 | MEDIUM | ✓ Real: a spurious error and a pointless query on blank input |
The dedicated validator spec never exercises the whitelist/blacklist branches: both cases use bad@spamclub.com and differ only by a stubbed BlockedEmail.should_block? |
spec/components/validators/email_validator_spec.rb:9 | MEDIUM | ✓ Real: the core branches of a new validator are untested |
The blocklist gate only runs when the email changes (if: :email_changed?), so an already-registered user whose email is later added to BlockedEmail is never re-screened |
app/models/user.rb:47 | MEDIUM | ✓ Real: the gate is not retroactive, which limits the feature's stated goal of comprehensive blocking |
Rejected-email reason is lost: emailValidation returns the generic user.email.invalid regardless of why the server rejected the address, whereas the actual blocked reason is user.email.blocked = "is not allowed." (server.en.yml:734) |
app/assets/javascripts/discourse/controllers/create_account_controller.js:73 | MEDIUM | ✓ Real: the client and server disagree on the message for the same condition |
rejectedEmails sticky-blocks on ANY email error (uniqueness, not_allowed, blocked) and never clears, so non-blocked conditions pin misleading "is not valid" and the user cannot resubmit for the page session; the contains(email) guard also compares the raw case-preserved accountEmail against the possibly-normalized result.values.email, so it can silently fail to engage |
app/assets/javascripts/discourse/controllers/create_account_controller.js:274 | MEDIUM | ✓ Real: a client-side guard that both over-blocks and under-blocks depending on input casing |
Fragile last_match_at assertion: blocked_email.last_match_at.should be_within_one_second_of(Time.zone.now) only passes because the preceding change { blocked_email.reload.match_count } incidentally reloads and refreshes last_match_at on the stale fabricated instance |
spec/models/blocked_email_spec.rb:30 | LOW | ✓ Borderline: a fragile-but-passing test assertion; a test-quality nit |
The spec asserting record.errors[:email] is empty depends on the default SiteSetting.email_domains_blacklist ('mailinator.com') not matching spamclub.com |
spec/components/validators/email_validator_spec.rb:10 | LOW | ✓ Borderline: non-isolated spec that depends on default settings; legitimate test hygiene point |
Server error response shape is inconsistent: errors/values are only emitted in the else branch of user.save; ActiveRecord::StatementInvalid returns {success:false, message:...} with no errors, and RestClient::Forbidden returns errors as an array, not the hash the client expects |
app/controllers/users_controller.rb:194 | LOW | ✓ Real: inconsistent envelopes mean the client's defensive checks silently no-op for some failure branches |
No honeypot/challenge refresh after a failed submit: the controller resets formSubmitted but reuses the already-fetched accountPasswordConfirm/accountChallenge; works today only because the server challenge is static |
app/assets/javascripts/discourse/controllers/create_account_controller.js:282 | LOW | ✓ Borderline: latent — breaks only if the server ever rotates the honeypot |
The new user.email.blocked message ("is not allowed.") is semantically near-duplicate of not_allowed ("is not allowed from that email provider. Please use another email address.") and gives no guidance to the end user in the blocked case |
config/locales/server.en.yml:734 | LOW | ✓ Real: the two failure outcomes are indistinguishable to the user |
Honest Analysis
FriendlyReviewer's full review
As posted on the PR — the complete output from FriendlyReviewer.
Hi there 👋
| 🌥️ Tech | 🌤️ Feat |
|---|---|
| 3 high, 6 medium, 6 low | 1 mitigated |
This MR introduces the BlockedEmail model, a reusable EmailValidator, and client-side handling to enforce a blocklist of emails during account creation. The core server-side blocking works and locale/autoload wiring is consistent, but the review found several blocking concerns that must be addressed before merge.
Most important: BlockedEmail.should_block? is a predicate that also mutates the database (increments match_count, touches last_match_at, saves). It is invoked from inside validate_each, so every email change performs a DB write — and the unauthenticated users#create action runs the validator twice per request (once via user.valid?, once via user.save), guarded only by a static honeypot. This both inflates stats with failed attempts and opens a new resource-exhaustion/DoS vector on POST /users. The match_count += 1; save increment is also a non-atomic read-modify-write that can lose increments under concurrency; record.save additionally swallows failures. The validator should be made side-effect-free (defer tracking to an explicit non-validation path) and use an atomic single-statement update.
Contract / behavior gaps: the new blocklist gate applies to create_for_email flows (invite redemption, DiscourseHub account creation) with no rescue of RecordInvalid — confirm this is intended; and because the gate runs only when the email changes, it never applies retroactively to existing users. On the client, the rejected-email reason is lost (a generic "is not valid" is shown instead of the server's "is not allowed"), the rejectedEmails mechanism pins on any email error and never clears, and its case/normalization comparison can fail to engage. The server's error response shape is also inconsistent across failure branches.
No review task reported errors, so findings reflect the reviewed scope. Several REQUEST_CHANGES were recommended; I concur and request changes before merge.
These might need a close look
- 🔴 lib/validators/email_validator.rb (L13)
Side-effectful DB write inside the validator.BlockedEmail.should_block?(value)mutates the database (incrementsmatch_count, setslast_match_at, saves) as a side effect of every email validation — including foraction_type == :do_nothingrecords and when the surrounding save will later fail (e.g. invalid username), so stats are inflated by failed attempts. Compounding this,users#createruns the validator twice per request (user.valid?at users_controller.rb:168 thenuser.saveat :172), so a single POST for a blocked address performs two DB writes, and the action is unauthenticated/CSRF-free and only rate-limited by a static honeypot — enabling a resource-exhaustion /match_count-inflation flood vector. Splitshould_block?into a pure predicate plus an explicittrack!step not invoked during validation, and avoid evaluating it twice per create (e.g. run the save only or gate to a single call). Also confirm the write-per-check cost for:do_nothingentries (blocked_email.rb:12) is intended. - 🔴 app/models/blocked_email.rb (L14)
Non-atomic read-modify-write on match_count.record.match_count += 1followed byrecord.saveis a classic race:should_block?runs in a concurrent hot path (simultaneous signups from the same spam address), so two requests can read the same value, both increment to the same number, and both save — silently losing increments and racing onlast_match_at. Use an atomic single-statement update, e.g.BlockedEmail.where(id: record.id).update_all(['match_count = match_count + 1, last_match_at = ?', Time.zone.now]). - 🔴 app/models/user.rb (L47)
Blast radius: the new BlockedEmail gate now runs for everysave!increate_for_emailflows — invite redemption (InviteRedeemer#redeem) and DiscourseHub account creation — so a blocked email will now raiseActiveRecord::RecordInvalidwhere it previously succeeded. Confirm this is intended and that callers handle the validation failure gracefully (they do not appear to rescueRecordInvalid); if the blocklist should only gate self-registration, gate theemail: truevalidation accordingly.
Worth checking
- 🟡 app/models/blocked_email.rb (L16)
record.saveruns the full validation suite on every stats bump and returnsfalseon failure without signalling it; the method then returns based onaction_typeregardless of write success, so statistics can be dropped silently. Preferupdate_column(:match_count, ...)/update_columns(skips validations/callbacks) orrecord.save!, and at minimum do not ignore the return value. - 🟡 lib/validators/email_validator.rb (L21)
The validator runs when the email is blank/empty (form posting an empty field), producing a spuriousnot_allowederror on top of the presence error and issuing aBlockedEmail.where(email: nil)query. Short-circuit on blank value (e.g.return if value.blank?) or only run the validator whenemail.present?. - 🟡 spec/components/validators/email_validator_spec.rb (L9)
The dedicated validator spec never exercises the whitelist/blacklist branches: both cases usebad@spamclub.comand differ only by a stubbedBlockedEmail.should_block?. Add positive/negative cases for whitelist and blacklist settings, case-insensitivity, and the interaction where the BlockedEmail check is skipped when a restriction error was already added. - 🟡 app/models/user.rb (L47)
The blocklist gate only runs when the email changes (if: :email_changed?), so an already-registered user whose email is later added toBlockedEmailis never re-screened. Given the MR's goal of comprehensive email blocking, confirm whether existing users should be re-screened (e.g. at login) or accept this limitation. - 🟡 app/assets/javascripts/discourse/controllers/create_account_controller.js (L73)
Rejected-email reason is lost:emailValidationreturns the genericuser.email.invalidregardless of why the server rejected the address, whereas the actual blocked reason isuser.email.blocked= "is not allowed." (server.en.yml:734). The client and server therefore disagree on the message for the same condition. Store the authoritative reason fromresult.errors.email(an array) alongside the rejected email and render it rather than the hard-coded generic message; align the server and client copy. - 🟡 app/assets/javascripts/discourse/controllers/create_account_controller.js (L274)
rejectedEmailssticky-blocks on ANY email error (uniqueness,not_allowed, blocked) and never clears, so non-blocked conditions pin misleading "is not valid" and the user cannot resubmit for the page session. Also,emailValidationcompares the raw case-preservedaccountEmailagainst the possibly-normalizedresult.values.email; if they differ (e.g.Foo@Bar.COMvsfoo@bar.com) the.contains(email)guard silently fails to engage, so resubmission of a blocked email is not prevented client-side. Confirm the normalization and make the comparison consistent/case-insensitive.
Small things (take or leave)
- 🔵 spec/models/blocked_email_spec.rb (L30)
Fragilelast_match_atassertion:blocked_email.last_match_at.should be_within_one_second_of(Time.zone.now)only passes because the precedingchange { blocked_email.reload.match_count }incidentally reloads and refresheslast_match_aton the stale fabricated instance. Make the reload explicit (blocked_email.reload.last_match_at.should ...) so the intent is robust. - 🔵 lib/validators/email_validator.rb (L21)
Restriction-domain matching is substring-based and unanchored:Regexp.new("@(#{domains})", true)matches@domainanywhere in the address (e.g.foo@mailinator.com.evil.org). Carried over from the oldUser#email_in_restriction_setting?so not a regression, but in the shared validator consider parsing the host and anchoring the match, and note theRegexp.newis rebuilt on every validation. - 🔵 spec/components/validators/email_validator_spec.rb (L10)
The spec assertingrecord.errors[:email]is empty depends on the defaultSiteSetting.email_domains_blacklist('mailinator.com') not matchingspamclub.com. Stubemail_domains_whitelist/email_domains_blacklistexplicitly per case so each assertion is deterministic and isolated from defaults. - 🔵 app/controllers/users_controller.rb (L194)
Server error response shape is inconsistent:errors/valuesare only emitted in theelsebranch ofuser.save.ActiveRecord::StatementInvalidreturns{success:false, message:...}with noerrors, andRestClient::Forbiddenreturnserrorsas an array, not the hash the client expects. The client's defensiveresult.errors && result.errors.emailcheck avoids crashes, butrejectedEmailssilently won't engage for hub-forbidden failures. Consider returning a consistent{success, errors:{...}, values:{...}}envelope from all failure branches. - 🔵 app/assets/javascripts/discourse/controllers/create_account_controller.js (L282)
No honeypot/challenge refresh after a failed submit: the controller resetsformSubmittedbut reuses the already-fetchedaccountPasswordConfirm/accountChallenge. Works today because the server challenge is static, but combined with the unbounded retry loop it allows indefinite resubmission of the same payload; if the server ever rotates the honeypot it would break legitimate retries. Recommend re-fetching viafetchConfirmationValue()on the failure path. - 🔵 config/locales/server.en.yml (L734)
The newuser.email.blockedmessage ("is not allowed.") is semantically near-duplicate ofnot_allowed("is not allowed from that email provider. Please use another email address.") and gives no guidance to the end user in the blocked case. Consider a more descriptive, distinct message (e.g. "has been blocked. Please use a different email address.") so the two outcomes are distinguishable.
✅ Feature-level checklist
Partially covered
- ⚠️ Add comprehensive email validation for blocked users (enforce a BlockedEmail blocklist during email validation).
Server-side blocking during email validation is implemented and works for new/self-registered users, but the requirement is not fully met: the blocklist only applies when the email changes (not retroactively to existing users), the client-side guard is unreliable due to case/normalization mismatch, and the validation path performs mutating DB writes (twice per create) with an unconfirmed blast radius on invite/hub-account flows. These gaps should be resolved to consider the feature comprehensive.
FriendlyReviewer found both golden bugs — recall 100%. The side-effect predicate finding (golden #1) goes well beyond the reference comment: it establishes that
should_block?writes to the database from insidevalidate_each, then connects that to the double validation perusers#createrequest (user.valid?thenuser.save) and to the unauthenticated, honeypot-only guard — turning a "misleading method name" observation into a concrete resource-exhaustion vector onPOST /users. That escalation from code smell to DoS path is the strongest single analysis on this page.The second golden is found, but with a calibration caveat: the substring-based, unanchored regex is flagged only as a 🔵 "small thing", and FriendlyReviewer explicitly notes it is carried over from the old
User#email_in_restriction_setting?and is "not a regression". The dataset rates it Medium. The finding is correct and actionable (parse the host, anchor the match), but its placement in "take or leave" signals that the review does not consider it a blocker — a severity disagreement worth noting honestly.The 13 supplementary findings are dense but legitimate, and the two HIGH ones are arguably more important than the goldens themselves: the non-atomic
match_countincrement is a genuine lost-update race in a concurrent signup path, and the blast-radius finding traces the new gate intocreate_for_emailflows (invite redemption, DiscourseHub) where nothing rescues the newRecordInvalid. Both are cross-file consequences that a diff-only review would miss. The client-side findings (lost rejection reason, stickyrejectedEmails, case-sensitive comparison) are also real: the client over-blocks and under-blocks depending on input casing.The weaker items are all in the low bucket — spec isolation, a fragile
last_match_atassertion, a near-duplicate locale message — and none is forced. The verdict (request changes) is supported by the high findings; the only real blind spot on this run is severity ordering: the review's most severe items concern availability and flow breadth, while the dataset's Medium goldens got the closer attention they warranted.