Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 12 additions & 6 deletions .github/test-env-configs.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -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"
}
]
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 8 additions & 0 deletions app/overrides/decidim/add_staging_warning.rb
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions app/views/decidim/pokecode/_staging_warning.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<div class="callout alert">
<p>
<strong><%= t("decidim.pokecode.staging_warning.title") %></strong><br>
<%= t("decidim.pokecode.staging_warning.message", emails: Decidim::Pokecode.allowed_recipients_list.map {|email| "<code>#{email}</code>"}.join(", ")).html_safe %>
</p>
</div>
7 changes: 4 additions & 3 deletions config/locales/en.yml
Original file line number Diff line number Diff line change
@@ -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}."
2 changes: 2 additions & 0 deletions lib/decidim/pokecode.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
8 changes: 8 additions & 0 deletions lib/decidim/pokecode/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down
9 changes: 9 additions & 0 deletions lib/decidim/pokecode/engine.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
49 changes: 49 additions & 0 deletions lib/decidim/pokecode/mail_interceptor.rb
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions spec/lib/overrides_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
171 changes: 171 additions & 0 deletions spec/mailers/mail_interceptor_spec.rb
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading