diff --git a/app/controllers/admin/settings/protections_controller.rb b/app/controllers/admin/settings/protections_controller.rb new file mode 100644 index 0000000000..2d33a44285 --- /dev/null +++ b/app/controllers/admin/settings/protections_controller.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class Admin::Settings::ProtectionsController < Admin::SettingsController + private + + def after_update_redirect_path + admin_settings_protections_path + end +end diff --git a/app/javascript/material-icons/400-24px/security.svg b/app/javascript/material-icons/400-24px/security.svg new file mode 100644 index 0000000000..ecb1f583b1 --- /dev/null +++ b/app/javascript/material-icons/400-24px/security.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/lib/activitypub/activity/create.rb b/app/lib/activitypub/activity/create.rb index f7c723757e..2798147622 100644 --- a/app/lib/activitypub/activity/create.rb +++ b/app/lib/activitypub/activity/create.rb @@ -14,7 +14,7 @@ class ActivityPub::Activity::Create < ActivityPub::Activity private def create_status - return reject_payload! if unsupported_object_type? || non_matching_uri_hosts?(@account.uri, object_uri) || tombstone_exists? || !related_to_local_activity? + return reject_payload! if unsupported_object_type? || non_matching_uri_hosts?(@account.uri, object_uri) || tombstone_exists? || !related_to_local_activity? || reject_pattern? with_redis_lock("create:#{object_uri}") do return if delete_arrived_first?(object_uri) || poll_vote? @@ -450,6 +450,10 @@ class ActivityPub::Activity::Create < ActivityPub::Activity Tombstone.exists?(uri: object_uri) end + def reject_pattern? + Setting.reject_pattern.present? && @object['content']&.match?(Setting.reject_pattern) + end + def forward_for_reply return unless @status.distributable? && @json['signature'].present? && reply_to_local? diff --git a/app/models/form/admin_settings.rb b/app/models/form/admin_settings.rb index 5f23e683b9..a34c1f5450 100644 --- a/app/models/form/admin_settings.rb +++ b/app/models/form/admin_settings.rb @@ -41,6 +41,7 @@ class Form::AdminSettings app_icon favicon min_age + reject_pattern ).freeze INTEGER_KEYS = %i( @@ -94,6 +95,7 @@ class Form::AdminSettings validates :media_cache_retention_period, :content_cache_retention_period, :backups_retention_period, numericality: { only_integer: true }, allow_blank: true, if: -> { defined?(@media_cache_retention_period) || defined?(@content_cache_retention_period) || defined?(@backups_retention_period) } validates :min_age, numericality: { only_integer: true }, allow_blank: true, if: -> { defined?(@min_age) } validates :site_short_description, length: { maximum: DESCRIPTION_LIMIT }, if: -> { defined?(@site_short_description) } + validates :reject_pattern, regexp_syntax: true, if: -> { defined?(@reject_pattern) } validates :status_page_url, url: true, allow_blank: true validate :validate_site_uploads diff --git a/app/validators/regexp_syntax_validator.rb b/app/validators/regexp_syntax_validator.rb new file mode 100644 index 0000000000..dd8dc80c9f --- /dev/null +++ b/app/validators/regexp_syntax_validator.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class RegexpSyntaxValidator < ActiveModel::EachValidator + def validate_each(record, attribute, value) + return if value.blank? + + Regexp.compile(value) + rescue RegexpError => e + record.errors.add(attribute, I18n.t('applications.invalid_regexp', message: e.message)) + end +end diff --git a/app/views/admin/settings/protections/show.html.haml b/app/views/admin/settings/protections/show.html.haml new file mode 100644 index 0000000000..cdf9e3890c --- /dev/null +++ b/app/views/admin/settings/protections/show.html.haml @@ -0,0 +1,22 @@ +- content_for :page_title do + = t('admin.settings.protections.title') + +- content_for :heading do + %h2= t('admin.settings.title') + = render partial: 'admin/settings/shared/links' + += simple_form_for @admin_settings, url: admin_settings_protections_path, html: { method: :patch } do |f| + = render 'shared/error_messages', object: @admin_settings + + %p.lead= t('admin.settings.protections.preamble') + + %h4= t('admin.settings.protections.activitypub') + + .fields-group + = f.input :reject_pattern, + as: :text, + input_html: { rows: 8 }, + wrapper: :with_block_label + + .actions + = f.button :button, t('generic.save_changes'), type: :submit diff --git a/app/views/admin/settings/shared/_links.html.haml b/app/views/admin/settings/shared/_links.html.haml index c03e5cfd9f..9da99a2e1f 100644 --- a/app/views/admin/settings/shared/_links.html.haml +++ b/app/views/admin/settings/shared/_links.html.haml @@ -7,3 +7,4 @@ primary.item :discovery, safe_join([material_symbol('search'), t('admin.settings.discovery.title')]), admin_settings_discovery_path primary.item :content_retention, safe_join([material_symbol('history'), t('admin.settings.content_retention.title')]), admin_settings_content_retention_path primary.item :appearance, safe_join([material_symbol('computer'), t('admin.settings.appearance.title')]), admin_settings_appearance_path + primary.item :protections, safe_join([material_symbol('security'), t('admin.settings.protections.title')]), admin_settings_protections_path diff --git a/config/locales/en.yml b/config/locales/en.yml index 4df63f4c73..85ca69fc2e 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -842,6 +842,10 @@ en: all: To everyone disabled: To no one users: To logged-in local users + protections: + activitypub: ActivityPub + preamble: Settings to protect your server + title: Protection registrations: moderation_recommandation: Please make sure you have an adequate and reactive moderation team before you open registrations to everyone! preamble: Control who can create an account on your server. @@ -1178,6 +1182,7 @@ en: applications: created: Application successfully created destroyed: Application successfully deleted + invalid_regexp: 'The provided Regexp is invalid: %{message}' logout: Logout regenerate_token: Regenerate access token token_regenerated: Access token successfully regenerated diff --git a/config/locales/simple_form.en.yml b/config/locales/simple_form.en.yml index 74614d1af6..086c4377af 100644 --- a/config/locales/simple_form.en.yml +++ b/config/locales/simple_form.en.yml @@ -94,6 +94,7 @@ en: min_age: Users will be asked to confirm their date of birth during sign-up peers_api_enabled: A list of domain names this server has encountered in the fediverse. No data is included here about whether you federate with a given server, just that your server knows about it. This is used by services that collect statistics on federation in a general sense. profile_directory: The profile directory lists all users who have opted-in to be discoverable. + reject_pattern: Set a regular expression pattern to inspect Create Activity content, and refuse Activity if you match require_invite_text: When sign-ups require manual approval, make the “Why do you want to join?” text input mandatory rather than optional site_contact_email: How people can reach you for legal or support inquiries. site_contact_username: How people can reach you on Mastodon. @@ -285,6 +286,7 @@ en: peers_api_enabled: Publish list of discovered servers in the API profile_directory: Enable profile directory registrations_mode: Who can sign-up + reject_pattern: Reject Pattern require_invite_text: Require a reason to join show_domain_blocks: Show domain blocks show_domain_blocks_rationale: Show why domains were blocked diff --git a/config/routes/admin.rb b/config/routes/admin.rb index 3d9f24ae83..1dac31f902 100644 --- a/config/routes/admin.rb +++ b/config/routes/admin.rb @@ -68,6 +68,7 @@ namespace :admin do resource :about, only: [:show, :update], controller: 'about' resource :appearance, only: [:show, :update], controller: 'appearance' resource :discovery, only: [:show, :update], controller: 'discovery' + resource :protections, only: [:show, :update], controller: 'protections' end resources :site_uploads, only: [:destroy] diff --git a/config/settings.yml b/config/settings.yml index ba81fcb8c6..20503beb8e 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -52,6 +52,7 @@ defaults: &defaults backups_retention_period: 7 captcha_enabled: false allow_referer_origin: false + reject_pattern: '' development: <<: *defaults diff --git a/spec/lib/activitypub/activity/create_spec.rb b/spec/lib/activitypub/activity/create_spec.rb index 74c9f10718..5fe752abfe 100644 --- a/spec/lib/activitypub/activity/create_spec.rb +++ b/spec/lib/activitypub/activity/create_spec.rb @@ -26,6 +26,8 @@ RSpec.describe ActivityPub::Activity::Create do before do sender.update(uri: ActivityPub::TagManager.instance.uri_for(sender)) + Setting.reject_pattern = 'spam' + stub_request(:get, 'http://example.com/attachment.png').to_return(request_fixture('avatar.txt')) stub_request(:get, 'http://example.com/emoji.png').to_return(body: attachment_fixture('emojo.png')) stub_request(:get, 'http://example.com/emojib.png').to_return(body: attachment_fixture('emojo.png'), headers: { 'Content-Type' => 'application/octet-stream' }) @@ -95,6 +97,24 @@ RSpec.describe ActivityPub::Activity::Create do } end + let(:spam_object_json) do + { + id: [ActivityPub::TagManager.instance.uri_for(sender), 'post3'].join('/'), + type: 'Note', + to: [ + 'https://www.w3.org/ns/activitystreams#Public', + ActivityPub::TagManager.instance.uri_for(follower), + ], + content: '@bob lorem ispam', + published: 1.hour.ago.utc.iso8601, + updated: 1.hour.ago.utc.iso8601, + tag: { + type: 'Mention', + href: ActivityPub::TagManager.instance.uri_for(follower), + }, + } + end + def activity_for_object(json) { '@context': 'https://www.w3.org/ns/activitystreams', @@ -168,6 +188,17 @@ RSpec.describe ActivityPub::Activity::Create do # It has queued a mention resolve job expect(MentionResolveWorker).to have_enqueued_sidekiq_job(status.id, invalid_mention_json[:tag][:href], anything) end + + it 'do not process posts that contain reject patterns', :aggregate_failures do + stub_request(:get, spam_object_json[:id]).to_return(status: 500) + described_class.new(activity_for_object(spam_object_json), sender, delivery: true).perform + + # …it creates a status + status = Status.find_by(uri: spam_object_json[:id]) + + # Check the process did crash + expect(status.nil?).to be true + end end describe '#perform' do diff --git a/spec/system/admin/settings/protection_spec.rb b/spec/system/admin/settings/protection_spec.rb new file mode 100644 index 0000000000..107c844403 --- /dev/null +++ b/spec/system/admin/settings/protection_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe 'Admin::Settings::Protections' do + let(:admin_user) { Fabricate(:admin_user) } + + before { sign_in(admin_user) } + + it 'Saves changes to protections settings' do + visit admin_settings_protections_path + + fill_in reject_pattern_field, + with: 'https://foo.bar' + + click_on submit_button + + expect(page) + .to have_content(success_message) + end + + def reject_pattern_field + form_label 'form_admin_settings.reject_pattern' + end +end diff --git a/spec/validators/regexp_syntax_validator_spec.rb b/spec/validators/regexp_syntax_validator_spec.rb new file mode 100644 index 0000000000..d24ba89223 --- /dev/null +++ b/spec/validators/regexp_syntax_validator_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require 'rails_helper' + +RSpec.describe RegexpSyntaxValidator do + let(:record_class) do + Class.new do + include ActiveModel::Validations + + attr_accessor :text + + validates :text, regexp_syntax: true + end + end + + let(:record) { record_class.new } + + describe '#validate_each' do + context 'with a nil value' do + it 'does not add errors' do + record.text = nil + + expect(record).to be_valid + expect(record.errors).to be_empty + end + end + + context 'with a blank' do + it 'does not add errors' do + record.text = '' + + expect(record).to be_valid + expect(record.errors).to be_empty + end + end + + context 'with valid regexp' do + it 'does not add errors' do + record.text = 'spam|https://foo.bar' + + expect(record).to be_valid + expect(record.errors).to be_empty + end + end + + context 'with more lines than limit' do + it 'adds an error' do + record.text = '(' + + expect(record).to_not be_valid + expect(record.errors.where(:text)).to_not be_empty + end + end + end +end