Fri 08 Sep 2017
Reading time: (~ mins)
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 SmackBLOCKED = %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)
enddef 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].freezeFILENAME = './banlist.txt'.freeze
def initialize(app) @app = appIO.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) enddef 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!