Skip to content
Draft
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
11 changes: 11 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -97,3 +97,14 @@ WEBPACK_SPEED=

# Logs
# ENABLE_LOG_RAGE=

# Rate limiting. Enabled in production and staging; every limit and period of
# lib/middlewares/rack_attack_rules.rb can be overridden without a deploy.
# RACK_ATTACK_ENABLED=
# RACK_ATTACK_SAFELIST_IPS=203.0.113.0/24,198.51.100.7
# RACK_ATTACK_AGENDAS_LIMIT=
# RACK_ATTACK_AGENDAS_PERIOD=
# RACK_ATTACK_AGENDAS_BOGUS_DATE_LIMIT=
# RACK_ATTACK_BUDGETS_EXECUTION_LIMIT=
# RACK_ATTACK_REQ_BURST_LIMIT=
# RACK_ATTACK_REQ_SUSTAINED_LIMIT=
10 changes: 7 additions & 3 deletions app/controllers/concerns/gobierto_people/dates_range_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,13 @@ module DatesRangeHelper
CALENDAR_WINDOW_PAST_YEARS = 10
CALENDAR_WINDOW_FUTURE_YEARS = 3

def self.calendar_date_window
today = Date.current
(today - CALENDAR_WINDOW_PAST_YEARS.years)..(today + CALENDAR_WINDOW_FUTURE_YEARS.years)
end

included do
helper_method :site_configuration_dates_range?, :filter_start_date, :filter_end_date, :date_range_params, :all_start_date, :all_end_date
helper_method :site_configuration_dates_range?, :filter_start_date, :filter_end_date, :date_range_params, :all_start_date, :all_end_date, :calendar_date_window
end

def date_range_params
Expand Down Expand Up @@ -64,8 +69,7 @@ def calendar_date_params_within_window?
end

def calendar_date_window
today = Date.current
(today - CALENDAR_WINDOW_PAST_YEARS.years)..(today + CALENDAR_WINDOW_FUTURE_YEARS.years)
DatesRangeHelper.calendar_date_window
end

private
Expand Down
20 changes: 20 additions & 0 deletions app/controllers/concerns/lograge_host.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,28 @@ module LogrageHost
extend ActiveSupport::Concern

# This will add request's host to lograge
#
# The address fields are read by config/initializers/lograge.rb. They tell
# whether the client addresses rate limiting counts are the visitors' own or
# the address of a proxy shared by a whole site.
def append_info_to_payload(payload)
super
payload[:host] = request.host
payload[:remote_ip] = resolved_remote_ip
payload[:ip] = request.ip
payload[:x_forwarded_for] = request.headers["X-Forwarded-For"]
payload[:cf_connecting_ip] = request.headers["CF-Connecting-IP"]
end

private

