From e407d0195cf8230a6142f987a79324a2c006ef08 Mon Sep 17 00:00:00 2001 From: Mike Perham Date: Tue, 28 Jun 2016 15:50:43 -0700 Subject: [PATCH 1/3] 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. --- spec/middleware/csrf_spec.cr | 73 ++++++++++++++++++++++++++++++++++++ src/kemal/middleware/csrf.cr | 44 ++++++++++++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 spec/middleware/csrf_spec.cr create mode 100644 src/kemal/middleware/csrf.cr diff --git a/spec/middleware/csrf_spec.cr b/spec/middleware/csrf_spec.cr new file mode 100644 index 0000000..664eabb --- /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"], + "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 diff --git a/src/kemal/middleware/csrf.cr b/src/kemal/middleware/csrf.cr new file mode 100644 index 0000000..c309196 --- /dev/null +++ b/src/kemal/middleware/csrf.cr @@ -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 From 8f5736a057eecf7a4aaade717f1ff5133080efde Mon Sep 17 00:00:00 2001 From: Mike Perham Date: Tue, 28 Jun 2016 16:46:45 -0700 Subject: [PATCH 2/3] Need to initialize the session token or forms won't render --- src/kemal/middleware/csrf.cr | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/kemal/middleware/csrf.cr b/src/kemal/middleware/csrf.cr index c309196..cc46c75 100644 --- a/src/kemal/middleware/csrf.cr +++ b/src/kemal/middleware/csrf.cr @@ -16,12 +16,13 @@ module Kemal::Middleware 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 - 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]? @@ -29,6 +30,7 @@ module Kemal::Middleware else "nothing" end + current_token = context.session["csrf"] if current_token == submitted # reset the token so it can't be used again From 22d6c1773ee74250ebef7dcffae1e22d5e9c48bd Mon Sep 17 00:00:00 2001 From: Mike Perham Date: Wed, 29 Jun 2016 14:52:47 -0700 Subject: [PATCH 3/3] Remove HTTP prefix, this is a Rack impl convention, not a standard. --- spec/middleware/csrf_spec.cr | 2 +- src/kemal/middleware/csrf.cr | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/middleware/csrf_spec.cr b/spec/middleware/csrf_spec.cr index 664eabb..c5e814a 100644 --- a/spec/middleware/csrf_spec.cr +++ b/spec/middleware/csrf_spec.cr @@ -55,7 +55,7 @@ describe "Kemal::Middleware::CSRF" do 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 }) + "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 diff --git a/src/kemal/middleware/csrf.cr b/src/kemal/middleware/csrf.cr index cc46c75..9ae841f 100644 --- a/src/kemal/middleware/csrf.cr +++ b/src/kemal/middleware/csrf.cr @@ -11,7 +11,7 @@ module Kemal::Middleware # where an attacker can re-submit a form. # class CSRF < HTTP::Handler - HEADER = "HTTP_X_CSRF_TOKEN" + HEADER = "X_CSRF_TOKEN" ALLOWED_METHODS = %w[GET HEAD OPTIONS TRACE] PARAMETER_NAME = "authenticity_token"