-
-
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
base: develop
Are you sure you want to change the base?
Spam protection tools #2138
Changes from all commits
2913cc7
15c9eb8
c272d8a
6a86506
a08ed7f
4e5c1e9
02d3d8f
3e0ab42
f32f2a3
ab08aa4
c6a4eeb
ab1424a
29c9b98
bfe3033
fdb9187
8670298
21b5d33
49ef46c
d864950
fce21a7
710a3ba
97b822d
ebd2ebb
5fff126
ad4beaf
51ed50b
635633d
1f990d2
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 |
|---|---|---|
|
|
@@ -8,7 +8,11 @@ class Users::RegistrationsController < Devise::RegistrationsController | |
| def create | ||
| super do |user| | ||
| unless user.errors.any? | ||
| rate_limit = AppConfig.server_settings['registration_rate_limit'] | ||
| rate_limit = if Rails.env.development? | ||
| 0 | ||
| else | ||
| AppConfig.server_settings['registration_rate_limit'] | ||
| end | ||
|
Comment on lines
+11
to
+15
Member
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. Non-blocking, but I'd rather we had a way of overriding app config settings instead of hardcoding this. rate_limit = ENV.fetch("REGISTRATION_RATE_LIMIT") { AppConfig.server_settings['registration_rate_limit'] }This way we'll get the best of both worlds: configurability and a default that's guaranteed to be set at runtime. |
||
| ip_list = [user.current_sign_in_ip, request.remote_ip].compact | ||
| previous_ip_users = User.where(current_sign_in_ip: ip_list).or(User.where(last_sign_in_ip: ip_list)) | ||
| .where(created_at: rate_limit.seconds.ago..DateTime.now) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -342,7 +342,9 @@ def soft_delete | |
| return | ||
| end | ||
|
|
||
| @user.soft_delete(current_user) | ||
| max_rep = @user.community_users.maximum(:reputation) | ||
| check_threshold = AppConfig.spam_protection['deletion_block_max_rep'] | ||
| DeleteUserJob.perform_later(@user, current_user, perform_spam_check: max_rep < check_threshold) | ||
|
Comment on lines
+345
to
+347
Member
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. Let's move the threshold check to the job itself - it's not impossible to get the job scheduled when the user is not over the threshold but for it to execute when the user is actually over it. Non-blocking |
||
| else | ||
| render json: { status: 'failed', message: 'Unrecognised deletion type.' }, status: 400 | ||
| return | ||
|
|
||
| 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 |
| 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 | ||
| 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 | ||
| 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 | ||
| 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 |
| 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 | ||
| 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 | ||
Uh oh!
There was an error while loading. Please reload this page.