-
Notifications
You must be signed in to change notification settings - Fork 0
Add comprehensive email validation for blocked users #3
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: blocked-email-validation-pre
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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; | ||||||||||||||
|
|
@@ -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, | ||||||||||||||
|
|
@@ -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()) { | ||||||||||||||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Verify null safety when accessing nested properties. The condition checks 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
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||
| createAccountController.set('formSubmitted', false); | ||||||||||||||
| } | ||||||||||||||
| if (result.active) { | ||||||||||||||
|
|
||||||||||||||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: find . -name "blocked_email.rb" -type fRepository: AI-Code-Review-Evals/coderabbit-discourse Length of output: 114 🏁 Script executed: cat -n ./app/models/blocked_email.rbRepository: 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 2Repository: 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 3Repository: 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 5Repository: 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 -nRepository: 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 -20Repository: 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 5Repository: 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.rbRepository: 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.rbRepository: AI-Code-Review-Evals/coderabbit-discourse Length of output: 1984 Address case-insensitive email matching and silent save failure in Two issues in the
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 |
||
|
|
||
| def set_defaults | ||
| self.action_type ||= BlockedEmail.actions[:block] | ||
| end | ||
|
|
||
| end | ||
| 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 |
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Calling 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 |
||
| end | ||
|
|
||
| def email_in_restriction_setting?(setting, value) | ||
| domains = setting.gsub('.', '\.') | ||
| regexp = Regexp.new("@(#{domains})", true) | ||
| value =~ regexp | ||
| end | ||
|
|
||
| end | ||
| 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 |
| 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 |
| 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 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
rg -n "CreateAccountController" --type js -C 3Repository: 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.jsRepository: AI-Code-Review-Evals/coderabbit-discourse
Length of output: 162
🏁 Script executed:
Repository: AI-Code-Review-Evals/coderabbit-discourse
Length of output: 4140
🏁 Script executed:
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 -100Repository: AI-Code-Review-Evals/coderabbit-discourse
Length of output: 3734
🏁 Script executed:
Repository: AI-Code-Review-Evals/coderabbit-discourse
Length of output: 322
🏁 Script executed:
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.jsRepository: AI-Code-Review-Evals/coderabbit-discourse
Length of output: 852
🏁 Script executed:
cat -n app/assets/javascripts/discourse/controllers/create_account_controller.js | tail -200Repository: AI-Code-Review-Evals/coderabbit-discourse
Length of output: 8447
🏁 Script executed:
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
rejectedEmailsarray 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