Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Defending your Rails app

Defending your Rails app

This talk goes beyond classic security topics like XSS and CSRF, and focuses on how to defend Rails applications from real-world abuse and how Harvest leverages on the rack-attack gem to achive so

Avatar for Julia López

Julia López

June 20, 2025
Tweet

More Decks by Julia López

Other Decks in Technology

Transcript

  1. Defending your Rails app Julia López – Brighton Ruby 2025

    How to keep bad actors out without locking users in
  2. Julia López From Barcelona ☀ ❤ Rails since 2011 Refactoring

    🧹 & Upgrades ⏫ 🔗 https://julialopez.dev
  3. What is a bad actor? From whom or what are

    we defending our app from? 🦹
  4. Summarized by ChatGPT from usage across security documentation by Cloud

    f lare, Stripe, and abuse engineering blogs “A bad actor is any human or automated system that intentionally abuses, misuses, or exploits an application to gain unauthorized access, cause disruption, or commit fraud”
  5. 🤖 Bots: credential stu ff ers, signup spam 🥷 Fraudsters:

    use stolen credit cards 🎭 Scammers: impersonation 📣 Spammers: content or message f looding 🌩 DDoS: orchestrate attacks 🙈 Unintentional Chaos monkey: means no harm Kinds of bad actors
  6. 🤖 Bots: credential stu ff ers, signup spam 🥷 Fraudsters:

    use stolen credit cards 🎭 Scammers: impersonation 📣 Spammers: content or message f looding 🌩 DDoS: orchestrate attacks 🙈 Unintentional Chaos monkey: means no harm Kinds of bad actors
  7. 🤖 Bots: credential stu ff ers, signup spam 🥷 Fraudsters:

    use stolen credit cards 🎭 Scammers: impersonation 📣 Spammers: content or message f looding 🌩 DDoS: orchestrate attacks 🙈 Unintentional Chaos monkey: means no harm Kinds of bad actors
  8. 🤖 Bots: credential stu ff ers, signup spam 🥷 Fraudsters:

    use stolen credit cards 🎭 Scammers: impersonation 📣 Spammers: content or message f looding 🌩 DDoS: orchestrate attacks 🙈 Unintentional Chaos monkey: means no harm Kinds of bad actors
  9. 🤖 Bots: credential stu ff ers, signup spam 🥷 Fraudsters:

    use stolen credit cards 🎭 Scammers: impersonation 📣 Spammers: content or message f looding 🌩 DDoS: orchestrate attacks 🙈 Unintentional Chaos monkey: means no harm Kinds of bad actors
  10. 🤖 Bots: credential stu ff ers, signup spam 🥷 Fraudsters:

    use stolen credit cards 🎭 Scammers: impersonation 📣 Spammers: content or message f looding 🌩 DDoS: orchestrate attacks 🙈 Unintentional chaos monkey: means no harm Kinds of bad actors
  11. How do we defend our application? What tools we have

    at our disposal to prevent malicious practices
  12. rack-attack Rack::Attack.blocklist("block bad UA logins") do |request| # Requests are

    blocked if the return value is truthy request.user_agent == "badUA" end
  13. rack-attack Rack::Attack.blocklist("block bad UA logins") do |request| # Requests are

    blocked if the return value is truthy request.user_agent == "badUA" end
  14. rack-attack Rack::Attack.blocklist("block bad UA logins") do |request| # Requests are

    blocked if the return value is truthy request.user_agent == "badUA" end
  15. rack-attack Rack::Attack.throttled_responder = ->(request) do env = request.env match_data =

    env["rack.attack.match_data"] throttle_name = env["rack.attack.matched"] discriminator = env["rack.attack.match_discriminator"] Prom.counters["throttles"].observe 1, name: throttle_name Rails.logger.error "Throttle #{throttle_name} => #{discriminator}: throttling occurred, retry after #{match_data[:period]}" accepts = env["HTTP_ACCEPT"] || "text/html" message = case accepts.split(",").first when /json/ ... when /xml/ ... else ... end [ 429, { "Content-Type" => accepts, "Retry-After" => match_data[:period].to_s, }, [message], ] end
  16. rack-attack Rack::Attack.throttled_responder = ->(request) do env = request.env match_data =

    env["rack.attack.match_data"] throttle_name = env["rack.attack.matched"] discriminator = env["rack.attack.match_discriminator"] Prom.counters["throttles"].observe 1, name: throttle_name Rails.logger.error "Throttle #{throttle_name} => #{discriminator}: throttling occurred, retry after #{match_data[:period]}" accepts = env["HTTP_ACCEPT"] || "text/html" message = case accepts.split(",").first when /json/ ... when /xml/ ... else ... end [ 429, { "Content-Type" => accepts, "Retry-After" => match_data[:period].to_s, }, [message], ] end
  17. rack-attack Rack::Attack.throttled_responder = ->(request) do env = request.env match_data =

    env["rack.attack.match_data"] throttle_name = env["rack.attack.matched"] discriminator = env["rack.attack.match_discriminator"] Prom.counters["throttles"].observe 1, name: throttle_name Rails.logger.error "Throttle #{throttle_name} => #{discriminator}: throttling occurred, retry after #{match_data[:period]}" accepts = env["HTTP_ACCEPT"] || "text/html" message = case accepts.split(",").first when /json/ ... when /xml/ ... else ... end [ 429, { "Content-Type" => accepts, "Retry-After" => match_data[:period].to_s, }, [message], ] end
  18. rack-attack Rack::Attack.throttled_responder = ->(request) do env = request.env match_data =

    env["rack.attack.match_data"] throttle_name = env["rack.attack.matched"] discriminator = env["rack.attack.match_discriminator"] Prom.counters["throttles"].observe 1, name: throttle_name Rails.logger.error "Throttle #{throttle_name} => #{discriminator}: throttling occurred, retry after #{match_data[:period]}" accepts = env["HTTP_ACCEPT"] || "text/html" message = case accepts.split(",").first when /json/ ... when /xml/ ... else ... end [ 429, { "Content-Type" => accepts, "Retry-After" => match_data[:period].to_s, }, [message], ] end
  19. rack-attack # app/controllers/application_controller.rb before_action :throttling_info def throttling_info if throttle_data =

    request.env["rack.attack.throttle_data"] throttle_data.each do |name, data| Rails.logger.info "Throttle #{name} => #{data[:discriminator]}: #{data[:count]}/#{data[:limit]} (#{data[:period]}s)" end end end
  20. rack-attack # app/controllers/application_controller.rb before_action :throttling_info def throttling_info if throttle_data =

    request.env["rack.attack.throttle_data"] throttle_data.each do |name, data| Rails.logger.info "Throttle #{name} => #{data[:discriminator]}: #{data[:count]}/#{data[:limit]} (#{data[:period]}s)" end end end
  21. rack-attack # app/controllers/application_controller.rb before_action :throttling_info def throttling_info if throttle_data =

    request.env["rack.attack.throttle_data"] throttle_data.each do |name, data| Rails.logger.info "Throttle #{name} => #{data[:discriminator]}: #{data[:count]}/#{data[:limit]} (#{data[:period]}s)" end end end
  22. General abuse Rack::Attack.throttle(“api/account/user", limit: 100, period: 15.seconds) do |request| discriminator

    = user_discriminator(request) "api-#{request.env['HTTP_HARVEST_ACCOUNT_ID']}-#{discriminator}" end
  23. General abuse Rack::Attack.throttle(“api/account/user", limit: 100, period: 15.seconds) do |request| discriminator

    = user_discriminator(request) "api-#{request.env['HTTP_HARVEST_ACCOUNT_ID']}-#{discriminator}" end
  24. General abuse Rack::Attack.throttle(“api/account/user", limit: 100, period: 15.seconds) do |request| discriminator

    = user_discriminator(request) "api-#{request.env['HTTP_HARVEST_ACCOUNT_ID']}-#{discriminator}" end
  25. General abuse Rack::Attack.throttle(“api/reports/user", limit: 100, period: 15.minutes) do |request| if

    request.path.starts_with?(“/v2/reports") discriminator = user_discriminator(request) "api-#{request.env['HTTP_HARVEST_ACCOUNT_ID']}-#{discriminator}" end end
  26. General abuse Rack::Attack.throttle(“api/reports/user", limit: 100, period: 15.minutes) do |request| if

    request.path.starts_with?(“/v2/reports") discriminator = user_discriminator(request) "api-#{request.env['HTTP_HARVEST_ACCOUNT_ID']}-#{discriminator}" end end
  27. General abuse Rack::Attack.throttle(“api/reports/user", limit: 100, period: 15.minutes) do |request| if

    request.path.starts_with?(“/v2/reports") discriminator = user_discriminator(request) "api-#{request.env['HTTP_HARVEST_ACCOUNT_ID']}-#{discriminator}" end end
  28. Credential stu ff ing Rack::Attack.throttle("logins", limit: 10, period: 1.minute) do

    |request| if request.post? && request.path.start_with?("/sessions") request.ip end end
  29. Credential stu ff ing Rack::Attack.throttle("logins", limit: 10, period: 1.minute) do

    |request| if request.post? && request.path.start_with?("/sessions") request.ip end end
  30. Credential stu ff ing Rack::Attack.throttle("logins", limit: 10, period: 1.minute) do

    |request| if request.post? && request.path.start_with?("/sessions") request.ip end end
  31. Signup spam Rack::Attack.throttle("possibly spammy signup", limit: 10, period: 24.hours) do

    |request| if request.post? && request.path.start_with?("/signup") && looks_spammy?(request) "spammy_signup" end end
  32. Signup spam Rack::Attack.throttle("possibly spammy signup", limit: 10, period: 24.hours) do

    |request| if request.post? && request.path.start_with?("/signup") && looks_spammy?(request) "spammy_signup" end end
  33. Signup spam Rack::Attack.throttle("possibly spammy signup", limit: 10, period: 24.hours) do

    |request| if request.post? && request.path.start_with?("/signup") && looks_spammy?(request) "spammy_signup" end end
  34. Business logic abuse ThrottlingRule::RULES.each do |rule_name, rule| rule[:throttle_discriminator].each do |disc|

    name = "#{rule_name}/#{disc}" limit = proc { |request| ThrottlingRule.new(request, rule).limit(disc) } Rack::Attack.throttle(name, limit: limit, period: rule[:period]) do |request| conditions = ThrottlingRule.new(request, rule) if conditions.run conditions.get_discriminator(disc) end end end end
  35. Business logic abuse ThrottlingRule::RULES.each do |rule_name, rule| rule[:throttle_discriminator].each do |disc|

    name = "#{rule_name}/#{disc}" limit = proc { |request| ThrottlingRule.new(request, rule).limit(disc) } Rack::Attack.throttle(name, limit: limit, period: rule[:period]) do |request| conditions = ThrottlingRule.new(request, rule) if conditions.run conditions.get_discriminator(disc) end end end end
  36. Business logic abuse ThrottlingRule::RULES.each do |rule_name, rule| rule[:throttle_discriminator].each do |disc|

    name = "#{rule_name}/#{disc}" limit = proc { |request| ThrottlingRule.new(request, rule).limit(disc) } Rack::Attack.throttle(name, limit: limit, period: rule[:period]) do |request| conditions = ThrottlingRule.new(request, rule) if conditions.run conditions.get_discriminator(disc) end end end end
  37. Business logic abuse ThrottlingRule::RULES.each do |rule_name, rule| rule[:throttle_discriminator].each do |disc|

    name = "#{rule_name}/#{disc}" limit = proc { |request| ThrottlingRule.new(request, rule).limit(disc) } Rack::Attack.throttle(name, limit: limit, period: rule[:period]) do |request| conditions = ThrottlingRule.new(request, rule) if conditions.run conditions.get_discriminator(disc) end end end end
  38. Business logic abuse create_invoice_non_api_new_company: { limit: 100, period: 24.hours, throttle_discriminator:

    [:subdomain], methods: [:post], path_match: { type: :exact, discriminator: "/invoices", }, api_check: :is_non_api, suspicious_check: :new_nonpaying_company, }
  39. Business logic abuse create_invoice_non_api_new_company: { limit: 100, period: 24.hours, throttle_discriminator:

    [:subdomain], methods: [:post], path_match: { type: :exact, discriminator: "/invoices", }, api_check: :is_non_api, suspicious_check: :new_nonpaying_company, }
  40. Business logic abuse create_invoice_non_api_new_company: { limit: 100, period: 24.hours, throttle_discriminator:

    [:subdomain], methods: [:post], path_match: { type: :exact, discriminator: "/invoices", }, api_check: :is_non_api, suspicious_check: :new_nonpaying_company, }
  41. Business logic abuse create_invoice_non_api_new_company: { limit: 100, period: 24.hours, throttle_discriminator:

    [:subdomain], methods: [:post], path_match: { type: :exact, discriminator: "/invoices", }, api_check: :is_non_api, suspicious_check: :new_nonpaying_company, }
  42. Business logic abuse create_invoice_non_api_new_company: { limit: 100, period: 24.hours, throttle_discriminator:

    [:subdomain], methods: [:post], path_match: { type: :exact, discriminator: "/invoices", }, api_check: :is_non_api, suspicious_check: :new_nonpaying_company, }
  43. Business logic abuse create_invoice_non_api_new_company: { limit: 100, period: 24.hours, throttle_discriminator:

    [:subdomain], methods: [:post], path_match: { type: :exact, discriminator: "/invoices", }, api_check: :is_non_api, suspicious_check: :new_nonpaying_company, }
  44. Business logic abuse ActiveSupport::Notifications.subscribe("throttle.rack_attack") do |_, _, _, _, payload|

    request = payload[:request] throttle_name = request.env["rack.attack.matched"] # Removes the discriminator suffix (e.g. "/signup_ip", "/subdomain", "/ip", etc) rule_name = throttle_name.sub(%r{/\w+\z}, "").to_sym rule = ThrottlingRule::RULES.find { |name, _| name == rule_name } next unless rule rule_config = rule.second throttling_rule = ThrottlingRule.new(request, rule_config) notes = "Throttled by rule #{rule_name}" discriminators = rule_config[:throttle_discriminator] discriminators.each.with_index do |discriminator, index| flag_discriminator_as_suspicious(discriminator, throttling_rule, notes) end end
  45. Business logic abuse ActiveSupport::Notifications.subscribe("throttle.rack_attack") do |_, _, _, _, payload|

    request = payload[:request] throttle_name = request.env["rack.attack.matched"] # Removes the discriminator suffix (e.g. "/signup_ip", "/subdomain", "/ip", etc) rule_name = throttle_name.sub(%r{/\w+\z}, "").to_sym rule = ThrottlingRule::RULES.find { |name, _| name == rule_name } next unless rule rule_config = rule.second throttling_rule = ThrottlingRule.new(request, rule_config) notes = "Throttled by rule #{rule_name}" discriminators = rule_config[:throttle_discriminator] discriminators.each.with_index do |discriminator, index| flag_discriminator_as_suspicious(discriminator, throttling_rule, notes) end end
  46. Business logic abuse ActiveSupport::Notifications.subscribe("throttle.rack_attack") do |_, _, _, _, payload|

    request = payload[:request] throttle_name = request.env["rack.attack.matched"] # Removes the discriminator suffix (e.g. "/signup_ip", "/subdomain", "/ip", etc) rule_name = throttle_name.sub(%r{/\w+\z}, "").to_sym rule = ThrottlingRule::RULES.find { |name, _| name == rule_name } next unless rule rule_config = rule.second throttling_rule = ThrottlingRule.new(request, rule_config) notes = "Throttled by rule #{rule_name}" discriminators = rule_config[:throttle_discriminator] discriminators.each.with_index do |discriminator, index| flag_discriminator_as_suspicious(discriminator, throttling_rule, notes) end end
  47. Business logic abuse ActiveSupport::Notifications.subscribe("throttle.rack_attack") do |_, _, _, _, payload|

    request = payload[:request] throttle_name = request.env["rack.attack.matched"] # Removes the discriminator suffix (e.g. "/signup_ip", "/subdomain", "/ip", etc) rule_name = throttle_name.sub(%r{/\w+\z}, "").to_sym rule = ThrottlingRule::RULES.find { |name, _| name == rule_name } next unless rule rule_config = rule.second throttling_rule = ThrottlingRule.new(request, rule_config) notes = "Throttled by rule #{rule_name}" discriminators = rule_config[:throttle_discriminator] discriminators.each.with_index do |discriminator, index| flag_discriminator_as_suspicious(discriminator, throttling_rule, notes) end end
  48. ActiveSupport::Notifications.subscribe("throttle.rack_attack") do |_, _, _, _, payload| request = payload[:request]

    throttle_name = request.env["rack.attack.matched"] # Removes the discriminator suffix (e.g. "/signup_ip", "/subdomain", "/ip", etc) rule_name = throttle_name.sub(%r{/\w+\z}, "").to_sym rule = ThrottlingRule::RULES.find { |name, _| name == rule_name } next unless rule rule_config = rule.second throttling_rule = ThrottlingRule.new(request, rule_config) notes = "Throttled by rule #{rule_name}" discriminators = rule_config[:throttle_discriminator] discriminators.each.with_index do |discriminator, index| flag_discriminator_as_suspicious(discriminator, throttling_rule, notes) end end Business logic abuse
  49. Business logic abuse ThrottlingRule::RULES.each do |rule_name, rule| rule[:throttle_discriminator].each do |disc|

    name = "#{rule_name}/#{disc}" limit = proc { |request| ThrottlingRule.new(request, rule).limit(disc) } Rack::Attack.throttle(name, limit: limit, period: rule[:period]) do |request| conditions = ThrottlingRule.new(request, rule) if conditions.run conditions.get_discriminator(disc) end end end end
  50. Business logic abuse # lib/throttling_rule.rb def limit(discriminator) level = level_for_discriminator(discriminator)

    case level when SUSPICIOUS_LEVEL limit / 2 when MALICIOUS_LEVEL 0 else limit end end
  51. Business logic abuse # lib/throttling_rule.rb def limit(discriminator) level = level_for_discriminator(discriminator)

    case level when SUSPICIOUS_LEVEL limit / 2 when MALICIOUS_LEVEL 0 else limit end end
  52. Business logic abuse # lib/throttling_rule.rb def limit(discriminator) level = level_for_discriminator(discriminator)

    case level when SUSPICIOUS_LEVEL limit / 2 when MALICIOUS_LEVEL 0 else limit end end
  53. Stolen card testing # "charge.failed", "charge.succeeded" Stripe events charge =

    event.data.charge note = "Stripe #{charge.outcome.type} with risk score = #{charge.outcome.risk_score.to_i}” case charge.outcome.type when "blocked" company.flag_as_malicious(notes: note) when "manual_review" company.flag_as_suspicious(notes: note) end
  54. Stolen card testing # "charge.failed", "charge.succeeded" Stripe events charge =

    event.data.charge note = "Stripe #{charge.outcome.type} with risk score = #{charge.outcome.risk_score.to_i}” case charge.outcome.type when "blocked" company.flag_as_malicious(notes: note) when "manual_review" company.flag_as_suspicious(notes: note) end
  55. Stolen card testing # "charge.failed", "charge.succeeded" Stripe events charge =

    event.data.charge note = "Stripe #{charge.outcome.type} with risk score = #{charge.outcome.risk_score.to_i}” case charge.outcome.type when "blocked" company.flag_as_malicious(notes: note) when "manual_review" company.flag_as_suspicious(notes: note) end
  56. Stolen card testing # "charge.failed", "charge.succeeded" Stripe events charge =

    event.data.charge note = "Stripe #{charge.outcome.type} with risk score = #{charge.outcome.risk_score.to_i}” case charge.outcome.type when "blocked" company.flag_as_malicious(notes: note) when "manual_review" company.flag_as_suspicious(notes: note) end
  57. Stolen card testing # "charge.failed", "charge.succeeded" Stripe events charge =

    event.data.charge note = "Stripe #{charge.outcome.type} with risk score = #{charge.outcome.risk_score.to_i}” case charge.outcome.type when "blocked" company.flag_as_malicious(notes: note) when "manual_review" company.flag_as_suspicious(notes: note) end