Add comprehensive email validation for blocked users

discourse/discourse@5f8a1302
Golden recall: 100% Extra findings: 13

Golden 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 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 inside validate_each, then connects that to the double validation per users#create request (user.valid? then user.save) and to the unauthenticated, honeypot-only guard — turning a "misleading method name" observation into a concrete resource-exhaustion vector on POST /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_count increment is a genuine lost-update race in a concurrent signup path, and the blast-radius finding traces the new gate into create_for_email flows (invite redemption, DiscourseHub) where nothing rescues the new RecordInvalid. Both are cross-file consequences that a diff-only review would miss. The client-side findings (lost rejection reason, sticky rejectedEmails, 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_at assertion, 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.

FriendlyReviewer's full review

As posted on the PR — the complete output from FriendlyReviewer.

friendly-reviewer bot commented now

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 (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 — enabling a resource-exhaustion / match_count-inflation flood vector. Split should_block? into a pure predicate plus an explicit track! 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_nothing entries (blocked_email.rb:12) is intended.
  • 🔴 app/models/blocked_email.rb (L14)
    Non-atomic read-modify-write on match_count. record.match_count += 1 followed by record.save is 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 on last_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 every save! in create_for_email flows — invite redemption (InviteRedeemer#redeem) and DiscourseHub account creation — so a blocked email will now raise ActiveRecord::RecordInvalid where it previously succeeded. Confirm this is intended and that callers handle the validation failure gracefully (they do not appear to rescue RecordInvalid); if the blocklist should only gate self-registration, gate the email: true validation accordingly.

Worth checking

  • 🟡 app/models/blocked_email.rb (L16)
    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. Prefer update_column(:match_count, ...)/update_columns (skips validations/callbacks) or record.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 spurious not_allowed error on top of the presence error and issuing a BlockedEmail.where(email: nil) query. Short-circuit on blank value (e.g. return if value.blank?) or only run the validator when email.present?.
  • 🟡 spec/components/validators/email_validator_spec.rb (L9)
    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?. 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 to BlockedEmail is 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: 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). The client and server therefore disagree on the message for the same condition. Store the authoritative reason from result.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)
    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. Also, emailValidation compares the raw case-preserved accountEmail against the possibly-normalized result.values.email; if they differ (e.g. Foo@Bar.COM vs foo@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)
    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. 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 @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.
  • 🔵 spec/components/validators/email_validator_spec.rb (L10)
    The spec asserting record.errors[:email] is empty depends on the default SiteSetting.email_domains_blacklist ('mailinator.com') not matching spamclub.com. Stub email_domains_whitelist/email_domains_blacklist explicitly per case so each assertion is deterministic and isolated from defaults.
  • 🔵 app/controllers/users_controller.rb (L194)
    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. The client's defensive result.errors && result.errors.email check avoids crashes, but rejectedEmails silently 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 resets formSubmitted but reuses the already-fetched accountPasswordConfirm/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 via fetchConfirmationValue() on the failure path.
  • 🔵 config/locales/server.en.yml (L734)
    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. 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.