Implementiamo il Rate limit in Ruby on Rails

Rate limit in Ruby on Rails

Continuiamo il viaggio nella sicurezza delle API iniziato nell’ultimo articolo con un approfondimento sull’implementazione del Rate Limit in Ruby on Rails.

Lo scopo del rate limit è di impedire che un determinato indirizzo IP esegua troppe richieste API in una determinata unità di tempo. Quindi non stiamo parlando di tentativi di brute force attack che possono essere combattuti anche attraverso strumenti sistemistici come Fail2ban, bensì di richieste lecite che però eccedono il massimo stabilito dalle nostre policy.

Nella realizzazione del sistema di rate limit, bisogna innanzitutto valutare che la procedura di controllo verrà eseguita ad ogni chiamata e questa comporterà sicuramente una lettura e, nel caso di una chiamata accettata, anche una scrittura: quindi è necessario utilizzare una base dati estremamente performante per entrambe le direzioni dell’I/O. D’altro canto, si tratta di un dato “usa e getta” per cui non è necessaria la persistenza dello stesso. In questo caso ci viene incontro l’ottimo Redis, nel quale andremo a memorizzare l’indirizzo IP di chi chiama l’API e il numero di richieste usando la funzione interna di incremento; inoltre, per verificare la relazione con l’unità di tempo, utilizzaremo la funzione interna di expire, minimizzando il numero di operazioni svolte da Ruby on Rails.

La tecnica più semplice per implementare il rate limit è quella di usare un metodo ad hoc nell’application controller e di associarlo al before_filter in modo da eseguirlo ad ogni chiamata. Questa tecnica, però, non permette di modificare gli header di una richiesta valida per informare il client sul numero di richieste rimanenti, per cui si potrebbe associare uno secondo metodo associato ad un after_filter, però la cosa diventerebbe troppo macchinosa. Proponiamo di seguito una implementazione che trae isporazione da questo articolo e che si basa su un metodo alternativo: il rack midlleware.

# app/middleware/rate_limit.rb

class RateLimit
  def initialize(app)
    @app = app
  end

  def call(env)
    client_ip = env["action_dispatch.remote_ip"]
    key = "count:#{client_ip}"
    count = REDIS.get(key)
    unless count
      REDIS.set(key, 0)
      REDIS.expire(key, Settings.throttle_time_window)
    end

    if count.to_i >= Settings.throttle_max_requests
      [
        429,
        rate_limit_headers(count, key),
        [message]
      ]
    else
      REDIS.incr(key)
      status, headers, body = @app.call(env)
      [
        status,
        headers.merge(rate_limit_headers(count.to_i + 1, key)),
        body
      ]
    end
  end

  private
  def message
    {
      :message => "You have fired too many requests. Please wait for some time."
    }.to_json
  end

  def rate_limit_headers(count, key)
    ttl = REDIS.ttl(key)
    time = Time.now.to_i
    time_till_reset = (time + ttl.to_i).to_s
    {
      "X-Rate-Limit-Limit" => Settings.throttle_max_requests,
      "X-Rate-Limit-Remaining" => (Settings.throttle_max_requests - count.to_i).to_s,
      "X-Rate-Limit-Reset" => time_till_reset
    }
  end
end

Inoltre è necessario modificare il file config/application.rb per collegare il middleware:

# config/application.rb

class Application < Rails::Application
  ...
  config.middleware.use "RateLimit"
end

Rispetto alla versione proposta nell’articolo originario, è stata variata la variabile di environment attraverso la quale si legge l’indirizzo IP del client da “REMOTE_ADDR” ad “action_dispatch.remote_ip”. Il motivo è che nella mia configurazione sistemistica, l’applicazione Ruby on Rails viene eseguita dal webserver Puma (jRuby) dietro ad Nginx: con questa configurazione, la variabile REMOTE_ADDR viene popolata con l’IP dell’NGINX (127.0.0.1, nel mio caso) e non con l’effettivo indirizzo IP del client che origina la chiamata.

Leave a Reply

Your email address will not be published. Required fields are marked *

18 − 6 =