Skip to content

Commit 2c7fcac

Browse files
Add blocked email domains setting
1 parent ca5aad2 commit 2c7fcac

11 files changed

Lines changed: 470 additions & 0 deletions

File tree

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# frozen_string_literal: true
2+
3+
#-- copyright
4+
# OpenProject is an open source project management software.
5+
# Copyright (C) the OpenProject GmbH
6+
#
7+
# This program is free software; you can redistribute it and/or
8+
# modify it under the terms of the GNU General Public License version 3.
9+
#
10+
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
11+
# Copyright (C) 2006-2013 Jean-Philippe Lang
12+
# Copyright (C) 2010-2013 the ChiliProject Team
13+
#
14+
# This program is free software; you can redistribute it and/or
15+
# modify it under the terms of the GNU General Public License
16+
# as published by the Free Software Foundation; either version 2
17+
# of the License, or (at your option) any later version.
18+
#
19+
# This program is distributed in the hope that it will be useful,
20+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
21+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
22+
# GNU General Public License for more details.
23+
#
24+
# You should have received a copy of the GNU General Public License
25+
# along with this program; if not, write to the Free Software
26+
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
27+
#
28+
# See COPYRIGHT and LICENSE files for more details.
29+
#++
30+
31+
module Interceptors
32+
# Removes recipients on a domain listed in the `blocked_email_domains` setting.
33+
# The validation on User only applies to addresses as they are entered, so this
34+
# covers users whose domain was blocked after their account already existed.
35+
#
36+
# Mails left without any recipient are dropped by DoNotSendMailsWithoutRecipient,
37+
# which is why this has to run before it.
38+
module RemoveBlockedRecipients
39+
FIELDS = %i[to cc bcc].freeze
40+
41+
module_function
42+
43+
def delivering_email(mail)
44+
domains = ::OpenProject::BlockedEmailDomains.domains
45+
46+
return if domains.empty?
47+
48+
FIELDS.each { |field| remove_blocked(mail, field, domains) }
49+
end
50+
51+
def remove_blocked(mail, field, domains)
52+
addresses = Array(mail.send(field))
53+
allowed = addresses.reject { |address| ::OpenProject::BlockedEmailDomains.blocked?(address, domains:) }
54+
55+
return if allowed.size == addresses.size
56+
57+
Rails.logger.info do
58+
"Removed blocked #{field} recipients from '#{mail.subject}': #{(addresses - allowed).join(', ')}"
59+
end
60+
61+
mail.send(:"#{field}=", allowed)
62+
end
63+
end
64+
end

app/models/user.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,8 @@ def self.blocked_condition(blocked)
185185

186186
validates :mail, email: true, unless: Proc.new { |user| user.mail.blank? }
187187
validates :mail, length: { maximum: 256, allow_nil: true }
188+
# Only on change so that blocking a domain does not make its existing users unsaveable
189+
validates :mail, blocked_email_domain: true, if: Proc.new { |user| user.mail_changed? }
188190

