-
Notifications
You must be signed in to change notification settings - Fork 0
Add comprehensive email validation for blocked users #8
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 |
|---|---|---|
| @@ -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 | ||
|
|
||
| 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 | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| end | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+3
to
+16
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 chainAdd 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 Without format validation, malformed emails like 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 -20Length 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\\?" || trueLength 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
+ endlib/validators/email_validator.rb — update validate_each. 📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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: "[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 |
| 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) { '[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 |
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.
Critical: Race condition and silent failure in should_block?
This method has several critical issues:
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.Silent failure (line 16): The
savecall 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.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:
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