diff --git a/.github/test-env-configs.json b/.github/test-env-configs.json index da75e7b..88d25f8 100644 --- a/.github/test-env-configs.json +++ b/.github/test-env-configs.json @@ -11,7 +11,8 @@ "AWS_CDN_HOST": "https://cdn.example.com", "DISABLE_POKECODE_FOOTER": "", "DISABLE_LANGUAGE_MENU": "", - "DISABLE_ASSEMBLY_MEMBERS_VISIBLE": "" + "DISABLE_ASSEMBLY_MEMBERS_VISIBLE": "", + "ALLOWED_RECIPIENTS": "" }, { "env-config": "all-disabled", @@ -25,7 +26,8 @@ "AWS_CDN_HOST": "", "DISABLE_POKECODE_FOOTER": "1", "DISABLE_LANGUAGE_MENU": "1", - "DISABLE_ASSEMBLY_MEMBERS_VISIBLE": "1" + "DISABLE_ASSEMBLY_MEMBERS_VISIBLE": "1", + "ALLOWED_RECIPIENTS": "recipient1@example.com recipient2@example.com" }, { "env-config": "footer-only", @@ -39,7 +41,8 @@ "AWS_CDN_HOST": "", "DISABLE_POKECODE_FOOTER": "", "DISABLE_LANGUAGE_MENU": "1", - "DISABLE_ASSEMBLY_MEMBERS_VISIBLE": "1" + "DISABLE_ASSEMBLY_MEMBERS_VISIBLE": "1", + "ALLOWED_RECIPIENTS": "" }, { "env-config": "language-menu-only", @@ -53,7 +56,8 @@ "AWS_CDN_HOST": "", "DISABLE_POKECODE_FOOTER": "1", "DISABLE_LANGUAGE_MENU": "", - "DISABLE_ASSEMBLY_MEMBERS_VISIBLE": "1" + "DISABLE_ASSEMBLY_MEMBERS_VISIBLE": "1", + "ALLOWED_RECIPIENTS": "" }, { "env-config": "assembly-members-visible-only", @@ -67,7 +71,8 @@ "AWS_CDN_HOST": "", "DISABLE_POKECODE_FOOTER": "1", "DISABLE_LANGUAGE_MENU": "1", - "DISABLE_ASSEMBLY_MEMBERS_VISIBLE": "" + "DISABLE_ASSEMBLY_MEMBERS_VISIBLE": "", + "ALLOWED_RECIPIENTS": "recipient1@example.com recipient2@example.com" }, { "env-config": "admin-iframe-only", @@ -81,6 +86,7 @@ "AWS_CDN_HOST": "", "DISABLE_POKECODE_FOOTER": "1", "DISABLE_LANGUAGE_MENU": "1", - "DISABLE_ASSEMBLY_MEMBERS_VISIBLE": "1" + "DISABLE_ASSEMBLY_MEMBERS_VISIBLE": "1", + "ALLOWED_RECIPIENTS": "recipient1@example.com recipient2@example.com" } ] diff --git a/README.md b/README.md index a186f1d..df4c2b2 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ This plugin relies on the command `decidim:upgrade` to make sure common files ar | `AWS_PUBLIC` | Usually, to be used in combination with the previous option. This generates assets without signatures, which basically means they don't expire. | `true` | | | `AWS_FORCE_PATH_STYLE` | Certain providers do not support the bucket name as the subdomain of the AWS endpoint (ie: Contabo). Set to `true` if that's the case. | `false` | | | `CONTENT_SECURITY_POLICY` | Sets custom Content Security Policy headers for enhanced security. When set, it is added to the default CSP configuration. | `""` (disabled) | | +| `ALLOWED_RECIPIENTS` | A list of emails or domains that must match in order to send an email, separated by spaces. For instance `@pokecode.net johnsmith@gmail.com`. Exact email addresses (without a leading `@`) must match the full recipient email, while domain patterns starting with `@` are matched as suffixes of the recipient email. Leave empty to disable any interception. | `""` | ## Installation diff --git a/app/overrides/decidim/add_staging_warning.rb b/app/overrides/decidim/add_staging_warning.rb new file mode 100644 index 0000000..07a241e --- /dev/null +++ b/app/overrides/decidim/add_staging_warning.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +if Decidim::Pokecode.allowed_recipients_list.any? + Deface::Override.new(:virtual_path => "decidim/admin/dashboard/show", + :name => "add-staging-warning", + :insert_before => "div.content", + :partial => "decidim/pokecode/staging_warning") +end diff --git a/app/views/decidim/pokecode/_staging_warning.html.erb b/app/views/decidim/pokecode/_staging_warning.html.erb new file mode 100644 index 0000000..69a9017 --- /dev/null +++ b/app/views/decidim/pokecode/_staging_warning.html.erb @@ -0,0 +1,6 @@ +
+

