Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ Discourse.CreateAccountController = Discourse.Controller.extend(Discourse.ModalF
accountPasswordConfirm: 0,
accountChallenge: 0,
formSubmitted: false,
rejectedEmails: Em.A([]),

submitDisabled: function() {
if (this.get('formSubmitted')) return true;
Expand Down Expand Up @@ -64,6 +65,14 @@ Discourse.CreateAccountController = Discourse.Controller.extend(Discourse.ModalF
}

email = this.get("accountEmail");

if (this.get('rejectedEmails').contains(email)) {
return Discourse.InputValidation.create({
failed: true,
reason: I18n.t('user.email.invalid')
});
}

if ((this.get('authOptions.email') === email) && this.get('authOptions.email_valid')) {
return Discourse.InputValidation.create({
ok: true,
Expand All @@ -84,7 +93,7 @@ Discourse.CreateAccountController = Discourse.Controller.extend(Discourse.ModalF
failed: true,
reason: I18n.t('user.email.invalid')
});
}.property('accountEmail'),
}.property('accountEmail', 'rejectedEmails.@each'),

usernameMatch: function() {
if (this.usernameNeedsToBeValidatedWithEmail()) {
Expand Down Expand Up @@ -262,6 +271,9 @@ Discourse.CreateAccountController = Discourse.Controller.extend(Discourse.ModalF
createAccountController.set('complete', true);
} else {
createAccountController.flash(result.message || I18n.t('create_account.failed'), 'error');
if (result.errors && result.errors.email && result.values) {
createAccountController.get('rejectedEmails').pushObject(result.values.email);
}
createAccountController.set('formSubmitted', false);
}
if (result.active) {
Expand Down
4 changes: 3 additions & 1 deletion app/controllers/users_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,9 @@ def create
else
render json: {
success: false,
message: I18n.t("login.errors", errors: user.errors.full_messages.join("\n"))
message: I18n.t("login.errors", errors: user.errors.full_messages.join("\n")),
errors: user.errors.to_hash,
values: user.attributes.slice("name", "username", "email")
}
end
rescue ActiveRecord::StatementInvalid
Expand Down
25 changes: 25 additions & 0 deletions app/models/blocked_email.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
class BlockedEmail < ActiveRecord::Base

before_validation :set_defaults

validates :email, presence: true, uniqueness: true

def self.actions
@actions ||= Enum.new(:block, :do_nothing)
end

def self.should_block?(email)
record = BlockedEmail.where(email: email).first
if record
record.match_count += 1
record.last_match_at = Time.zone.now
record.save
end
record && record.action_type == actions[:block]
end
Comment on lines +11 to +19
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Critical: Race condition and silent failure in should_block?

This method has several critical issues:

  1. Race condition (lines 14-16): Multiple concurrent calls with the same email will read the same match_count, each increment it, and each save, resulting in lost increments. This is a classic TOCTOU (time-of-check-time-of-use) vulnerability.

  2. Silent failure (line 16): The save call can fail due to validation errors or database constraints, but failures are silently ignored. Tracking data may be lost while the method still returns a blocking decision.

  3. Misleading method name (lines 11-19): The name should_block? suggests a read-only query, but it modifies the database. This violates the principle of least surprise and makes the code harder to reason about.

Apply this diff to fix the race condition and improve reliability:

   def self.should_block?(email)
-    record = BlockedEmail.where(email: email).first
+    record = BlockedEmail.find_by(email: email)
     if record
-      record.match_count += 1
-      record.last_match_at = Time.zone.now
-      record.save
+      # Use atomic increment to prevent race conditions
+      record.increment!(:match_count)
+      record.update_column(:last_match_at, Time.zone.now)
     end
     record && record.action_type == actions[:block]
   end

Note: Consider separating the blocking check from the tracking logic to make the side effects more explicit, e.g., a separate track_match!(email) method.

🤖 Prompt for AI Agents
In app/models/blocked_email.rb around lines 11 to 19, the should_block? method
both mutates state and returns a boolean which causes a race condition and
silent failures; refactor by splitting responsibilities: rename should_block? to
a pure predicate that only reads (e.g., blocked?), create a separate
track_match!(email) method that performs an atomic DB-level increment and
timestamp update (use update_counters or an UPDATE ... SET match_count =
match_count + 1, last_match_at = ? with a where clause inside a transaction or
use incremental SQL to avoid TOCTOU), ensure the tracking method raises or logs
on failure (do not silently ignore save failures), and update callers to call
blocked? for checks and track_match! when you want to record a match so behavior
and side effects are explicit.


def set_defaults
self.action_type ||= BlockedEmail.actions[:block]
end

end
23 changes: 2 additions & 21 deletions app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,9 @@ class User < ActiveRecord::Base
has_one :user_search_data

validates_presence_of :username
validates_presence_of :email
validates_uniqueness_of :email
validate :username_validator
validate :email_validator, if: :email_changed?
validates :email, presence: true, uniqueness: true
validates :email, email: true, if: :email_changed?
validate :password_validator

before_save :cook
Expand Down Expand Up @@ -565,24 +564,6 @@ def username_validator
end
end

def email_validator
if (setting = SiteSetting.email_domains_whitelist).present?
unless email_in_restriction_setting?(setting)
errors.add(:email, I18n.t(:'user.email.not_allowed'))
end
elsif (setting = SiteSetting.email_domains_blacklist).present?
if email_in_restriction_setting?(setting)
errors.add(:email, I18n.t(:'user.email.not_allowed'))
end
end
end

def email_in_restriction_setting?(setting)
domains = setting.gsub('.', '\.')
regexp = Regexp.new("@(#{domains})", true)
self.email =~ regexp
end

def password_validator
if (@raw_password && @raw_password.length < 6) || (@password_required && !@raw_password)
errors.add(:password, "must be 6 letters or longer")
Expand Down
1 change: 1 addition & 0 deletions config/locales/server.en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -731,6 +731,7 @@ en:
must_begin_with_alphanumeric: "must begin with a letter or number"
email:
not_allowed: "is not allowed from that email provider. Please use another email address."
blocked: "is not allowed."

invite_mailer:
subject_template: "[%{site_name}] %{invitee_name} invited you to join a discussion on %{site_name}"
Expand Down
12 changes: 12 additions & 0 deletions db/migrate/20130724201552_create_blocked_emails.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
class CreateBlockedEmails < ActiveRecord::Migration
def change
create_table :blocked_emails do |t|
t.string :email, null: false
t.integer :action_type, null: false
t.integer :match_count, null: false, default: 0
t.datetime :last_match_at
t.timestamps
end
add_index :blocked_emails, :email, unique: true
end
end
24 changes: 24 additions & 0 deletions lib/validators/email_validator.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
class EmailValidator < ActiveModel::EachValidator

def validate_each(record, attribute, value)
if (setting = SiteSetting.email_domains_whitelist).present?
unless email_in_restriction_setting?(setting, value)
record.errors.add(attribute, I18n.t(:'user.email.not_allowed'))
end
elsif (setting = SiteSetting.email_domains_blacklist).present?
if email_in_restriction_setting?(setting, value)
record.errors.add(attribute, I18n.t(:'user.email.not_allowed'))
end
end
if record.errors[attribute].blank? and BlockedEmail.should_block?(value)
record.errors.add(attribute, I18n.t(:'user.email.blocked'))
end
end
Comment on lines +3 to +16
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

Add basic email format validation before domain and blocked email checks.

The validator correctly handles whitelist/blacklist precedence and blocked email checks. However, it appears to be missing basic email format validation (e.g., presence of @, valid domain format).

Without format validation, malformed emails like "not-an-email" could reach the BlockedEmail.should_block? check. Verify whether email format validation occurs elsewhere in the validation chain:

If no format validation exists, consider adding it:

 def validate_each(record, attribute, value)
+  # Basic format check
+  unless value =~ /\A[^@\s]+@[^@\s]+\.[^@\s]+\z/
+    record.errors.add(attribute, I18n.t(:'user.email.invalid'))
+    return
+  end
+
   if (setting = SiteSetting.email_domains_whitelist).present?

🏁 Script executed:

#!/bin/bash
# Search for email format validation in the User model or other validators
rg -n -B3 -A3 'email.*format|format.*email|emailValid|email.*regexp|EMAIL.*=' --type=rb -g '!spec/**'

Length of output: 26


🏁 Script executed:

#!/bin/bash
# Search for email format validation patterns without type restriction
rg -n 'email.*format|emailValid|EMAIL.*REGEX|@.*format' -g '!spec/**' -g '!test/**'

Length of output: 318677


🏁 Script executed:

#!/bin/bash
# Find email validator and related files
fd -e rb 'email|validator' | head -20

Length of output: 957


🏁 Script executed:

#!/bin/bash
# Search for BlockedEmail class and should_block? method
rg -n 'class BlockedEmail|def should_block' -g '!spec/**' -g '!test/**'

Length of output: 69


🏁 Script executed:

#!/bin/bash
# Search for User model email validation
rg -n 'validates.*:email|email.*validates' -g '!spec/**' -g '!test/**'

Length of output: 120339


🏁 Script executed:

#!/bin/bash
set -e
echo "== lib/validators/email_validator.rb =="
if [ -f lib/validators/email_validator.rb ]; then sed -n '1,240p' lib/validators/email_validator.rb; else echo "MISSING: lib/validators/email_validator.rb"; fi

echo
echo "== app/models/blocked_email.rb =="
if [ -f app/models/blocked_email.rb ]; then sed -n '1,240p' app/models/blocked_email.rb; else echo "MISSING: app/models/blocked_email.rb"; fi

echo
echo "== app/models/user.rb (first 200 lines) =="
if [ -f app/models/user.rb ]; then sed -n '1,200p' app/models/user.rb; else echo "MISSING: app/models/user.rb"; fi

echo
echo "== rg for should_block? =="
rg -n "should_block\\?" || true

Length of output: 7484


Validate email format before domain/blocked checks.

Add a simple format check at the top of lib/validators/email_validator.rb#validate_each so malformed emails are rejected before whitelist/blacklist/blocked checks (BlockedEmail.should_block? assumes a valid address).

Suggested minimal change:

 def validate_each(record, attribute, value)
+  # Basic format check
+  unless value =~ /\A[^@\s]+@[^@\s]+\.[^@\s]+\z/
+    record.errors.add(attribute, I18n.t(:'user.email.invalid'))
+    return
+  end

lib/validators/email_validator.rb — update validate_each.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def validate_each(record, attribute, value)
if (setting = SiteSetting.email_domains_whitelist).present?
unless email_in_restriction_setting?(setting, value)
record.errors.add(attribute, I18n.t(:'user.email.not_allowed'))
end
elsif (setting = SiteSetting.email_domains_blacklist).present?
if email_in_restriction_setting?(setting, value)
record.errors.add(attribute, I18n.t(:'user.email.not_allowed'))
end
end
if record.errors[attribute].blank? and BlockedEmail.should_block?(value)
record.errors.add(attribute, I18n.t(:'user.email.blocked'))
end
end
def validate_each(record, attribute, value)
# Basic format check
unless value =~ /\A[^@\s]+@[^@\s]+\.[^@\s]+\z/
record.errors.add(attribute, I18n.t(:'user.email.invalid'))
return
end
if (setting = SiteSetting.email_domains_whitelist).present?
unless email_in_restriction_setting?(setting, value)
record.errors.add(attribute, I18n.t(:'user.email.not_allowed'))
end
elsif (setting = SiteSetting.email_domains_blacklist).present?
if email_in_restriction_setting?(setting, value)
record.errors.add(attribute, I18n.t(:'user.email.not_allowed'))
end
end
if record.errors[attribute].blank? and BlockedEmail.should_block?(value)
record.errors.add(attribute, I18n.t(:'user.email.blocked'))
end
end
🤖 Prompt for AI Agents
In lib/validators/email_validator.rb around lines 3 to 16, validate_each
currently runs whitelist/blacklist and BlockedEmail checks without first
ensuring the email is well-formed; add a simple format check at the top of
validate_each that uses a standard email regex (e.g. Ruby's
URI::MailTo::EMAIL_REGEXP or a minimal reasonable regex) and if the value is
malformed call record.errors.add(attribute, I18n.t(:'user.email.invalid')) (or
an existing invalid-email i18n key) and return early so subsequent
whitelist/blacklist/BlockedEmail.should_block? logic only runs for valid
addresses.


def email_in_restriction_setting?(setting, value)
domains = setting.gsub('.', '\.')
regexp = Regexp.new("@(#{domains})", true)
value =~ regexp
end

end
23 changes: 23 additions & 0 deletions spec/components/validators/email_validator_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
require 'spec_helper'

describe EmailValidator do

let(:record) { Fabricate.build(:user, email: "[email protected]") }
let(:validator) { described_class.new({attributes: :email}) }
subject(:validate) { validator.validate_each(record,:email,record.email) }

context "blocked email" do
it "doesn't add an error when email doesn't match a blocked email" do
BlockedEmail.stubs(:should_block?).with(record.email).returns(false)
validate
record.errors[:email].should_not be_present
end

it "adds an error when email matches a blocked email" do
BlockedEmail.stubs(:should_block?).with(record.email).returns(true)
validate
record.errors[:email].should be_present
end
end

end
4 changes: 4 additions & 0 deletions spec/fabricators/blocked_email_fabricator.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Fabricator(:blocked_email) do
email { sequence(:email) { |n| "bad#{n}@spammers.org" } }
action_type BlockedEmail.actions[:block]
end
48 changes: 48 additions & 0 deletions spec/models/blocked_email_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
require 'spec_helper'

describe BlockedEmail do

let(:email) { '[email protected]' }

describe "new record" do
it "sets a default action_type" do
BlockedEmail.create(email: email).action_type.should == BlockedEmail.actions[:block]
end

it "last_match_at is null" do
# If we manually load the table with some emails, we can see whether those emails
# have ever been blocked by looking at last_match_at.
BlockedEmail.create(email: email).last_match_at.should be_nil
end
end

describe "#should_block?" do
subject { BlockedEmail.should_block?(email) }

it "returns false if a record with the email doesn't exist" do
subject.should be_false
end

shared_examples "when a BlockedEmail record matches" do
it "updates statistics" do
Timecop.freeze(Time.zone.now) do
expect { subject }.to change { blocked_email.reload.match_count }.by(1)
blocked_email.last_match_at.should be_within_one_second_of(Time.zone.now)
end
end
end

context "action_type is :block" do
let!(:blocked_email) { Fabricate(:blocked_email, email: email, action_type: BlockedEmail.actions[:block]) }
it { should be_true }
include_examples "when a BlockedEmail record matches"
end

context "action_type is :do_nothing" do
let!(:blocked_email) { Fabricate(:blocked_email, email: email, action_type: BlockedEmail.actions[:do_nothing]) }
it { should be_false }
include_examples "when a BlockedEmail record matches"
end
end

end