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([]),

Copy link
Copy Markdown

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

🏁 Script executed:

rg -n "CreateAccountController" --type js -C 3

Repository: AI-Code-Review-Evals/coderabbit-discourse

Length of output: 1444


🏁 Script executed:

# Also check the file itself to see the full context
wc -l app/assets/javascripts/discourse/controllers/create_account_controller.js

Repository: AI-Code-Review-Evals/coderabbit-discourse

Length of output: 162


🏁 Script executed:

# Check if this is a modal controller and how it's instantiated
rg -n "ModalFunctionality|new.*CreateAccount|CreateAccountController" --type js | head -30

Repository: AI-Code-Review-Evals/coderabbit-discourse

Length of output: 4140


🏁 Script executed:

# Look for similar controller patterns with mutable defaults in the same directory
fd -e js . app/assets/javascripts/discourse/controllers/ | head -5 | xargs -I {} sh -c 'echo "=== {} ===" && head -30 "{}"'

Repository: AI-Code-Review-Evals/coderabbit-discourse

Length of output: 4152


🏁 Script executed:

cat -n app/assets/javascripts/discourse/controllers/create_account_controller.js | head -100

Repository: AI-Code-Review-Evals/coderabbit-discourse

Length of output: 3734


🏁 Script executed:

# Check if there are any methods that clear or reset rejectedEmails
rg -n "rejectedEmails" app/assets/javascripts/discourse/controllers/create_account_controller.js

Repository: AI-Code-Review-Evals/coderabbit-discourse

Length of output: 322


🏁 Script executed:

# Check for init or reset methods in the controller
rg -n "init:|reset:|onOpen:|onShow:" app/assets/javascripts/discourse/controllers/create_account_controller.js

Repository: AI-Code-Review-Evals/coderabbit-discourse

Length of output: 67


🏁 Script executed:

# Look at ModalFunctionality to see if it handles cleanup
cat -n app/assets/javascripts/discourse/mixins/modal_functionality.js

Repository: AI-Code-Review-Evals/coderabbit-discourse

Length of output: 852


🏁 Script executed:

cat -n app/assets/javascripts/discourse/controllers/create_account_controller.js | tail -200

Repository: AI-Code-Review-Evals/coderabbit-discourse

Length of output: 8447


🏁 Script executed:

# Specifically look for any clearing/reset logic around rejectedEmails
rg -n -B5 -A5 "rejectedEmails.*pushObject|pushObject.*rejectedEmails" app/assets/javascripts/discourse/controllers/create_account_controller.js

Repository: AI-Code-Review-Evals/coderabbit-discourse

Length of output: 657


Rejected emails array should be cleared when the modal is opened, not when initialized.

The rejectedEmails array is populated when email validation fails (line 275) but is never cleared. When the modal is closed and reopened, previously rejected emails remain in the array and continue to block form submission. This causes state pollution across modal sessions.

Since CreateAccountController is a singleton modal controller, the issue is not multiple instances sharing state, but rather persistent state across separate modal interactions. Clear the array when the modal opens or when form inputs are reset.

🤖 Prompt for AI Agents
In @app/assets/javascripts/discourse/controllers/create_account_controller.js at
line 17, CreateAccountController's rejectedEmails array persists across modal
sessions; to fix, clear it when the modal opens or when the form is reset by
calling this.rejectedEmails.clear() (or reassigning Em.A([])) inside the
modal-open/reset handler rather than only at initialization. Add that clear call
to the controller method that runs when the modal is shown (e.g., the modal
open/show handler or the controller's resetForm/initializeForm routine) so
previously rejected addresses don't block subsequent submissions; also consider
clearing it just before running the email validation logic to ensure fresh
state.


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);
}
Comment on lines +274 to +276

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Verify null safety when accessing nested properties.

The condition checks result.errors && result.errors.email && result.values, but then accesses result.values.email. If result.values exists but result.values.email is undefined/null, pushObject would add an undefined value to the array.