+ <%= t("decidim.pokecode.staging_warning.title") %>
+ <%= t("decidim.pokecode.staging_warning.message", emails: Decidim::Pokecode.allowed_recipients_list.map {|email| "#{email}"}.join(", ")).html_safe %> +

+
diff --git a/config/locales/en.yml b/config/locales/en.yml index 232ff21..f893e0e 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -1,6 +1,7 @@ --- en: decidim: - components: - Pokecode: - name: Pokecode + pokecode: + staging_warning: + title: "Warning: Staging Environment" + message: "Emails sent from this instance may not reach their intended recipients. Allowed destinations are: %{emails}." diff --git a/lib/decidim/pokecode.rb b/lib/decidim/pokecode.rb index 2cf4bda..4e96e76 100644 --- a/lib/decidim/pokecode.rb +++ b/lib/decidim/pokecode.rb @@ -19,6 +19,8 @@ require "sidekiq/cron" end +require "decidim/pokecode/mail_interceptor" if Decidim::Pokecode.allowed_recipients_list.any? + require "decidim/pokecode/admin" require "decidim/pokecode/engine" require "decidim/pokecode/admin_engine" diff --git a/lib/decidim/pokecode/configuration.rb b/lib/decidim/pokecode/configuration.rb index 7e7e4c4..a120730 100644 --- a/lib/decidim/pokecode/configuration.rb +++ b/lib/decidim/pokecode/configuration.rb @@ -65,6 +65,10 @@ module Pokecode host.present? && host.starts_with?("https://") ? host : "" end + config_accessor :allowed_recipients do + Decidim::Env.new("ALLOWED_RECIPIENTS", "").value + end + config_accessor :content_security_policies_extra do { "connect-src" => ENV.fetch("CONTENT_SECURITY_POLICY", "").split, @@ -86,6 +90,10 @@ def self.rack_attack_ips Pokecode.rack_attack_allowed_ips&.split(/[,\s]+/)&.reject(&:blank?) || [] end + def self.allowed_recipients_list + Pokecode.allowed_recipients&.split(/[,\s]+/)&.reject(&:blank?) || [] + end + def self.deface_enabled Pokecode.pokecode_footer_enabled || Decidim::Pokecode.language_menu_enabled end diff --git a/lib/decidim/pokecode/engine.rb b/lib/decidim/pokecode/engine.rb index ed4cc65..04c1915 100644 --- a/lib/decidim/pokecode/engine.rb +++ b/lib/decidim/pokecode/engine.rb @@ -168,6 +168,15 @@ class Engine < ::Rails::Engine initializer "pokecode.shakapacker.assets_path" do Decidim.register_assets_path File.expand_path("app/packs", root) end + + initializer "pokecode.mail_interceptor" do + if Decidim::Pokecode.allowed_recipients_list.any? + Mail.register_interceptor(Decidim::Pokecode::MailInterceptor) + Rails.logger.info "[Decidim::Pokecode] Email interceptor enabled. Allowed recipients: #{Decidim::Pokecode.allowed_recipients_list.join(", ")}" + else + Rails.logger.info "[Decidim::Pokecode] Email interceptor disabled." + end + end end end end diff --git a/lib/decidim/pokecode/mail_interceptor.rb b/lib/decidim/pokecode/mail_interceptor.rb new file mode 100644 index 0000000..0538a06 --- /dev/null +++ b/lib/decidim/pokecode/mail_interceptor.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module Decidim + module Pokecode + # Email interceptor that filters outgoing emails based on allowed recipients. + # Only allows emails to recipients that match the configured allowed list. + class MailInterceptor + def self.delivering_email(message) + return if allowed_recipients_empty? + + # Get all recipients from the email message + to_recipients = Array(message.to).map(&:downcase) + cc_recipients = Array(message.cc).map(&:downcase) + bcc_recipients = Array(message.bcc).map(&:downcase) + + all_recipients = to_recipients + cc_recipients + bcc_recipients + + # Filter recipients to only those in the allowed list + filtered_recipients = all_recipients.select { |recipient| allowed?(recipient) } + + if filtered_recipients.empty? + Rails.logger.warn "[Decidim::Pokecode] Email delivery intercepted. No recipients matched allowed list. Original recipients: #{all_recipients.join(", ")}" + message.perform_deliveries = false + else + # Update the message with only the allowed recipients + message.to = (to_recipients & filtered_recipients).uniq + message.cc = (cc_recipients & filtered_recipients).uniq + message.bcc = (bcc_recipients & filtered_recipients).uniq + + Rails.logger.info "[Decidim::Pokecode] Email delivery filtered. Original recipients: #{all_recipients.join(", ")}, Allowed recipients: #{filtered_recipients.join(", ")}" + end + end + + def self.allowed_recipients_empty? + Decidim::Pokecode.allowed_recipients_list.empty? + end + + def self.allowed?(email) + email_lowercase = email.downcase + Decidim::Pokecode.allowed_recipients_list.any? do |allowed| + allowed_lowercase = allowed.downcase + # Check if the email matches or ends with the allowed value + # This allows both exact email matches and domain-based matches (e.g., @pokecode.net) + email_lowercase == allowed_lowercase || email_lowercase.end_with?(allowed_lowercase.to_s.start_with?("@") ? allowed_lowercase : "@#{allowed_lowercase}") + end + end + end + end +end diff --git a/spec/lib/overrides_spec.rb b/spec/lib/overrides_spec.rb index ffd67e0..ac7422f 100644 --- a/spec/lib/overrides_spec.rb +++ b/spec/lib/overrides_spec.rb @@ -20,6 +20,12 @@ "/app/permissions/decidim/assemblies/permissions.rb" => "f26397a30c34eeb60af141b8ef0eb1bb" } }, + { + package: "decidim-admin", + files: { + "/app/views/decidim/admin/dashboard/show.html.erb" => "45558619f30212c2aa079e744c4be4ea" + } + }, { package: "aws-sdk-s3", files: { diff --git a/spec/mailers/mail_interceptor_spec.rb b/spec/mailers/mail_interceptor_spec.rb new file mode 100644 index 0000000..972c512 --- /dev/null +++ b/spec/mailers/mail_interceptor_spec.rb @@ -0,0 +1,171 @@ +# frozen_string_literal: true + +require "spec_helper" +require "decidim/pokecode/mail_interceptor" + +module Decidim::Pokecode + describe MailInterceptor do + let(:message) do + instance_double( + Mail::Message, + to: to_recipients, + cc: cc_recipients, + bcc: bcc_recipients, + perform_deliveries: true + ) + end + + let(:to_recipients) { [] } + let(:cc_recipients) { [] } + let(:bcc_recipients) { [] } + let(:allowed_recipients) { [] } + + before do + allow(Rails.logger).to receive(:warn) + allow(Rails.logger).to receive(:info) + allow(message).to receive(:to=) + allow(message).to receive(:cc=) + allow(message).to receive(:bcc=) + allow(message).to receive(:perform_deliveries=) + end + + describe ".delivering_email" do + before do + allow(Decidim::Pokecode).to receive(:allowed_recipients_list).and_return(allowed_recipients) + end + + context "when allowed_recipients is empty" do + it "does not intercept the email" do + described_class.delivering_email(message) + expect(message).not_to have_received(:perform_deliveries=) + end + end + + context "when allowed_recipients is configured" do + context "with domain-based allowed recipients" do + let(:allowed_recipients) { ["@pokecode.net"] } + + context "when recipients match the allowed domain" do + let(:to_recipients) { ["user@pokecode.net", "admin@pokecode.net"] } + + it "allows the email" do + described_class.delivering_email(message) + expect(message).not_to have_received(:perform_deliveries=) + expect(message).to have_received(:to=).with(["user@pokecode.net", "admin@pokecode.net"].uniq) + end + + it "logs the filtering" do + described_class.delivering_email(message) + expect(Rails.logger).to have_received(:info) + end + end + + context "when recipients don't match the allowed domain" do + let(:to_recipients) { ["user@example.com", "admin@gmail.com"] } + + it "blocks the email" do + described_class.delivering_email(message) + expect(message).to have_received(:perform_deliveries=).with(false) + end + + it "logs the interception" do + described_class.delivering_email(message) + expect(Rails.logger).to have_received(:warn) + end + end + + context "with mixed recipients" do + let(:to_recipients) { ["user@pokecode.net", "admin@example.com"] } + let(:cc_recipients) { ["cc@pokecode.net"] } + let(:bcc_recipients) { ["bcc@gmail.com"] } + + it "filters to only allowed recipients" do + described_class.delivering_email(message) + expect(message).to have_received(:to=).with(["user@pokecode.net"]) + expect(message).to have_received(:cc=).with(["cc@pokecode.net"]) + expect(message).to have_received(:bcc=).with([]) + end + end + end + + context "with exact email allowed recipients" do + let(:allowed_recipients) { ["john@example.com", "jane@example.com"] } + + context "when recipients match exactly" do + let(:to_recipients) { ["john@example.com"] } + + it "allows the email" do + described_class.delivering_email(message) + expect(message).not_to have_received(:perform_deliveries=) + expect(message).to have_received(:to=).with(["john@example.com"]) + end + end + + context "when recipients don't match" do + let(:to_recipients) { ["bob@example.com"] } + + it "blocks the email" do + described_class.delivering_email(message) + expect(message).to have_received(:perform_deliveries=).with(false) + end + end + end + + context "with mixed allowed recipients" do + let(:allowed_recipients) { ["@pokecode.net", "external@gmail.com"] } + + context "when recipients match domain or exact email" do + let(:to_recipients) { ["user@pokecode.net", "external@gmail.com"] } + + it "allows the email" do + described_class.delivering_email(message) + expect(message).not_to have_received(:perform_deliveries=) + expect(message).to have_received(:to=).with(["user@pokecode.net", "external@gmail.com"]) + end + end + + context "when some recipients don't match" do + let(:to_recipients) { ["user@pokecode.net", "other@gmail.com"] } + + it "filters to only allowed recipients" do + described_class.delivering_email(message) + expect(message).to have_received(:to=).with(["user@pokecode.net"]) + end + end + end + + context "with case-insensitive matching" do + let(:allowed_recipients) { ["@pokecode.net", "John@Example.Com"] } + + context "with uppercase recipients" do + let(:to_recipients) { ["USER@POKECODE.NET", "JOHN@EXAMPLE.COM"] } + + it "matches case-insensitively" do + described_class.delivering_email(message) + expect(message).to have_received(:to=).with(["user@pokecode.net", "john@example.com"]) + end + end + + context "with mixed case recipients" do + let(:to_recipients) { ["user@PokeCode.Net", "John@example.com"] } + + it "matches case-insensitively" do + described_class.delivering_email(message) + expect(message).to have_received(:to=).with(["user@pokecode.net", "john@example.com"]) + end + end + end + + context "when only BCC recipients match" do + let(:allowed_recipients) { ["@pokecode.net"] } + let(:bcc_recipients) { ["admin@pokecode.net"] } + + it "allows the email with only BCC" do + described_class.delivering_email(message) + expect(message).to have_received(:bcc=).with(["admin@pokecode.net"]) + end + end + end + end + end +end diff --git a/spec/system/allowed_recipients_spec.rb b/spec/system/allowed_recipients_spec.rb new file mode 100644 index 0000000..74256d4 --- /dev/null +++ b/spec/system/allowed_recipients_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require "spec_helper" + +describe "AllowedRecipients" do + let!(:organization) { create(:organization) } + let!(:user) { create(:user, :confirmed, :admin, organization:) } + + before do + switch_to_host(organization.host) + login_as user, scope: :user + visit decidim_admin.root_path + end + + if Decidim::Pokecode.allowed_recipients_list.any? + it "shows the staging warning in the admin dashboard" do + expect(page).to have_content("Warning: Staging Environment") + Decidim::Pokecode.allowed_recipients_list.each do |email| + expect(page).to have_content(email) + end + end + else + it "does not show the staging warning in the admin dashboard" do + expect(page).to have_no_content("Warning: Staging Environment") + end + end +end