Implement CSRF protection

This adds a middleware which, when activated, will deny any form submission which does not include a valid `authenticity_token` parameter or `http-x-csrf-token` header with the request.

The header and parameter names are identical to the ones supported by Ruby's rack-protection gem for interoperability purposes.
This commit is contained in:
Mike Perham 2016-06-28 15:50:43 -07:00
parent 7e49237468
commit e407d0195c
2 changed files with 117 additions and 0 deletions

View file

@ -0,0 +1,73 @@
require "../spec_helper"
describe "Kemal::Middleware::CSRF" do
it "sends GETs to next handler" do
handler = Kemal::Middleware::CSRF.new
request = HTTP::Request.new("GET", "/")
io_with_context = create_request_and_return_io(handler, request)
client_response = HTTP::Client::Response.from_io(io_with_context, decompress: false)
client_response.status_code.should eq 404
end
it "blocks POSTs without the token" do
handler = Kemal::Middleware::CSRF.new
request = HTTP::Request.new("POST", "/")
io_with_context = create_request_and_return_io(handler, request)
client_response = HTTP::Client::Response.from_io(io_with_context, decompress: false)
client_response.status_code.should eq 403
end
it "allows POSTs with the correct token in FORM submit" do
handler = Kemal::Middleware::CSRF.new
request = HTTP::Request.new("POST", "/",
body: "authenticity_token=cemal&hasan=lamec",
headers: HTTP::Headers{"Content-Type" => "application/x-www-form-urlencoded"})
io, context = process_request(handler, request)
client_response = HTTP::Client::Response.from_io(io, decompress: false)
client_response.status_code.should eq 403
current_token = context.session["csrf"]
handler = Kemal::Middleware::CSRF.new
request = HTTP::Request.new("POST", "/",
body: "authenticity_token=#{current_token}&hasan=lamec",
headers: HTTP::Headers{"Content-Type" => "application/x-www-form-urlencoded",
"Set-Cookie" => client_response.headers["Set-Cookie"]})
io, context = process_request(handler, request)
client_response = HTTP::Client::Response.from_io(io, decompress: false)
client_response.status_code.should eq 404
end
it "allows POSTs with the correct token in HTTP header" do
handler = Kemal::Middleware::CSRF.new
request = HTTP::Request.new("POST", "/",
body: "hasan=lamec",
headers: HTTP::Headers{"Content-Type" => "application/x-www-form-urlencoded"})
io, context = process_request(handler, request)
client_response = HTTP::Client::Response.from_io(io, decompress: false)
client_response.status_code.should eq 403
current_token = context.session["csrf"]
handler = Kemal::Middleware::CSRF.new
request = HTTP::Request.new("POST", "/",
body: "hasan=lamec",
headers: HTTP::Headers{"Content-Type" => "application/x-www-form-urlencoded",
"Set-Cookie" => client_response.headers["Set-Cookie"],
"http-x-csrf-token" => current_token })
io, context = process_request(handler, request)
client_response = HTTP::Client::Response.from_io(io, decompress: false)
client_response.status_code.should eq 404
end
end
def process_request(handler, request)
io = MemoryIO.new
response = HTTP::Server::Response.new(io)
context = HTTP::Server::Context.new(request, response)
handler.call(context)
response.close
io.rewind
{io, context}
end

View file

@ -0,0 +1,44 @@
require "secure_random"
require "http"
module Kemal::Middleware
# This middleware adds CSRF protection to your application.
#
# Returns 403 "Forbidden" unless the current CSRF token is submitted
# with any non-GET/HEAD request.
#
# Without CSRF protection, your app is vulnerable to replay attacks
# where an attacker can re-submit a form.
#
class CSRF < HTTP::Handler
HEADER = "HTTP_X_CSRF_TOKEN"
ALLOWED_METHODS = %w[GET HEAD OPTIONS TRACE]
PARAMETER_NAME = "authenticity_token"
def call(context)
return call_next(context) if ALLOWED_METHODS.includes?(context.request.method)
req = context.request
current_token = context.session["csrf"]? || begin
context.session["csrf"] = SecureRandom.hex(16)
end
submitted = if req.headers[HEADER]?
req.headers[HEADER]
elsif context.params.body[PARAMETER_NAME]?
context.params.body[PARAMETER_NAME]
else
"nothing"
end
if current_token == submitted
# reset the token so it can't be used again
context.session["csrf"] = SecureRandom.hex(16)
return call_next(context)
else
context.response.status_code = 403
context.response.print "Forbidden"
end
end
end
end