Building Rack Middleware to Block Malicious Users

Fri 08 Sep 2017
Reading time: (~ mins)

There is an ever present threat on the internet. Bots are constantly crawling every access point looking for weaknesses to exploit. This bogs down the availability of our site and clutters logs. Here is an example from the logs of my website packmule.ca:

142.54.161.10 -- "GET /new/wp-admin/setup-config.php" 404
192.187.97.66 -- "GET /wp-admin" 404
198.204.251.162 -- "GET /wp-login.php" 404
142.54.171.66 -- "GET /wp-content/uploads/8d4efb5471f2b9d.php" 404
121.42.50.93 -- "GET /xmlrpc.php" 404

I don't have a lick of php on that site and I'm definitely not running any instance of WordPress. But they don't care about that...they will still poke and prod known vulnerabilities. So what should we do about these unwanted guests. My idea is to block them from further accessing any part of my website. I'll be strict and implement a one strike policy: try a single exploit against my server and you can never visit any resource at that IP ever again.

There exists a pretty beefy solution in Rack::Attack but that is overkill for what I want here. I want minimal overhead and a singular focus in terms of features. This is a perfect chance to build my own Rack middleware to solve my problem! Say HELLO to Rack::Smack:

module Rack
  class Smack
    def initialize(app)
    end
    def call(env)
      [403, { 'Content-Type' => 'text/html' }, ['Banned.']]
    end
  end
end

Now if I put

use Rack::Smack

into my middleware stack every response will be blocked! That technically satisfies my condition but obviously I want to allow legitimate traffic through. I will search the request path for suspicious keywords, if found I'll terminate request processing else I will send the request forward to the rest of my application:

  module Rack
    class Smack
    BLOCKED  = %w[wp wordpress xmlrpc sfn].freeze

      def initialize(app)
      @app = app
      end

      def call(env)
      [403, { 'Content-Type' => 'text/html' }, ['Banned.']]
      @req = Rack::Request.new(env)
        return smack if BLOCKED.any? { |block| @req.path.index(block) }
        @app.call(env)
      end

    def smack
        [403, { 'Content-Type' => 'text/html' }, ['Banned.']]
      end
    end
  end

I define an array of strings, that I would like to ban, in the BLOCKED variable. In :initialize I take the app parameter and store it in an instance var so if I want I can pass it on for further processing. In :call I take advantage of Rack::Request which makes it easier to deal with the Rack environment. Like I said before, I'll search the request path against my blocked strings and if it matches match I'll send them to :smack which will end processing and give them a 403. If no match is made @app.call(env) exits this middleware and passes processing forward to the next app in the stack. Neat!

Right now 'banning' is stateless. My app will not remember that someone was banned and they can keep accessing the rest of my site as if nothing happened. I need to persist the banned state for malicious users and wholly block them from all my content. To do this I'll use a flat file to store offenders. I originally used the CSV library but normal file IO is waaay faster for our purposes. Anyways here we go:

  module Rack
    class Smack
      BLOCKED  = %w[wp wordpress xmlrpc sfn].freeze
    FILENAME = './banlist.txt'.freeze

      def initialize(app)
        @app = app
      IO.write(FILENAME, '') unless ::File.file?(FILENAME)
      end

      def call(env)
        @req = Rack::Request.new(env)
      return smack if BLOCKED.any? { |block| @req.path.index(block) }
      return smack if banned?
        return ban!  if BLOCKED.any? { |block| @req.path.index(block) }
        @app.call(env)
      end

    def ban!
        IO.write(FILENAME, "#{@req.ip},#{@req.path},#{Time.now}\n", mode: 'a')
        smack
      end

      def banned?
        IO.foreach(FILENAME) do |row|
          return true if row.split(',')[0] == @req.ip
        end
        false
      end

      def smack
        [403, { 'Content-Type' => 'text/html' }, ['Banned.']]
      end
    end
  end

We add a FILENAME constant and then in :initialize I attempt to create the file that will store our banned users unless it already exists. In :call the first thing I check is if they are :banned?. This new method efficiently reads our ban list and checks if the current user's ip matches a banned ip and if so :smack will make sure all further processing never happens. Otherwise we move on to check what is being requested. If the request contains a malicious keyword then we make sure to take note with :ban!. This method writes the user's ip with some other information to the log and then :smacks them out! Any future visits for that user will now terminate above when they are searched in the file. In the case that the request is not suspicious the next middleware is called, and everything moves along as normal. This gives me exactly the behaviour I need to catch and deny malicious users! Here is the final code that I use on production for my apps:

module Rack
  # don't cross me boy
  class Smack
    ASSET    = %w[css gif jpg jpeg js png ico txt].freeze
    BLOCKED  = %w[wp wordpress xmlrpc sfn].freeze
    FILENAME = './ban_list.csv'.freeze

    def initialize(app, opts = {})
      @app     = app
      @asset   = opts.delete(:asset) || ASSET
      @blocked = opts.delete(:list)  || BLOCKED
      @file    = opts.delete(:file)  || FILENAME
      raise TypeError        unless options_valid?
      IO.write(FILENAME, '') unless ::File.file?(@file)
    end

    def call(env)
      @req = Rack::Request.new(env)
      return @app.call(env) if @asset.include? @req.path.split('.')[-1]
      return smack          if banned?
      return ban!           if @blocked.any? { |block| @req.path.index(block) }
      @app.call(env)
    end

    private

    def ban!
      IO.write(@file, "#{@req.ip},#{@req.path},#{Time.now}\n", mode: 'a')
      smack
    end

    def banned?
      IO.foreach(@file) do |row|
        return true if row.split(',')[0] == @req.ip
      end
      false
    end

    def options_valid?
      @blocked.is_a?(Array) && @asset.is_a?(Array) && @file.is_a?(String)
    end

    def smack
      [403, { 'Content-Type' => 'text/html' }, ['Banned.']]
    end
  end
end

Pretty much the same with some extra candy. I allow the ability to customize the BLOCKED list as well as the FILENAME. I also add a new list called ASSET which allows me to always skip checking certain requests and lower the overhead of my app's response time. I also do some very basic error checking so we break on initialization instead of at runtime if bad options are given. Here is a usage example:

use Rack::Smack file: 'bad_guys.txt', list: ['wp', 'wordpress', php', 'admin']

Feel free to grab Rack::Smack from here and build upon it! Good luck out there and fight the good fight!


Questions? Free free to contact me anytime :)

Get Notified of Future Posts