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 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!