# Contradictory Client-IP and X-Forwarded-For headers are supplied by the
# client, and append_info_to_payload runs in the ensure of process_action:
# letting the raise through turns a rendered response into a 500. The other
# address fields still record what the client sent.
def resolved_remote_ip
remote_ip
rescue ActionDispatch::RemoteIp::IpSpoofAttackError
nil
end
end
29 changes: 26 additions & 3 deletions app/javascript/gobierto_people/modules/person_events_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import timeGridPlugin from '@fullcalendar/timegrid';
export class PersonEventsController {
constructor() {
const calendarEl = document.getElementById('calendar');
const errorEl = document.getElementById('calendar_error');

if (calendarEl) {
var onlyCalendar = window.location.search.indexOf("only_calendar") > -1;
Expand All @@ -14,18 +15,40 @@ export class PersonEventsController {
const calendar = new Calendar(calendarEl, {
plugins: [dayGridPlugin, timeGridPlugin],
locale: I18n.locale,
events: function ({ startStr, endStr }, successCallback) {
// Dates outside this range are rejected by the server, so navigation
// stops at its boundaries instead of asking for them.
validRange: {
start: calendarEl.dataset.windowStart,
end: calendarEl.dataset.windowEnd
},
// FullCalendar only writes event source errors to the console, so the
// message has to be rendered explicitly.
eventSourceFailure: function (error) {
if (errorEl) {
errorEl.textContent = error.message;
}
},
events: function ({ startStr, endStr }, successCallback, failureCallback) {
var params = {
start: startStr,
end: endStr,
};
if (onlyCalendar) {
params.only_calendar = true;
}
if (errorEl) {
errorEl.textContent = "";
}

fetch(`${eventsEndpoint}?${new URLSearchParams(params).toString()}`)
.then((r) => r.json())
.then((doc) => successCallback(doc.events));
.then((r) => {
if (!r.ok) {
throw new Error(r.status === 429 ? I18n.t("gobierto_calendars.fullcalendar.too_many_requests") : r.statusText);
}
return r.json();
})
.then((doc) => successCallback(doc.events))
.catch((error) => failureCallback(error));
},
headerToolbar: {
left: "prev,next today",
Expand Down
6 changes: 3 additions & 3 deletions app/javascript/lib/i18n/modules/translations.js

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions app/javascript/lib/shared/modules/autocomplete_settings.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export const AUTOCOMPLETE_DEFAULTS = {
dataType: 'json',
minChars: 3,
deferRequestBy: 300,
showNoSuggestionNotice: true,
noSuggestionNotice: 'Lo sentimos, pero no hay resultados.',
preserveInput: true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,8 @@
</div>

<% if request.path == gobierto_people_person_events_path(@person.slug) || show_only_calendar? %>
<div id='calendar'>
<div id='calendar_error' role="status"></div>
<div id='calendar' data-window-start="<%= calendar_date_window.first.iso8601 %>" data-window-end="<%= calendar_date_window.last.iso8601 %>">
</div>
<% end %>

Expand Down
1 change: 1 addition & 0 deletions config/initializers/lograge.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
remote_ip: event.payload[:remote_ip],
ip: event.payload[:ip],
x_forwarded_for: event.payload[:x_forwarded_for],
cf_connecting_ip: event.payload[:cf_connecting_ip],
user_agent: event.payload[:headers]&.[]("HTTP_USER_AGENT"),

params: event.payload[:params].except(*exceptions).to_json,
Expand Down
102 changes: 48 additions & 54 deletions config/initializers/rack_attack.rb
Original file line number Diff line number Diff line change
@@ -1,60 +1,54 @@
# frozen_string_literal: true

Rack::Attack.enabled = !Rails.env.test?

Rack::Attack.blocklist("block negative-year budget execution paths") do |request|
request.path.match?(%r{\A(?:/[a-z]{2})?/presupuestos/ejecucion/-\d+\z})
end

Rack::Attack.throttle("requests by ip Tier 1", limit: 20, period: 10.seconds) do |request|
request.ip
end

Rack::Attack.throttle("budgets execution by ip", limit: 10, period: 10.seconds) do |request|
request.ip if request.path.match?(%r{\A(?:/[a-z]{2})?/presupuestos/ejecucion(/|\z)})
end

Rack::Attack.throttle("agendas by ip", limit: 10, period: 10.seconds) do |request|
request.ip if request.path.match?(%r{\A(?:/[a-z]{2})?/agendas(/|\z)})
end

module RackAttackHelpers
AGENDAS_PATH_REGEX = %r{\A(?:/[a-z]{2})?/agendas(/|\z)}.freeze
CALENDAR_PARAMS = %w(date start_date end_date start end).freeze

def self.agendas_date_out_of_window?(request)
return false unless request.get?

today = Date.current
window = (today - GobiertoPeople::DatesRangeHelper::CALENDAR_WINDOW_PAST_YEARS.years)..
(today + GobiertoPeople::DatesRangeHelper::CALENDAR_WINDOW_FUTURE_YEARS.years)

CALENDAR_PARAMS.any? do |key|
raw = request.params[key]
next false if raw.blank?

parsed = Date.parse(raw.to_s) rescue nil
parsed.nil? || !window.cover?(parsed)
end
require_relative "../../lib/middlewares/rack_attack_rules"
require_relative "../../lib/middlewares/rack_attack_responder"

Rack::Attack.enabled = RackAttackRules.enabled?
Rack::Attack.throttled_responder = ->(request) { RackAttackResponder.throttled(request) }
Rack::Attack.blocklisted_responder = ->(request) { RackAttackResponder.blocklisted(request) }

RackAttackRules.install!

# One subscriber for every event type. The rule name lives in
# env["rack.attack.matched"]: without it every rule writes the same line and
# there is no way to tell from a log which limit a client hit.
ActiveSupport::Notifications.subscribe(/\.rack_attack\z/) do |_name, _start, _finish, _id, payload|
request = payload[:request]
env = request.env
match_type = env["rack.attack.match_type"].to_s
match_data = env["rack.attack.match_data"] || {}

# Safelisted requests are the common case; counting them is useful, logging
# them would drown the log.
unless match_type == "safelist"
fields = {
event: match_type,
rule: env["rack.attack.matched"],
discriminator: env["rack.attack.match_discriminator"],
count: match_data[:count],
limit: match_data[:limit],
period: match_data[:period],
client_ip: RackAttackRules.client_ip(request),
rack_ip: request.ip,
x_forwarded_for: request.get_header("HTTP_X_FORWARDED_FOR"),
cf_connecting_ip: request.get_header("HTTP_CF_CONNECTING_IP"),
host: request.host,
method: request.request_method,
path: request.fullpath.to_s.slice(0, 300),
user_agent: request.user_agent.to_s.slice(0, 200)
}.compact

Rails.logger.info("[rack_attack] #{fields.map { |key, value| "#{key}=#{value.to_s.inspect}" }.join(" ")}")
end
end

Rack::Attack.blocklist("fail2ban agendas bogus dates") do |request|
next false unless request.path.match?(RackAttackHelpers::AGENDAS_PATH_REGEX)

Rack::Attack::Fail2Ban.filter("agendas-#{request.ip}", maxretry: 5, findtime: 5.minutes, bantime: 1.hour) do
RackAttackHelpers.agendas_date_out_of_window?(request)
if defined?(::Appsignal) && ::Appsignal.respond_to?(:increment_counter)
# Tags stay low cardinality: the host is a customer domain and there are
# hundreds of them, so it belongs in the log, not in a metric tag.
::Appsignal.increment_counter(
"rack_attack_matches",
1,
rule: env["rack.attack.matched"].to_s,
type: match_type
)
end
end

Rack::Attack.throttle("requests by ip hourly", limit: 1000, period: 1.hour) do |request|
request.ip
end

ActiveSupport::Notifications.subscribe("throttle.rack_attack") do |name, start, finish, instrumenter_id, payload|
Rails.logger.info("[rack_attack] #{payload[:request].ip} #{payload[:request].request_method} #{payload[:request].fullpath}")
end

ActiveSupport::Notifications.subscribe("blocklist.rack_attack") do |name, start, finish, instrumenter_id, payload|
Rails.logger.info("[rack_attack blocklist] #{payload[:request].ip} #{payload[:request].request_method} #{payload[:request].fullpath}")
end
2 changes: 2 additions & 0 deletions config/locales/gobierto_calendars/views/ca.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,6 @@ ca:
list: Llista
month: Mes
today: Avui
too_many_requests: No hem pogut carregar l'agenda. Torna-ho a provar en uns
segons.
week: Setmana
2 changes: 2 additions & 0 deletions config/locales/gobierto_calendars/views/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,6 @@ en:
list: List
month: Month
today: Today
too_many_requests: The agenda could not be loaded. Please try again in a few
seconds.
week: Week
2 changes: 2 additions & 0 deletions config/locales/gobierto_calendars/views/es.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,6 @@ es:
list: Lista
month: Mes
today: Hoy
too_many_requests: No hemos podido cargar la agenda. Vuelve a intentarlo en
unos segundos.
week: Semana
Loading