189191
validates :password,
190192
confirmation: {
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# frozen_string_literal: true
2+
3+
#-- copyright
4+
# OpenProject is an open source project management software.
5+
# Copyright (C) the OpenProject GmbH
6+
#
7+
# This program is free software; you can redistribute it and/or
8+
# modify it under the terms of the GNU General Public License version 3.
9+
#
10+
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
11+
# Copyright (C) 2006-2013 Jean-Philippe Lang
12+
# Copyright (C) 2010-2013 the ChiliProject Team
13+
#
14+
# This program is free software; you can redistribute it and/or
15+
# modify it under the terms of the GNU General Public License
16+
# as published by the Free Software Foundation; either version 2
17+
# of the License, or (at your option) any later version.
18+
#
19+
# This program is distributed in the hope that it will be useful,
20+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
21+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
22+
# GNU General Public License for more details.
23+
#
24+
# You should have received a copy of the GNU General Public License
25+
# along with this program; if not, write to the Free Software
26+
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
27+
#
28+
# See COPYRIGHT and LICENSE files for more details.
29+
#++
30+
31+
# Rejects email addresses whose domain is listed in the `blocked_email_domains` setting.
32+
class BlockedEmailDomainValidator < ActiveModel::EachValidator
33+
def validate_each(record, attribute, value)
34+
return if value.blank?
35+
return unless OpenProject::BlockedEmailDomains.blocked?(value)
36+
37+
record.errors.add attribute, :blocked_domain
38+
end
39+
end

config/constants/settings/definition.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,12 @@ class Definition
219219
bcc_recipients: {
220220
default: true
221221
},
222+
blocked_email_domains: {
223+
format: :array,
224+
description: "Email domains that may not be used for user accounts. Subdomains are blocked as well. " \
225+
"Recipients on these domains are also skipped when sending emails.",
226+
default: []
227+
},
222228
boards_demo_data_available: {
223229
description: "Internal setting determining availability of demo seed data",
224230
default: false

config/initializers/register_mail_interceptors.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
# Do this here, so they aren't registered multiple times due to reloading in development mode.
3333
Rails.application.reloader.to_prepare do
3434
ApplicationMailer.register_interceptor Interceptors::DefaultHeaders
35+
ApplicationMailer.register_interceptor Interceptors::RemoveBlockedRecipients
3536
# following needs to be the last interceptor
3637
ApplicationMailer.register_interceptor Interceptors::DoNotSendMailsWithoutRecipient
3738
end

config/locales/en.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -497,6 +497,7 @@ en:
497497
before_or_equal_to: "must be before or equal to %{date}."
498498
blank: "can't be blank."
499499
blank_nested: "needs to have the property '%{property}' set."
500+
blocked_domain: "is not allowed. Please use an email address from a different domain."
500501
cannot_delete_mapping: "is required. Cannot be deleted."
501502
cant_link_a_work_package_with_a_descendant: "A work package cannot be linked to one of its subtasks."
502503
circular_dependency: "This relation would create a circular dependency."

docs/installation-and-operations/configuration/environment/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,7 @@ OPENPROJECT_BACKUP__INCLUDE__ATTACHMENTS (default=true) Allow inclusion of attac
158158
OPENPROJECT_BACKUP__INITIAL__WAITING__PERIOD (default=86400) Wait time before newly created backup tokens are usable
159159
OPENPROJECT_BCC__RECIPIENTS (default=true) Blind carbon copy recipients (bcc)
160160
OPENPROJECT_BLACKLISTED__ROUTES (default=[]) Blocked routes to prevent access to certain modules or pages
161+
OPENPROJECT_BLOCKED__EMAIL__DOMAINS (default=[]) Email domains that may not be used for user accounts. Subdomains are blocked as well. Recipients on these domains are also skipped when sending emails.
161162
OPENPROJECT_BOARDS__DEMO__DATA__AVAILABLE (default=false) Internal setting determining availability of demo seed data
162163
OPENPROJECT_BRUTE__FORCE__BLOCK__AFTER__FAILED__LOGINS (default=20) Number of login attempts per user before assuming brute force attack
163164
OPENPROJECT_BRUTE__FORCE__BLOCK__MINUTES (default=30) Number of minutes to block users after presumed brute force attack
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# frozen_string_literal: true
2+
3+
#-- copyright
4+
# OpenProject is an open source project management software.
5+
# Copyright (C) the OpenProject GmbH
6+
#
7+
# This program is free software; you can redistribute it and/or
8+
# modify it under the terms of the GNU General Public License version 3.
9+
#
10+
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
11+
# Copyright (C) 2006-2013 Jean-Philippe Lang
12+
# Copyright (C) 2010-2013 the ChiliProject Team
13+
#
14+
# This program is free software; you can redistribute it and/or
15+
# modify it under the terms of the GNU General Public License
16+
# as published by the Free Software Foundation; either version 2
17+
# of the License, or (at your option) any later version.
18+
#
19+
# This program is distributed in the hope that it will be useful,
20+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
21+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
22+
# GNU General Public License for more details.
23+
#
24+
# You should have received a copy of the GNU General Public License
25+
# along with this program; if not, write to the Free Software
26+
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
27+
#
28+
# See COPYRIGHT and LICENSE files for more details.
29+
#++
30+
31+
module OpenProject
32+
# Email domains that may not be used for user accounts, configured through the
33+
# `blocked_email_domains` setting. Setting it in configuration.yml or through an
34+
# environment variable makes it read only for administrators.
35+
module BlockedEmailDomains
36+
class << self
37+
# Pass `domains:` to check many addresses against one read of the setting.
38+
def blocked?(email, domains: self.domains)
39+
domain = domain_of(email)
40+
41+
return false if domain.blank?
42+
43+
domains.any? { |blocked| domain == blocked || domain.end_with?(".#{blocked}") }
44+
end
45+
46+
def domains
47+
normalize Setting.blocked_email_domains
48+
end
49+
50+
def domain_of(email)
51+
local, domain = email.to_s.rpartition("@").values_at(0, 2)
52+
53+
domain.strip.downcase.presence if local.present?
54+
end
55+
56+
private
57+
58+
def normalize(domains)
59+
list = domains.is_a?(String) ? domains.split(/[\s,]+/) : Array(domains)
60+
61+
list.filter_map do |domain|
62+
domain.to_s.strip.downcase.delete_prefix("@").delete_prefix(".").presence
63+
end
64+
end
65+
end
66+
end
67+
end
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
# frozen_string_literal: true
2+
3+
#-- copyright
4+
# OpenProject is an open source project management software.
5+
# Copyright (C) the OpenProject GmbH
6+
#
7+
# This program is free software; you can redistribute it and/or
8+
# modify it under the terms of the GNU General Public License version 3.
9+
#
10+
# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
11+
# Copyright (C) 2006-2013 Jean-Philippe Lang
12+
# Copyright (C) 2010-2013 the ChiliProject Team
13+
#
14+
# This program is free software; you can redistribute it and/or
15+
# modify it under the terms of the GNU General Public License
16+
# as published by the Free Software Foundation; either version 2
17+
# of the License, or (at your option) any later version.
18+
#
19+
# This program is distributed in the hope that it will be useful,
20+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
21+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
22+
# GNU General Public License for more details.
23+
#
24+
# You should have received a copy of the GNU General Public License
25+
# along with this program; if not, write to the Free Software
26+
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
27+
#
28+
# See COPYRIGHT and LICENSE files for more details.
29+
#++
30+
31+
require "spec_helper"
32+
33+
RSpec.describe OpenProject::BlockedEmailDomains do
34+
describe ".domain_of" do
35+
it "returns the lower cased domain" do
36+
expect(described_class.domain_of("Spam@Example.COM")).to eq "example.com"
37+
end
38+
39+
it "returns nil for values that are not addresses" do
40+
expect(described_class.domain_of("example.com")).to be_nil
41+
expect(described_class.domain_of("@example.com")).to be_nil
42+
expect(described_class.domain_of(nil)).to be_nil
43+
expect(described_class.domain_of("")).to be_nil
44+
end
45+
end
46+
47+
describe ".domains" do
48+
context "without configuration" do
49+
it "is empty" do
50+
expect(described_class.domains).to eq []
51+
end
52+
end
53+
54+
context "with a list", with_settings: { blocked_email_domains: [" Blocked.COM ", "@other.com", ".third.com", ""] } do
55+
it "normalizes the entries" do
56+
expect(described_class.domains).to eq %w[blocked.com other.com third.com]
57+
end
58+
end
59+
60+
context "with a separated string", with_settings: { blocked_email_domains: "blocked.com, other.com third.com" } do
61+
it "splits it" do
62+
expect(described_class.domains).to eq %w[blocked.com other.com third.com]
63+
end
64+
end
65+
end
66+
67+
describe ".blocked?", with_settings: { blocked_email_domains: ["blocked.com"] } do
68+
it "blocks the configured domain" do
69+
expect(described_class).to be_blocked("user@blocked.com")
70+
end
71+
72+
it "blocks it regardless of case" do
73+
expect(described_class).to be_blocked("User@Blocked.COM")
74+
end
75+
76+
it "blocks subdomains of it" do
77+
expect(described_class).to be_blocked("user@mail.blocked.com")
78+
end
79+
80+
it "does not block a domain that merely ends in the same characters" do
81+
expect(described_class).not_to be_blocked("user@notblocked.com")
82+
end
83+
84+
it "does not block other domains" do
85+
expect(described_class).not_to be_blocked("user@example.com")
86+
end
87+
88+
it "does not block blank values" do
89+
expect(described_class).not_to be_blocked(nil)
90+
expect(described_class).not_to be_blocked("")
91+
end
92+
end
93+
94+
describe ".blocked? without configuration" do
95+
it "blocks nothing" do
96+
expect(described_class).not_to be_blocked("user@blocked.com")
97+
end
98+
end
99+
end

0 commit comments

Comments
 (0)