Suggested fix
-        if (result.errors && result.errors.email && result.values) {
+        if (result.errors && result.errors.email && result.values && result.values.email) {
           createAccountController.get('rejectedEmails').pushObject(result.values.email);
         }
📝 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
if (result.errors && result.errors.email && result.values) {
createAccountController.get('rejectedEmails').pushObject(result.values.email);
}
if (result.errors && result.errors.email && result.values && result.values.email) {
createAccountController.get('rejectedEmails').pushObject(result.values.email);
}
🤖 Prompt for AI Agents
In @app/assets/javascripts/discourse/controllers/create_account_controller.js
around lines 274 - 276, The check that pushes to
createAccountController.get('rejectedEmails') can still push undefined because
it only verifies result.values exists; update the conditional to ensure
result.values.email is present and non-null/undefined before calling pushObject
(e.g. change the if to check result.values && result.values.email or use
optional chaining/result.values?.email), and only push result.values.email when
that value is truthy to avoid inserting undefined into rejectedEmails.

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
Copy Markdown

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

🏁 Script executed:

find . -name "blocked_email.rb" -type f

Repository: AI-Code-Review-Evals/coderabbit-discourse

Length of output: 114


🏁 Script executed:

cat -n ./app/models/blocked_email.rb

Repository: AI-Code-Review-Evals/coderabbit-discourse

Length of output: 802


🏁 Script executed:

# Check for email normalization patterns in the codebase
rg -n "downcase.*email|email.*downcase|email.*lower|lower.*email" --type ruby -C 2

Repository: AI-Code-Review-Evals/coderabbit-discourse

Length of output: 7078


🏁 Script executed:

# Check if BlockedEmail is created/updated elsewhere with email normalization
rg -n "BlockedEmail" --type ruby -B 3 -A 3

Repository: AI-Code-Review-Evals/coderabbit-discourse

Length of output: 6419


🏁 Script executed:

# Check how the email validator is used and if emails are normalized before validation
rg -n "email_validator|EmailValidator" --type ruby -B 5 -A 5

Repository: AI-Code-Review-Evals/coderabbit-discourse

Length of output: 1909


🏁 Script executed:

# Check how User model handles email normalization during creation/validation
sed -n '1,300p' ./app/models/user.rb | cat -n

Repository: AI-Code-Review-Evals/coderabbit-discourse

Length of output: 10703


🏁 Script executed:

# Check if User model normalizes email on assignment or before_save
rg -n "email.*=" ./app/models/user.rb | head -20

Repository: AI-Code-Review-Evals/coderabbit-discourse

Length of output: 487


🏁 Script executed:

# Check for before_save and email normalization in User model
rg -n "before_save|def email" ./app/models/user.rb -A 5

Repository: AI-Code-Review-Evals/coderabbit-discourse

Length of output: 774


🏁 Script executed:

# Verify the Email.downcase utility and confirm it handles case-insensitive matching properly
cat -n lib/email.rb

Repository: AI-Code-Review-Evals/coderabbit-discourse

Length of output: 942


🏁 Script executed:

# Check if there's any test that validates case-insensitive email matching for BlockedEmail
cat -n spec/models/blocked_email_spec.rb

Repository: AI-Code-Review-Evals/coderabbit-discourse

Length of output: 1984


Address case-insensitive email matching and silent save failure in should_block?

Two issues in the should_block? method:

  1. Case-sensitive email lookup (Line 12): The query where(email: email) is case-sensitive. A blocked email stored as "bad@spam.com" won't match lookups for "Bad@Spam.com", allowing users to bypass the block by varying case. The codebase establishes a pattern of using Email.downcase() for case-insensitive email handling; apply it here.

  2. Silent save failure (Line 16): record.save fails silently if validation errors occur. Statistics updates (match_count, last_match_at) are lost without indication. Use record.save! and handle exceptions to ensure failures are logged.

Suggested improvements
 def self.should_block?(email)
-  record = BlockedEmail.where(email: email).first
+  record = BlockedEmail.where("lower(email) = ?", email.to_s.downcase).first
   if record
     record.match_count += 1
     record.last_match_at = Time.zone.now
-    record.save
+    record.save!
   end
   record && record.action_type == actions[:block]
+rescue ActiveRecord::RecordInvalid => e
+  Rails.logger.error("Failed to update BlockedEmail stats: #{e.message}")
+  record && record.action_type == actions[:block]
 end
🤖 Prompt for AI Agents
In @app/models/blocked_email.rb around lines 11 - 19, In should_block?(email) on
BlockedEmail, perform a case-insensitive lookup by downcasing the incoming email
(e.g., use Email.downcase(email) or email.downcase) when querying instead of
where(email: email), increment match_count and update last_match_at as before
but replace record.save with record.save! wrapped in a begin/rescue to catch
ActiveRecord::RecordInvalid (or StandardError) and log the failure so statistics
updates aren’t silently lost; keep the final return checking record &&
record.action_type == actions[:block].


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
Comment on lines +13 to +15

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

BlockedEmail.should_block? has a side effect during validation.

Calling should_block? increments match_count and updates last_match_at (as seen in app/models/blocked_email.rb lines 14-16). This means statistics are updated every time validation runs, not just on actual signup attempts. If a user corrects other form fields and resubmits, the blocked email counter would be inflated.

Consider whether this is the intended behavior. If not, you may want a separate method that only checks without updating statistics, reserving the stat update for actual signup attempts.

🤖 Prompt for AI Agents
In @lib/validators/email_validator.rb around lines 13 - 15, Validation currently
calls BlockedEmail.should_block?, which has side effects (increments match_count
and sets last_match_at); change the validator (email_validator.rb) to call a new
side-effect-free method like BlockedEmail.blocked? (or similar) that performs
only the check, and implement that method in BlockedEmail model reusing the same
matching logic but without updating match_count/last_match_at; reserve
should_block? (which updates stats) to be called only during real signup
attempts (e.g., in the signup/create flow) so that repeated validations do not
inflate statistics; update/add tests to cover both the pure check method and the
stats-updating method.

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
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