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
Comment on lines +14 to +16

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggestion: The should_block? method updates match_count with a read-modify-write sequence (record.match_count += 1; record.save), which is not atomic; under concurrent requests for the same email, increments can be lost, causing the match_count tracking to be incorrect. Using an atomic increment operation avoids this race condition and ensures every blocked-email check is counted accurately while still updating last_match_at. [race condition]

Severity Level: Major ⚠️
- ⚠️ BlockedEmail.match_count undercounts concurrent blocked signups.
- ⚠️ Monitoring of attack volume via match_count becomes unreliable.
- ⚠️ last_match_at reflects only last writer under contention.
Suggested change
record.match_count += 1
record.last_match_at = Time.zone.now
record.save
record.increment!(:match_count)
record.update_column(:last_match_at, Time.zone.now)
Steps of Reproduction ✅
1. Create a `BlockedEmail` row for some address at `app/models/blocked_email.rb:1` with
`email: 'blocked@example.com'`, `match_count: 0`, and `action_type:
BlockedEmail.actions[:block]`.

2. Trigger two concurrent operations that save or create `User` records with the same
email `'blocked@example.com'`, so that the email validator runs on both. The validator is
attached to `User` via `validates :email, email: true, if: :email_changed?` in
`app/models/user.rb:46-47`.

3. For each concurrent validation, `EmailValidator#validate_each` in
`lib/validators/email_validator.rb:3-16` is invoked, which calls
`BlockedEmail.should_block?(value)` at line 13 when no previous email errors exist.

4. Inside `BlockedEmail.should_block?` at `app/models/blocked_email.rb:11-18`, both
requests load the same row (`where(email: email).first`), each computes
`record.match_count += 1` in Ruby, then `record.save`. Because these updates are not
atomic at the database level, both can persist `match_count` as `1` (or `N+1`) instead of
`2` (or `N+2`), demonstrating lost increments under concurrent access.
Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** app/models/blocked_email.rb
**Line:** 14:16
**Comment:**
	*Race Condition: The `should_block?` method updates `match_count` with a read-modify-write sequence (`record.match_count += 1; record.save`), which is not atomic; under concurrent requests for the same email, increments can be lost, causing the `match_count` tracking to be incorrect. Using an atomic increment operation avoids this race condition and ensures every blocked-email check is counted accurately while still updating `last_match_at`.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
👍 | 👎

end
record && record.action_type == actions[:block]
end

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?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggestion: The validator assumes value is always a string, but if it is ever nil (for example when the attribute is unset or when the validator is reused without a presence check), email_in_restriction_setting? will call value =~ regexp and raise a NoMethodError, causing validation to crash instead of returning a proper error response. Adding a short-circuit for nil values in validate_each avoids this runtime failure and lets other validations (like presence) handle the case. [null pointer]

Severity Level: Major ⚠️
- ❌ User validations crash at app/models/user.rb:44-47 when email nil.
- ⚠️ Affects EmailValidator at lib/validators/email_validator.rb:3-21 with whitelists.
Suggested change
if (setting = SiteSetting.email_domains_whitelist).present?
return if value.nil?
Steps of Reproduction ✅
1. Ensure an email domain restriction is configured so the validator actually calls
`email_in_restriction_setting?`: set either `SiteSetting.email_domains_whitelist` or
`SiteSetting.email_domains_blacklist` to a non-empty value (these settings are read in
`EmailValidator#validate_each` at `lib/validators/email_validator.rb:4-12`).

2. Create a `User` record without setting an email so the `email` attribute remains `nil`,
for example in Rails console: `user = User.new(username: "test-user")` using the `User`
model defined in `app/models/user.rb:12` (note that the database schema enforces NOT NULL
only on persisted rows; unsaved instances can still have `email == nil`).

3. Trigger validations on this user by calling `user.valid?` or `user.save`; this runs the
validations declared in `app/models/user.rb:44-47`, including `validates :email, email:
true, if: :email_changed?`, which invokes `EmailValidator#validate_each(record, :email,
record.email)` with `value == nil`.

4. Inside `EmailValidator#validate_each` (`lib/validators/email_validator.rb:3-16`),
because a whitelist/blacklist setting is present, the code calls
`email_in_restriction_setting?(setting, value)`
(`lib/validators/email_validator.rb:18-21`); there `value =~ regexp` executes with `value
== nil`, raising `NoMethodError: undefined method '=~' for nil:NilClass` and causing the
validation (and any controller action or job relying on it) to raise instead of returning
normal validation errors.
Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** lib/validators/email_validator.rb
**Line:** 4:4
**Comment:**
	*Null Pointer: The validator assumes `value` is always a string, but if it is ever `nil` (for example when the attribute is unset or when the validator is reused without a presence check), `email_in_restriction_setting?` will call `value =~ regexp` and raise a NoMethodError, causing validation to crash instead of returning a proper error response. Adding a short-circuit for `nil` values in `validate_each` avoids this runtime failure and lets other validations (like presence) handle the case.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
👍 | 👎

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 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: "bad@spamclub.com") }
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) { 'block@spamfromhome.org' }

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggestion: The example asserting the result when no record exists expects a strict false, but BlockedEmail.should_block? returns nil in that case, causing this spec to fail even though the method correctly indicates a non-blocking result; the expectation should allow any falsey value. [logic error]

Severity Level: Critical 🚨
- CRIT: RSpec model spec fails for BlockedEmail.should_block?.
- WARN: CI pipeline or pre-commit hooks blocked by failure.
- WARN: Production email validation unaffected; only test expectation misaligned.
Suggested change
subject.should be_false
subject.should be_falsey
Steps of Reproduction ✅
1. Open `app/models/blocked_email.rb` and inspect `BlockedEmail.should_block?(email)` at
lines 11–18, where it loads a record with `record = BlockedEmail.where(email:
email).first` and returns `record && record.action_type == actions[:block]`.

2. Note that when no `BlockedEmail` row exists for the given email, `record` is `nil`, so
the expression `record && record.action_type == actions[:block]` evaluates to `nil`, not
`false`.

3. Open `spec/models/blocked_email_spec.rb` and inspect the example at lines 19–24:
`describe "#should_block?" do` with `subject { BlockedEmail.should_block?(email) }` and
the example `"returns false if a record with the email doesn't exist"` expecting
`subject.should be_false`.

4. Run the test file, e.g. `bundle exec rspec spec/models/blocked_email_spec.rb`; the
example for the no-record case will fail because RSpec's `be_false` matcher expects the
value `false`, but the method returns `nil`, causing a mismatch even though both values
are treated as non-blocking in actual usage (e.g. `lib/validators/email_validator.rb:13`
only relies on a truthy/falsey check).
Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** spec/models/blocked_email_spec.rb
**Line:** 23:23
**Comment:**
	*Logic Error: The example asserting the result when no record exists expects a strict `false`, but `BlockedEmail.should_block?` returns `nil` in that case, causing this spec to fail even though the method correctly indicates a non-blocking result; the expectation should allow any falsey value.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
👍 | 👎

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