diff --git a/app/controllers/admin/base_controller.rb b/app/controllers/admin/base_controller.rb index 633668e7a..797ded423 100644 --- a/app/controllers/admin/base_controller.rb +++ b/app/controllers/admin/base_controller.rb @@ -1,4 +1,5 @@ class Admin::BaseController < ApplicationController + include IpDeniedHandler layout "admin" before_action :authenticate_user! diff --git a/app/controllers/concerns/ip_denied_handler.rb b/app/controllers/concerns/ip_denied_handler.rb new file mode 100644 index 000000000..3a2e9acd5 --- /dev/null +++ b/app/controllers/concerns/ip_denied_handler.rb @@ -0,0 +1,17 @@ +module IpDeniedHandler + extend ActiveSupport::Concern + + included do + before_action :restrict_ip, unless: :allowed_ip? + end + +private + + def restrict_ip + redirect_to root_path, alert: t("ip_denied_handler.unauthorized") + end + + def allowed_ip? + RestrictAdminIps.new(request.remote_ip).allowed? + end +end diff --git a/app/lib/restrict_admin_ips.rb b/app/lib/restrict_admin_ips.rb new file mode 100644 index 000000000..6c00f8dff --- /dev/null +++ b/app/lib/restrict_admin_ips.rb @@ -0,0 +1,37 @@ +class RestrictAdminIps + attr_reader :ip + + def initialize(ip) + @ip = ip + end + + def allowed? + unrestricted_access? || allowed_ip? + end + + private + + def unrestricted_access? + allowed_ips.blank? + end + + def allowed_ips + Array(Tenant.current_secrets.dig(:security, :allowed_admin_ips)) + end + + def allowed_ip? + normalized_allowed_ips.any? { |allowed_ip| allowed_ip.include?(ip) } + rescue IPAddr::Error + false + end + + def normalized_allowed_ips + allowed_ips.map do |allowed_ip| + IPAddr.new(allowed_ip) + rescue IPAddr::Error + Rails.logger.warn "Your allowed_admin_ips configuration includes the " \ + "address \"#{allowed_ip}\", which is not valid" + nil + end.compact + end +end diff --git a/config/locales/en/general.yml b/config/locales/en/general.yml index 3137ea334..5168cbd9d 100644 --- a/config/locales/en/general.yml +++ b/config/locales/en/general.yml @@ -759,6 +759,8 @@ en: youtube: "%{org} YouTube" telegram: "%{org} Telegram" instagram: "%{org} Instagram" + ip_denied_handler: + unauthorized: "Access denied. Your IP address is not allowed." unauthorized: default: You do not have permission to access this page. manage: diff --git a/config/locales/es/general.yml b/config/locales/es/general.yml index 0e658e9ec..e8bf22da2 100644 --- a/config/locales/es/general.yml +++ b/config/locales/es/general.yml @@ -759,6 +759,8 @@ es: youtube: "YouTube de %{org}" telegram: "Telegram de %{org}" instagram: "Instagram de %{org}" + ip_denied_handler: + unauthorized: "Acceso denegado. Tu IP no tiene permiso para ver este contenido." unauthorized: default: No tienes permiso para acceder a esta página. manage: diff --git a/config/secrets.yml.example b/config/secrets.yml.example index b785db667..4f298b089 100644 --- a/config/secrets.yml.example +++ b/config/secrets.yml.example @@ -23,6 +23,7 @@ development: devise_lockable: false multitenancy: false security: + # allowed_admin_ips: ["123.45.67.89", "192.168.1.0/24"] last_sign_in: false password_complexity: false # lockable: @@ -64,6 +65,7 @@ staging: managers_application_key: "" multitenancy: false security: + # allowed_admin_ips: ["123.45.67.89", "192.168.1.0/24"] last_sign_in: false password_complexity: false # lockable: @@ -118,6 +120,7 @@ preproduction: managers_application_key: "" multitenancy: false security: + # allowed_admin_ips: ["123.45.67.89", "192.168.1.0/24"] last_sign_in: false password_complexity: false # lockable: @@ -171,6 +174,7 @@ production: managers_application_key: "" multitenancy: false security: + # allowed_admin_ips: ["123.45.67.89", "192.168.1.0/24"] last_sign_in: false password_complexity: false # lockable: diff --git a/spec/controllers/admin/base_controller_spec.rb b/spec/controllers/admin/base_controller_spec.rb new file mode 100644 index 000000000..a869c4cc8 --- /dev/null +++ b/spec/controllers/admin/base_controller_spec.rb @@ -0,0 +1,31 @@ +require "rails_helper" + +describe Admin::BaseController, :admin do + controller do + def index + render plain: "Index" + end + end + + describe "#restrict_ip" do + before do + stub_secrets(security: { allowed_admin_ips: ["1.2.3.4", "5.6.7.8"] }) + end + + it "renders the content when the IP is allowed" do + request.env["REMOTE_ADDR"] = "1.2.3.4" + get :index + + expect(response).to be_successful + expect(response.body).to eq "Index" + end + + it "redirects to the root path when the IP isn't allowed" do + request.env["REMOTE_ADDR"] = "9.10.11.12" + get :index + + expect(response).to redirect_to root_path + expect(flash[:alert]).to eq "Access denied. Your IP address is not allowed." + end + end +end diff --git a/spec/lib/restrict_admin_ips_spec.rb b/spec/lib/restrict_admin_ips_spec.rb new file mode 100644 index 000000000..e8f6a2f9d --- /dev/null +++ b/spec/lib/restrict_admin_ips_spec.rb @@ -0,0 +1,81 @@ +require "rails_helper" + +describe RestrictAdminIps do + it "allows any IP when allowed_admin_ips isn't configured" do + stub_secrets(security: {}) + + expect(RestrictAdminIps.new("1.2.3.4")).to be_allowed + expect(RestrictAdminIps.new("whatever")).to be_allowed + end + + it "allows any IP when allowed_admin_ips is empty" do + stub_secrets(security: { allowed_admin_ips: [] }) + + expect(RestrictAdminIps.new("1.2.3.4")).to be_allowed + expect(RestrictAdminIps.new("whatever")).to be_allowed + end + + it "only allows IPs present in allowed_admin_ips" do + stub_secrets(security: { allowed_admin_ips: ["1.2.3.4", "5.6.7.8"] }) + + expect(RestrictAdminIps.new("1.2.3.4")).to be_allowed + expect(RestrictAdminIps.new("5.6.7.8")).to be_allowed + expect(RestrictAdminIps.new("9.9.9.9")).not_to be_allowed + expect(RestrictAdminIps.new("whatever")).not_to be_allowed + end + + it "restricts every IP when there are only malformed IPs on the list" do + stub_secrets(security: { allowed_admin_ips: ["not_an_ip"] }) + + expect(RestrictAdminIps.new("1.2.3.4")).not_to be_allowed + expect(RestrictAdminIps.new("not_an_ip")).not_to be_allowed + end + + it "ignores malformed IPs in the allowed_admin_ips list" do + stub_secrets(security: { allowed_admin_ips: ["1.2.3.4", "not_an_ip"] }) + + expect(RestrictAdminIps.new("1.2.3.4")).to be_allowed + expect(RestrictAdminIps.new("not_an_ip")).not_to be_allowed + end + + it "supports ranges of IPs" do + stub_secrets(security: { allowed_admin_ips: ["1.2.3.0/16"] }) + + expect(RestrictAdminIps.new("1.2.3.4")).to be_allowed + expect(RestrictAdminIps.new("1.2.3.5")).to be_allowed + expect(RestrictAdminIps.new("5.6.7.8")).not_to be_allowed + end + + context "tenant overwriting secrets" do + before do + stub_secrets({ + security: { + allowed_admin_ips: ["1.2.3.4", "5.6.7.8"] + }, + tenants: { + private: { + security: { + allowed_admin_ips: ["127.0.0.1", "192.168.1.1"] + } + } + } + }) + end + + it "uses the general secrets for the main tenant" do + expect(RestrictAdminIps.new("1.2.3.4")).to be_allowed + expect(RestrictAdminIps.new("5.6.7.8")).to be_allowed + expect(RestrictAdminIps.new("127.0.0.1")).not_to be_allowed + expect(RestrictAdminIps.new("192.168.1.1")).not_to be_allowed + end + + it "uses the tenant secrets for a tenant" do + allow(Tenant).to receive(:current_schema).and_return("private") + + expect(RestrictAdminIps.new("127.0.0.1")).to be_allowed + expect(RestrictAdminIps.new("192.168.1.1")).to be_allowed + expect(RestrictAdminIps.new("1.2.3.4")).not_to be_allowed + expect(RestrictAdminIps.new("5.6.7.8")).not_to be_allowed + end + end +end