mirror of
https://github.com/Freika/dawarich.git
synced 2026-04-22 03:00:29 -04:00
91 lines
3.0 KiB
Ruby
91 lines
3.0 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
# Per-plan API rate limiting using rack-attack with Redis backend.
|
|
# Self-hosted instances are exempt from rate limiting entirely.
|
|
# Cloud plans: Lite = 200 req/hr, Pro = 1,000 req/hr.
|
|
# Points creation endpoints: 10,000 req/hr (all plans, including self-hosted).
|
|
|
|
Rack::Attack.cache.store = ActiveSupport::Cache::RedisCacheStore.new(
|
|
url: ENV['REDIS_URL'],
|
|
db: ENV.fetch('RACK_ATTACK_REDIS_DB', '3').to_i # dbs 0-2 are reserved for app caching, sidekiq and ws.
|
|
)
|
|
|
|
# Configurable per-plan limits. Override in tests via Rack::Attack.api_rate_limits=
|
|
class Rack::Attack
|
|
class << self
|
|
attr_accessor :api_rate_limits
|
|
end
|
|
self.api_rate_limits = { 'lite' => 200, 'pro' => 1_000 }
|
|
end
|
|
|
|
# Dynamic per-user rate limiting keyed by API token.
|
|
# Execution order: rack-attack evaluates the discriminator block first (which sets
|
|
# the per-user limit in req.env), then evaluates the limit proc (which reads it).
|
|
Rack::Attack.throttle('api/token',
|
|
limit: proc { |req| req.env['rack.attack.api_rate_limit'] || 1_000 },
|
|
period: 1.hour) do |req|
|
|
next unless req.path.start_with?('/api/')
|
|
next if DawarichSettings.self_hosted?
|
|
|
|
api_key = req.params['api_key'] || req.get_header('HTTP_AUTHORIZATION')&.split(' ')&.last
|
|
next if api_key.blank?
|
|
|
|
user_plan = Rails.cache.fetch("rack_attack/plan/#{api_key}", expires_in: 2.minutes) do
|
|
User.where(api_key: api_key).pick(:plan)
|
|
end
|
|
next if user_plan.nil?
|
|
|
|
req.env['rack.attack.api_rate_limit'] = Rack::Attack.api_rate_limits[user_plan] || 1_000
|
|
api_key
|
|
end
|
|
|
|
# Points creation rate limit: 10,000 req/hr per API key.
|
|
# Only applies to cloud instances.
|
|
POINTS_CREATION_PATHS = %w[
|
|
/api/v1/points
|
|
/api/v1/owntracks/points
|
|
/api/v1/overland/batches
|
|
].freeze
|
|
|
|
Rack::Attack.throttle('api/points_creation', limit: 10_000, period: 1.hour) do |req|
|
|
next unless req.post? && POINTS_CREATION_PATHS.include?(req.path)
|
|
next if DawarichSettings.self_hosted?
|
|
|
|
api_key = req.params['api_key'] || req.get_header('HTTP_AUTHORIZATION')&.split(' ')&.last
|
|
next if api_key.blank?
|
|
|
|
"points_creation:#{api_key}"
|
|
end
|
|
|
|
# Login brute-force protection: 5 attempts per email per minute, 20 per IP per minute.
|
|
Rack::Attack.throttle('logins/email', limit: 5, period: 1.minute) do |req|
|
|
next unless req.path == '/users/sign_in' && req.post?
|
|
|
|
req.params.dig('user', 'email')&.downcase&.strip
|
|
end
|
|
|
|
Rack::Attack.throttle('logins/ip', limit: 20, period: 1.minute) do |req|
|
|
next unless req.path == '/users/sign_in' && req.post?
|
|
|
|
req.ip
|
|
end
|
|
|
|
Rack::Attack.throttled_responder = lambda do |request|
|
|
match_data = request.env['rack.attack.match_data'] || {}
|
|
now = Time.current
|
|
period = match_data[:period] || 3600
|
|
|
|
headers = {
|
|
'Content-Type' => 'application/json',
|
|
'Retry-After' => (period - (now.to_i % period)).to_s
|
|
}
|
|
|
|
body = {
|
|
error: 'rate_limit_exceeded',
|
|
message: 'API rate limit exceeded. Please wait before making more requests.',
|
|
upgrade_url: "#{MANAGER_URL}/pricing"
|
|
}.to_json
|
|
|
|
[429, headers, [body]]
|
|
end
|