-
-
Notifications
You must be signed in to change notification settings - Fork 71
Spam protection tools #2138
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
Open
ArtOfCode-
wants to merge
26
commits into
develop
Choose a base branch
from
art/spam-tools
base: develop
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Spam protection tools #2138
Changes from all commits
Commits
Show all changes
26 commits
Select commit
Hold shift + click to select a range
2913cc7
Block spammers on deletion
ArtOfCode- 15c9eb8
That's for mailers
ArtOfCode- c272d8a
Missed the ability fixtures
ArtOfCode- 6a86506
Rubocop
ArtOfCode- a08ed7f
Those aren't keyword arguments
ArtOfCode- 4e5c1e9
Add tests
ArtOfCode- 02d3d8f
Merge branch 'develop' into art/spam-tools
ArtOfCode- 3e0ab42
Move existing creation validations to concern
ArtOfCode- f32f2a3
Block identical post spam
ArtOfCode- ab08aa4
Merge branch 'develop' into art/spam-tools
ArtOfCode- c6a4eeb
Tests
ArtOfCode- ab1424a
Wrong class
ArtOfCode- 29c9b98
Tests still
ArtOfCode- bfe3033
Change post fixture user
ArtOfCode- fdb9187
Block posting when spam flags are active
ArtOfCode- 8670298
Add CIDR flagging
ArtOfCode- 21b5d33
Rubocop
ArtOfCode- 49ef46c
Rubocop
ArtOfCode- d864950
Really?
ArtOfCode- fce21a7
Exempt dev env from registration rate limit
ArtOfCode- 710a3ba
Use generic error messages instead
ArtOfCode- 97b822d
Update expected error
ArtOfCode- ebd2ebb
Merge branch 'develop' into art/spam-tools
ArtOfCode- 5fff126
Missed a helper in the merge
ArtOfCode- ad4beaf
Merge branch 'develop' into art/spam-tools
ArtOfCode- 51ed50b
Merge branch 'develop' into art/spam-tools
ArtOfCode- File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,47 @@ | ||
| class CheckCIDRJob < ApplicationJob | ||
| queue_as :default | ||
|
|
||
| def perform(post) | ||
| @post = post | ||
| relevant_ips = [post.user.current_sign_in_ip, post.user.last_sign_in_ip] | ||
| prefixes = BlockedItem.where(item_type: 'ip_prefix') | ||
| .where(Arel.sql('expires >= CURRENT_TIMESTAMP')) | ||
| .where("? LIKE CONCAT(`value`, '%') OR ? LIKE CONCAT(`value`, '%')", *relevant_ips) | ||
|
|
||
| if prefixes.any? | ||
| create_flag prefixes[0] | ||
| return # because prefixes are more performant than CIDR checks, so if we can match there then that'll do | ||
| end | ||
|
|
||
| cidrs = BlockedItem.where(item_type: 'ip_cidr') | ||
| .where(Arel.sql('expires >= CURRENT_TIMESTAMP')) | ||
| cidrs.each do |cidr| | ||
| network = IPAddress.parse(cidr.value) | ||
| relevant_ips.each do |ip| | ||
| ip = IPAddress.parse(ip) | ||
| # rubocop:disable Style/Next | ||
| if network.include?(ip) | ||
| create_flag cidr | ||
| # rubocop:disable Lint/NonLocalExitFromIterator | ||
| return | ||
| # rubocop:enable Lint/NonLocalExitFromIterator | ||
| end | ||
| # rubocop:enable Style/Next | ||
| end | ||
| end | ||
| end | ||
|
|
||
| def create_flag(match) | ||
| reason = 'Automatically escalated spam flag - please leave this for the community team to handle.' | ||
| spam_flag_type = PostFlagType.unscoped.where(community: @post.community, name: "it's spam").first | ||
| flag = @post.flags.create(user: helpers.system_user, community: @post.community, post_flag_type: spam_flag_type, | ||
| reason: reason, escalated: true, escalated_at: DateTime.now, | ||
| escalated_by: helpers.system_user, | ||
| escalation_comment: "Suspicious IP address: user IP matches #{match.value}") | ||
| FlagMailer.with(flag: flag).flag_escalated.deliver_now | ||
| end | ||
|
|
||
| def helpers | ||
| ApplicationController.helpers | ||
| end | ||
| end |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| class DeleteUserJob < ApplicationJob | ||
| queue_as :default | ||
|
|
||
| ## | ||
| # Perform a network-wide soft-deletion of a user account. Also optionally checks for helpful spam flags against | ||
| # the target user and applies a spam block if found. The caller is responsible for managing thresholds for this spam | ||
| # check. | ||
| # @param user [User] user to soft-delete | ||
| # @param attribute_to [User] the user performing the deletion | ||
| # @param perform_spam_check [Boolean] whether to perform the spam check | ||
| def perform(user, attribute_to, perform_spam_check: true) | ||
| if perform_spam_check | ||
| # Can't use model helper methods very easily here, because we want network-wide flags and that doesn't play | ||
| # nicely with default scopes. | ||
| flag_query = Post.unscoped | ||
| .joins(Arel.sql("INNER JOIN flags ON flags.post_type = 'Post' AND flags.post_id = posts.id")) | ||
| .joins(Arel.sql('INNER JOIN post_flag_types ON flags.post_flag_type_id = post_flag_types.id')) | ||
| .where(flags: { status: 'helpful' }, | ||
| post_flag_types: { name: "it's spam" }, | ||
| posts: { user_id: user.id }) | ||
| if flag_query.any? | ||
|
cellio marked this conversation as resolved.
|
||
| user.block('automatic block from spam check during deletion') | ||
| end | ||
| end | ||
|
|
||
| user.soft_delete(attribute_to) | ||
| end | ||
| end | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,81 @@ | ||
| module PostCreationValidations | ||
| extend ActiveSupport::Concern | ||
|
|
||
| # rubocop:disable Metrics/BlockLength | ||
| included do | ||
| validate :no_mathjax_in_title, on: :create | ||
| validate :post_type_requires_parent, on: :create | ||
| validate :post_type_has_category, on: :create | ||
| validate :can_post_in_category, on: :create | ||
| validate :identical_post_spam, on: :create | ||
| validate :no_active_spam_flags, on: :create | ||
|
|
||
| after_create :escalate_suspicious_cidr | ||
|
cellio marked this conversation as resolved.
|
||
|
|
||
| private | ||
|
|
||
| def no_mathjax_in_title | ||
| if title? && title.include?('$$') | ||
| errors.add(:base, I18n.t('posts.no_block_mathjax_title')) | ||
| end | ||
| end | ||
|
|
||
| def post_type_requires_parent | ||
| if post_type.has_parent? && parent.nil? | ||
| errors.add(:base, helpers.i18ns('posts.type_requires_parent', type: post_type.name)) | ||
| end | ||
| end | ||
|
|
||
| def post_type_has_category | ||
| if post_type.has_category? && category.nil? && parent.nil? | ||
| errors.add(:base, helpers.i18ns('posts.type_requires_category', type: post_type.name)) | ||
| end | ||
| end | ||
|
|
||
| def can_post_in_category | ||
| if category.present? && !user.can_post_in?(category) | ||
| errors.add(:base, helpers.i18ns('posts.category_low_trust_level', name: category.name)) | ||
| end | ||
| end | ||
|
|
||
| def identical_post_spam | ||
| threshold = AppConfig.spam_protection['identical_post_spam_threshold'] | ||
| prev_non_deleted_count = Post.unscoped.where(user: user, deleted: false).count | ||
| unless prev_non_deleted_count >= threshold | ||
| identical_posts = Post.unscoped.where(user: user, body_markdown: body_markdown).where.not(id: id) | ||
| if identical_posts.any? | ||
| errors.add(:base, I18n.t('posts.spam_blocked')) | ||
|
cellio marked this conversation as resolved.
|
||
| end | ||
| end | ||
| end | ||
|
|
||
| def no_active_spam_flags | ||
| posts_threshold = AppConfig.spam_protection['spam_flag_posts_threshold'] | ||
| time_threshold = AppConfig.spam_protection['spam_flag_time_threshold'] | ||
| prev_non_deleted_count = Post.unscoped.where(user: user, deleted: false).count | ||
| unless prev_non_deleted_count >= posts_threshold | ||
| active = Post.unscoped | ||
| .joins(Arel.sql("INNER JOIN flags ON flags.post_type = 'Post' AND flags.post_id = posts.id")) | ||
| .joins(Arel.sql('INNER JOIN post_flag_types ON flags.post_flag_type_id = post_flag_types.id')) | ||
| .where(flags: { status: nil }, | ||
| post_flag_types: { name: "it's spam" }, | ||
| posts: { user_id: user.id }) | ||
| helpful = Post.unscoped | ||
| .joins(Arel.sql("INNER JOIN flags ON flags.post_type = 'Post' AND flags.post_id = posts.id")) | ||
| .joins(Arel.sql('INNER JOIN post_flag_types ON flags.post_flag_type_id = post_flag_types.id')) | ||
| .where(flags: { status: 'helpful' }, | ||
| post_flag_types: { name: "it's spam" }, | ||
| posts: { user_id: user.id }) | ||
| .where('flags.created_at <= ?', time_threshold.days.ago) | ||
| if active.any? || helpful.any? | ||
| errors.add(:base, I18n.t('posts.spam_blocked')) | ||
| end | ||
| end | ||
| end | ||
|
|
||
| def escalate_suspicious_cidr | ||
| CheckCIDRJob.perform_later(self) | ||
| end | ||
| end | ||
| # rubocop:enable Metrics/BlockLength | ||
| end | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,11 @@ | ||
| registration_rate_limit: 300 | ||
| # Minimum number of seconds between registration attempts from the same IP. | ||
| registration_rate_limit: 3600 | ||
|
ArtOfCode- marked this conversation as resolved.
|
||
|
|
||
| # Number of minutes that sudo mode will last for before asking for password re-entry. | ||
| user_sudo_duration: 30 | ||
|
|
||
| # Base domain for the network: if you have communities for a.example.com and b.example.com, this should be example.com. | ||
| network_base_domain: codidact.com | ||
|
|
||
| # File path on your server to which database backups will be saved while awaiting upload to S3. | ||
| db_backups_path: /var/sql-backups | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| # When a user is deleted, helpful spam flags against the user's post will additionally cause the user to be fail-banned. | ||
| # Users with equal to or more than this reputation value in any community are exempt from this check. | ||
| deletion_block_max_rep: 100 | ||
|
|
||
| # Users are blocked from posting something identical to any of their previous posts, unless they have equal to or | ||
| # greater than this number of non-deleted posts anywhere on the network. | ||
| identical_post_spam_threshold: 10 | ||
|
|
||
| # Users are blocked from posting if there is an active (i.e. not reviewed) spam flag against any of their posts. Users | ||
| # with equal to or greater than this number of non-deleted posts in any community are exempt from this check. | ||
| spam_flag_posts_threshold: 10 | ||
|
|
||
| # Helpful spam flags will also block posting as above for up to this number of days after the flag was cast. | ||
| spam_flag_time_threshold: 7 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -17,4 +17,5 @@ | |
|
|
||
| ActiveSupport::Inflector.inflections(:en) do |inflect| | ||
| inflect.acronym 'SE' | ||
| inflect.acronym 'CIDR' | ||
| end | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,40 @@ | ||
| module UserTestHelpers | ||
|
Member
Author
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. This whole file is just the existing helpers extracted from users_controller_test.rb to allow for a restructure. |
||
| extend ActiveSupport::Concern | ||
|
|
||
| included do | ||
| private | ||
|
|
||
| def create_other_user | ||
| other_community = Community.create(host: 'other.qpixel.com', name: 'Other') | ||
| RequestContext.redis.hset('network/community_registrations', 'other@example.com', other_community.id) | ||
| other_user = User.create!(email: 'other@example.com', password: 'abcdefghijklmnopqrstuvwxyz', username: 'other_user') | ||
| other_user.community_users.create!(community: other_community) | ||
| other_user | ||
| end | ||
|
|
||
| # @param type [String] deletion type (user or profile) | ||
| # @param user [User] user to soft delete | ||
| def try_soft_delete_user(type, user) | ||
| perform_enqueued_jobs do | ||
| delete :soft_delete, params: { id: user.id, | ||
| type: type, | ||
| format: :json } | ||
| end | ||
| end | ||
|
|
||
| def try_save_preference(name, value, community: nil) | ||
| post :set_preference, params: { | ||
| community: community, | ||
| name: name, | ||
| value: value, | ||
| format: :json | ||
| } | ||
| end | ||
|
|
||
| # @param user [User] user to undelete | ||
| def try_undelete_user(user) | ||
| post :undelete, params: { id: user.id, | ||
| format: :json } | ||
| end | ||
| end | ||
| end | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
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.
Apparently one of our system tests was relying on this redirecting, which was order-dependent, so I've moved it to always redirect when not signed in.