-
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? | ||||||||
|
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. Suggestion: The validator assumes Severity Level: Major
|
||||||||
| 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.| 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 | ||||||
|
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. Suggestion: The example asserting the result when no record exists expects a strict 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
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 | ||||||
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.
Suggestion: The
should_block?method updatesmatch_countwith 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 thematch_counttracking to be incorrect. Using an atomic increment operation avoids this race condition and ensures every blocked-email check is counted accurately while still updatinglast_match_at. [race condition]Severity Level: Major⚠️
Steps of Reproduction ✅
Prompt for AI Agent 🤖