Files
moreminimore-chat/config/initializers/rack_attack.rb
Vishnu Narayanan 039a311327 fix: consolidate widget rack-attack throttles with per-endpoint kill switches + saner defaults (#14376)
## Description

Consolidates all widget API throttles under the single
`ENABLE_RACK_ATTACK_WIDGET_API` flag, each with its own independent kill
switch and configurable limit, with defaults tuned from real prod
traffic.

Fixes https://linear.app/chatwoot/issue/INF-83
Related to https://linear.app/chatwoot/issue/INF-77

Parent flag stays default-true; installs that disabled it keep today's
behavior (no widget throttling). Each endpoint adds
`ENABLE_RACK_ATTACK_WIDGET_<X>` + `RATE_LIMIT_WIDGET_<X>`.

- **conversations create**: keyed on (IP, website_token), 30/min (was
per-IP 6/12h)
- **messages create**: new throttle, (IP, website_token), 60/min (was
unthrottled)
- **contact update**: fixes a dormant bug. `resource :contact` gives the
singular URL `/api/v1/widget/contact`, but the throttle checked plural
`/api/v1/widget/contacts` so it never fired. Now active (60/1h per IP).
- **widget load**: 5 to 200/1h, tuned from prod (real per-IP loads top
out ~50/hr; the higher tail is crawlers/scrapers)
- **transcript**: 5/1h retained

Token precedence: the (IP, website_token) throttles read `website_token`
via ActionDispatch (query wins), matching the controller, so a
body-supplied token cannot fork the throttle bucket.

## Type of change

- [x] Bug fix (non-breaking change which fixes an issue)

## How Has This Been Tested?

`ruby -c` and rubocop clean. No spec added, matching this file's
existing no-spec convention for throttles.

---------

Co-authored-by: Sony Mathew <2040199+sony-mathew@users.noreply.github.com>
2026-07-29 16:32:06 +05:30

368 lines
17 KiB
Ruby
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
class Rack::Attack
### Configure Cache ###
# If you don't want to use Rails.cache (Rack::Attack's default), then
# configure it here.
#
# Note: The store is only used for throttling (not blocklisting and
# safelisting). It must implement .increment and .write like
# ActiveSupport::Cache::Store
# Rack::Attack.cache.store = ActiveSupport::Cache::MemoryStore.new
# https://github.com/rack/rack-attack/issues/102
# Rails 7.1 automatically adds its own ConnectionPool around RedisCacheStore.
# Because `$velma` is *already* a ConnectionPool, double-wrapping causes
# Redis calls like `get` to hit the outer wrapper and explode.
# `pool: false` tells Rails to skip its internal pool and use ours directly.
# TODO: We can use build in connection pool in future upgrade
Rack::Attack.cache.store = ActiveSupport::Cache::RedisCacheStore.new(redis: $velma, pool: false)
class Request < ::Rack::Request
# You may need to specify a method to fetch the correct remote IP address
# if the web server is behind a load balancer.
def remote_ip
@remote_ip ||= (env['action_dispatch.remote_ip'] || ip).to_s
end
def allowed_ip?
default_allowed_ips = ['127.0.0.1', '::1']
env_allowed_ips = ENV.fetch('RACK_ATTACK_ALLOWED_IPS', '').split(',').map(&:strip)
(default_allowed_ips + env_allowed_ips).include?(remote_ip)
end
# Rails allows paths with extensions and trailing slashes, so compare against a normalized path.
# For example, /auth, /auth.json, and /auth/ should all use the same throttle.
def path_without_extensions
normalized_path = path[/^[^.]+/]
normalized_path == '/' ? normalized_path : normalized_path.sub(%r{/+\z}, '')
end
end
### Safelist IPs from Environment Variable ###
#
# This block ensures requests from any IP present in RACK_ATTACK_ALLOWED_IPS
# will bypass Rack::Attacks throttling rules.
#
# Example: RACK_ATTACK_ALLOWED_IPS="127.0.0.1,::1,192.168.0.10"
Rack::Attack.safelist('trusted IPs', &:allowed_ip?)
# Safelist health check endpoint so it never touches Redis for throttle tracking.
# This keeps /health fully dependency-free for ALB liveness checks.
Rack::Attack.safelist('health check') do |req|
req.path == '/health'
end
### Throttle Spammy Clients ###
# If any single client IP is making tons of requests, then they're
# probably malicious or a poorly-configured scraper. Either way, they
# don't deserve to hog all of the app server's CPU. Cut them off!
#
# Note: If you're serving assets through rack, those requests may be
# counted by rack-attack and this throttle may be activated too
# quickly. If so, enable the condition to exclude them from tracking.
# Throttle all requests by IP (60rpm)
#
# Key: "rack::attack:#{Time.now.to_i/:period}:req/ip:#{req.ip}"
throttle('req/ip', limit: ENV.fetch('RACK_ATTACK_LIMIT', '3000').to_i, period: 1.minute, &:ip)
###-----------------------------------------------###
###-----Authentication Related Throttling---------###
###-----------------------------------------------###
### Prevent Brute-Force Super Admin Login Attacks ###
throttle('super_admin_login/ip', limit: 5, period: 5.minutes) do |req|
req.ip if req.path_without_extensions == '/super_admin/sign_in' && req.post?
end
throttle('super_admin_login/email', limit: 5, period: 15.minutes) do |req|
if req.path_without_extensions == '/super_admin/sign_in' && req.post?
# NOTE: This line used to throw ArgumentError /rails/action_mailbox/sendgrid/inbound_emails : invalid byte sequence in UTF-8
# Hence placed in the if block
# ref: https://github.com/rack/rack-attack/issues/399
email = req.params['email'].presence || ActionDispatch::Request.new(req.env).params['email'].presence
email.to_s.downcase.gsub(/\s+/, '')
end
end
# ### Prevent Brute-Force Login Attacks ###
# Exclude MFA verification attempts from regular login throttling
throttle('login/ip', limit: 5, period: 5.minutes) do |req|
if req.path_without_extensions == '/auth/sign_in' && req.post? && req.params['mfa_token'].blank?
# Skip if this is an MFA verification request
req.ip
end
end
throttle('login/email', limit: 10, period: 15.minutes) do |req|
# Skip if this is an MFA verification request
if req.path_without_extensions == '/auth/sign_in' && req.post? && req.params['mfa_token'].blank?
# ref: https://github.com/rack/rack-attack/issues/399
# NOTE: This line used to throw ArgumentError /rails/action_mailbox/sendgrid/inbound_emails : invalid byte sequence in UTF-8
# Hence placed in the if block
email = req.params['email'].presence || ActionDispatch::Request.new(req.env).params['email'].presence
email.to_s.downcase.gsub(/\s+/, '')
end
end
## Reset password throttling
throttle('reset_password/ip', limit: 5, period: 30.minutes) do |req|
req.ip if req.path_without_extensions == '/auth/password' && req.post?
end
throttle('reset_password/email', limit: 5, period: 1.hour) do |req|
if req.path_without_extensions == '/auth/password' && req.post?
email = req.params['email'].presence || ActionDispatch::Request.new(req.env).params['email'].presence
email.to_s.downcase.gsub(/\s+/, '')
end
end
## Resend confirmation throttling (unauthenticated)
throttle('resend_confirmation/ip', limit: 5, period: 30.minutes) do |req|
req.ip if req.path_without_extensions == '/resend_confirmation' && req.post?
end
throttle('resend_confirmation/email', limit: 5, period: 1.hour) do |req|
if req.path_without_extensions == '/resend_confirmation' && req.post?
email = req.params['email'].presence || ActionDispatch::Request.new(req.env).params['email'].presence
email.to_s.downcase.gsub(/\s+/, '')
end
end
## Resend confirmation throttling (authenticated)
throttle('resend_confirmation_auth/ip', limit: 5, period: 30.minutes) do |req|
req.ip if req.path_without_extensions == '/api/v1/profile/resend_confirmation' && req.post?
end
## MFA throttling - prevent brute force attacks
throttle('mfa_verification/ip', limit: 5, period: 1.minute) do |req|
if req.path_without_extensions == '/api/v1/profile/mfa'
req.ip if req.delete? # Throttle disable attempts
elsif req.path_without_extensions.match?(%r{/api/v1/profile/mfa/(verify|backup_codes)})
req.ip if req.post? # Throttle verify and backup_codes attempts
end
end
# Separate rate limiting for MFA verification attempts
throttle('mfa_login/ip', limit: 10, period: 1.minute) do |req|
req.ip if req.path_without_extensions == '/auth/sign_in' && req.post? && req.params['mfa_token'].present?
end
throttle('mfa_login/token', limit: 10, period: 1.minute) do |req|
if req.path_without_extensions == '/auth/sign_in' && req.post?
# Track by MFA token to prevent brute force on a specific token
mfa_token = req.params['mfa_token'].presence
(mfa_token.presence)
end
end
## Prevent Brute-Force Signup Attacks ###
throttle('accounts/ip', limit: 5, period: 30.minutes) do |req|
req.ip if req.path_without_extensions == '/api/v1/accounts' && req.post?
end
##-----------------------------------------------##
###-----------------------------------------------###
###-----------Widget API Throttling---------------###
###-----------------------------------------------###
# Set ENABLE_RACK_ATTACK_WIDGET_API to false to disable all widget throttles (e.g. iframe embeds).
# Each throttle also has its own ENABLE_*/RATE_LIMIT_* override.
# TODO: Deprecate the blanket ENABLE_RACK_ATTACK_WIDGET_API switch after finding a better solution
if ActiveModel::Type::Boolean.new.cast(ENV.fetch('ENABLE_RACK_ATTACK_WIDGET_API', true))
## Conversation creation, keyed on (IP, website_token) so widgets behind a shared NAT get separate buckets.
if ActiveModel::Type::Boolean.new.cast(ENV.fetch('ENABLE_RACK_ATTACK_WIDGET_CONVERSATIONS', true))
throttle('api/v1/widget/conversations',
limit: ENV.fetch('RATE_LIMIT_WIDGET_CONVERSATIONS', '30').to_i,
period: 1.minute) do |req|
next unless req.path_without_extensions == '/api/v1/widget/conversations' && req.post?
# ActionDispatch precedence (query wins) matches the controller, so a body token can't fork the bucket.
token = ActionDispatch::Request.new(req.env).params['website_token'].presence
"#{req.ip}:#{token}" if token
end
end
## Message creation, keyed the same way, to cap single-conversation floods.
if ActiveModel::Type::Boolean.new.cast(ENV.fetch('ENABLE_RACK_ATTACK_WIDGET_MESSAGES', true))
throttle('api/v1/widget/messages',
limit: ENV.fetch('RATE_LIMIT_WIDGET_MESSAGES', '60').to_i,
period: 1.minute) do |req|
next unless req.path_without_extensions == '/api/v1/widget/messages' && req.post?
token = ActionDispatch::Request.new(req.env).params['website_token'].presence
"#{req.ip}:#{token}" if token
end
end
## Prevent Contact update Bombing in Widget API ###
if ActiveModel::Type::Boolean.new.cast(ENV.fetch('ENABLE_RACK_ATTACK_WIDGET_CONTACTS', true))
throttle('api/v1/widget/contact',
limit: ENV.fetch('RATE_LIMIT_WIDGET_CONTACTS', '60').to_i,
period: 1.hour) do |req|
req.ip if req.path_without_extensions == '/api/v1/widget/contact' && (req.patch? || req.put?)
end
end
## Prevent Conversation Bombing through repeated widget loads
if ActiveModel::Type::Boolean.new.cast(ENV.fetch('ENABLE_RACK_ATTACK_WIDGET_LOAD', true))
throttle('widget?website_token={website_token}&cw_conversation={x-auth-token}',
limit: ENV.fetch('RATE_LIMIT_WIDGET_LOAD', '200').to_i,
period: 1.hour) do |req|
req.ip if req.path_without_extensions == '/widget' && ActionDispatch::Request.new(req.env).params['cw_conversation'].blank?
end
end
## Prevent Transcript Bombing on Widget API ###
if ActiveModel::Type::Boolean.new.cast(ENV.fetch('ENABLE_RACK_ATTACK_WIDGET_TRANSCRIPT', true))
throttle('api/v1/widget/conversations/transcript',
limit: ENV.fetch('RATE_LIMIT_WIDGET_TRANSCRIPT', '5').to_i,
period: 1.hour) do |req|
req.ip if req.path_without_extensions == '/api/v1/widget/conversations/transcript' && req.post?
end
end
end
##-----------------------------------------------##
###-----------------------------------------------###
###----------Application API Throttling-----------###
###-----------------------------------------------###
## Prevent Abuse of Converstion Transcript APIs ###
throttle('/api/v1/accounts/:account_id/conversations/:conversation_id/transcript',
limit: ENV.fetch('RATE_LIMIT_CONVERSATION_TRANSCRIPT', '1000').to_i, period: 1.hour) do |req|
match_data = %r{/api/v1/accounts/(?<account_id>\d+)/conversations/(?<conversation_id>\d+)/transcript}.match(req.path)
match_data[:account_id] if match_data.present?
end
## Prevent abuse of conversation delete API (per account)
throttle('/api/v1/accounts/:account_id/conversations/:id DELETE',
limit: ENV.fetch('RATE_LIMIT_CONVERSATION_DELETE', '60').to_i, period: 1.minute) do |req|
next unless req.delete?
match_data = %r{\A/api/v1/accounts/(?<account_id>\d+)/conversations/(?<id>\d+)/?\z}.match(req.path_without_extensions)
match_data[:account_id] if match_data.present?
end
## Prevent abuse of agent create APIs (per account, covers bulk_create)
throttle('/api/v1/accounts/:account_id/agents POST',
limit: ENV.fetch('RATE_LIMIT_AGENT_CREATE', '100').to_i, period: 1.day) do |req|
next unless req.post?
match_data = %r{\A/api/v1/accounts/(?<account_id>\d+)/agents(?:/bulk_create)?/?\z}.match(req.path_without_extensions)
match_data[:account_id] if match_data.present?
end
## Prevent abuse of agent delete API (per account)
throttle('/api/v1/accounts/:account_id/agents/:id DELETE',
limit: ENV.fetch('RATE_LIMIT_AGENT_DELETE', '50').to_i, period: 1.day) do |req|
next unless req.delete?
match_data = %r{\A/api/v1/accounts/(?<account_id>\d+)/agents/(?<id>\d+)/?\z}.match(req.path_without_extensions)
match_data[:account_id] if match_data.present?
end
## Prevent Abuse of attachment upload APIs ##
throttle('/api/v1/accounts/:account_id/upload', limit: 60, period: 1.hour) do |req|
match_data = %r{/api/v1/accounts/(?<account_id>\d+)/upload}.match(req.path)
match_data[:account_id] if match_data.present?
end
## Prevent abuse of contact search api
throttle('/api/v1/accounts/:account_id/contacts/search', limit: ENV.fetch('RATE_LIMIT_CONTACT_SEARCH', '100').to_i, period: 1.minute) do |req|
match_data = %r{/api/v1/accounts/(?<account_id>\d+)/contacts/search}.match(req.path)
match_data[:account_id] if match_data.present?
end
reports_api_user_level_limit = ENV.fetch('RATE_LIMIT_REPORTS_API_USER_LEVEL', '100').to_i
reports_drilldown_api_user_level_limit = ENV.fetch(
'RATE_LIMIT_REPORTS_DRILLDOWN_API_USER_LEVEL',
[(reports_api_user_level_limit / 10), 1].max
).to_i
# Throttle drilldown requests by individual user (based on uid)
throttle('/api/v2/accounts/:account_id/reports/drilldown/user',
limit: reports_drilldown_api_user_level_limit, period: 1.minute) do |req|
match_data = %r{\A/api/v2/accounts/(?<account_id>\d+)/reports/drilldown\z}.match(req.path_without_extensions)
next unless match_data.present? && req.get?
# Extract user identification (uid for web, api_access_token for API requests)
user_uid = req.get_header('HTTP_UID')
api_access_token = req.get_header('HTTP_API_ACCESS_TOKEN') || req.get_header('api_access_token')
# Use uid if present, otherwise fallback to api_access_token for tracking
user_identifier = user_uid.presence || api_access_token.presence
"#{user_identifier}:#{match_data[:account_id]}" if user_identifier.present?
end
# Throttle by individual user (based on uid)
throttle('/api/v2/accounts/:account_id/reports/user', limit: reports_api_user_level_limit, period: 1.minute) do |req|
match_data = %r{/api/v2/accounts/(?<account_id>\d+)/reports}.match(req.path)
# Extract user identification (uid for web, api_access_token for API requests)
user_uid = req.get_header('HTTP_UID')
api_access_token = req.get_header('HTTP_API_ACCESS_TOKEN') || req.get_header('api_access_token')
# Use uid if present, otherwise fallback to api_access_token for tracking
user_identifier = user_uid.presence || api_access_token.presence
"#{user_identifier}:#{match_data[:account_id]}" if match_data.present? && user_identifier.present?
end
## Prevent abuse of reports api at account level
throttle('/api/v2/accounts/:account_id/reports', limit: ENV.fetch('RATE_LIMIT_REPORTS_API_ACCOUNT_LEVEL', '1000').to_i, period: 1.minute) do |req|
match_data = %r{/api/v2/accounts/(?<account_id>\d+)/reports}.match(req.path)
match_data[:account_id] if match_data.present?
end
## Prevent increased use of conversations meta API per user
throttle('/api/v1/accounts/:account_id/conversations/meta/user',
limit: ENV.fetch('RATE_LIMIT_CONVERSATIONS_META', '30').to_i, period: 1.minute) do |req|
match_data = %r{/api/v1/accounts/(?<account_id>\d+)/conversations/meta}.match(req.path)
next unless match_data.present? && req.get?
user_uid = req.get_header('HTTP_UID')
api_access_token = req.get_header('HTTP_API_ACCESS_TOKEN') || req.get_header('api_access_token')
user_identifier = user_uid.presence || api_access_token.presence
"#{user_identifier}:#{match_data[:account_id]}" if user_identifier.present?
end
## ----------------------------------------------- ##
end
# Log blocked events
ActiveSupport::Notifications.subscribe('throttle.rack_attack') do |_name, _start, _finish, _request_id, payload|
req = payload[:request]
user_uid = req.get_header('HTTP_UID')
api_access_token = req.get_header('HTTP_API_ACCESS_TOKEN') || req.get_header('api_access_token')
# Mask the token if present
masked_api_token = api_access_token.present? ? "#{api_access_token[0..4]}...[REDACTED]" : nil
# Use uid if present, otherwise fallback to masked api_access_token for tracking
user_identifier = user_uid.presence || masked_api_token.presence || 'unknown_user'
# Extract account ID if present
account_match = %r{/accounts/(?<account_id>\d+)}.match(req.path)
account_id = account_match ? account_match[:account_id] : 'unknown_account'
Rails.logger.warn(
"[Rack::Attack][Blocked] remote_ip: \"#{req.remote_ip}\", " \
"path: \"#{req.path}\", " \
"user_identifier: \"#{user_identifier}\", " \
"account_id: \"#{account_id}\", " \
"method: \"#{req.request_method}\", " \
"user_agent: \"#{req.user_agent}\""
)
end
Rack::Attack.enabled = Rails.env.production? ? ActiveModel::Type::Boolean.new.cast(ENV.fetch('ENABLE_RACK_ATTACK', true)) : false