diff --git a/spec/middleware/csrf_spec.cr b/spec/middleware/csrf_spec.cr new file mode 100644 index 0000000..c5e814a --- /dev/null +++ b/spec/middleware/csrf_spec.cr @@ -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"], + "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 diff --git a/src/kemal/middleware/csrf.cr b/src/kemal/middleware/csrf.cr new file mode 100644 index 0000000..9ae841f --- /dev/null +++ b/src/kemal/middleware/csrf.cr @@ -0,0 +1,46 @@ +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 = "X_CSRF_TOKEN" + ALLOWED_METHODS = %w[GET HEAD OPTIONS TRACE] + PARAMETER_NAME = "authenticity_token" + + def call(context) + unless context.session["csrf"]? + context.session["csrf"] = SecureRandom.hex(16) + end + + return call_next(context) if ALLOWED_METHODS.includes?(context.request.method) + + req = context.request + submitted = if req.headers[HEADER]? + req.headers[HEADER] + elsif context.params.body[PARAMETER_NAME]? + context.params.body[PARAMETER_NAME] + else + "nothing" + end + current_token = context.session["csrf"] + + 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