## Description The widget conversation transcript endpoint (`POST /api/v1/widget/conversations/transcript`) has no rate limit. Every other comparable endpoint does: the agent-facing transcript API and the widget conversation-create and contact-update endpoints are all throttled. This gap lets a single client trigger a large burst of transcript emails from one conversation. This adds an IP-based throttle (5 requests/hour) for the endpoint, placed inside the existing widget-API throttle block so it inherits the `ENABLE_RACK_ATTACK_WIDGET_API` opt-out used by embedded/iframe clients. The limit is generous for legitimate use (a visitor emailing themselves a transcript) while stopping abusive loops. Throttled requests get the standard 429 the widget already handles. ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? `config/initializers/rack_attack.rb` throttles have no existing specs in this file, so this follows the established convention (no new spec). Verified `ruby -c` and `rubocop` pass on the file. The new throttle mirrors the sibling widget throttles directly above it (same IP key, path guard, and structure). ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [x] My changes generate no new warnings --------- Co-authored-by: Sojan Jose <sojan@pepalo.com>
336 lines
15 KiB
Ruby
336 lines
15 KiB
Ruby
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::Attack’s 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---------------###
|
||
###-----------------------------------------------###
|
||
|
||
# Rack attack on widget APIs can be disabled by setting ENABLE_RACK_ATTACK_WIDGET_API to false
|
||
# For clients using the widgets in specific conditions like inside and iframe
|
||
# TODO: Deprecate this feature in future after finding a better solution
|
||
if ActiveModel::Type::Boolean.new.cast(ENV.fetch('ENABLE_RACK_ATTACK_WIDGET_API', true))
|
||
## Prevent Conversation Bombing on Widget APIs ###
|
||
throttle('api/v1/widget/conversations', limit: 6, period: 12.hours) do |req|
|
||
req.ip if req.path_without_extensions == '/api/v1/widget/conversations' && req.post?
|
||
end
|
||
|
||
## Prevent Contact update Bombing in Widget API ###
|
||
throttle('api/v1/widget/contacts', limit: 60, period: 1.hour) do |req|
|
||
req.ip if req.path_without_extensions == '/api/v1/widget/contacts' && (req.patch? || req.put?)
|
||
end
|
||
|
||
## Prevent Conversation Bombing through multiple sessions
|
||
throttle('widget?website_token={website_token}&cw_conversation={x-auth-token}', limit: 5, period: 1.hour) do |req|
|
||
req.ip if req.path_without_extensions == '/widget' && ActionDispatch::Request.new(req.env).params['cw_conversation'].blank?
|
||
end
|
||
|
||
## Prevent Transcript Bombing on Widget API ###
|
||
throttle('api/v1/widget/conversations/transcript', limit: 5, period: 1.hour) do |req|
|
||
req.ip if req.path_without_extensions == '/api/v1/widget/conversations/transcript' && req.post?
|
||
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
|