diff --git a/lib/aino/session/csrf.ex b/lib/aino/session/csrf.ex new file mode 100644 index 0000000..dce47e7 --- /dev/null +++ b/lib/aino/session/csrf.ex @@ -0,0 +1,73 @@ +defmodule Aino.Session.CSRF do + @moduledoc """ + Session Middleware for handling CSRF validation + + Example for using CSRF: + + ```elixir + middleware = [ + Aino.Middleware.common(), + &Aino.Session.config(&1, %Aino.Session.Cookie{key: "key", salt: "salt"}), + &Aino.Session.decode/1, + &Aino.Session.Flash.load/1, + &Aino.Middleware.Routes.routes(&1, routes()), + &Aino.Middleware.Routes.match_route/1, + &Aino.Middleware.params/1, + &Aino.Session.CSRF.validate/1, + &Aino.Session.CSRF.generate/1, + &Aino.Middleware.Routes.handle_route/1, + &Aino.Session.encode/1, + &Aino.Middleware.logging/1 + ] + + Aino.Token.reduce(token, middleware) + ``` + + `validate/1` and `generate/1` should be after `Session.load/1` and + `Aino.Middleware.params/1` to make sure the token can be properly loaded. + + Your forms must now include a new hidden field named `_csrf_token`. This will + use the session's `_csrf_token` value. + + ``` + " /> + ``` + """ + + alias Aino.Session + + require Logger + + @doc """ + Validate the token is present if a POST or PUT + """ + def validate(token) do + if token.request.method in [:POST, :PUT] do + # if the token isn't present in either, reject the request + # if the provided token doesn't match, reject the request + + session_token = token.session["_csrf_token"] + request_token = token.params["_csrf_token"] + + if present?(session_token) && present?(request_token) && session_token == request_token do + token + else + raise "Invalid CSRF token" + end + else + token + end + end + + defp present?(token) do + token != nil && token != "" + end + + @doc """ + Generate a token and store in the session + """ + def generate(token) do + csrf_token = :crypto.strong_rand_bytes(32) |> Base.encode64(padding: false) + Session.Token.put(token, "_csrf_token", csrf_token) + end +end diff --git a/test/aino/session/csrf_test.exs b/test/aino/session/csrf_test.exs new file mode 100644 index 0000000..e2f3654 --- /dev/null +++ b/test/aino/session/csrf_test.exs @@ -0,0 +1,58 @@ +defmodule Aino.Session.CSRFTest do + use ExUnit.Case, async: true + + alias Aino.Session.CSRF + + describe "generate a token" do + test "stores in the session" do + token = %{session: %{}} + + token = CSRF.generate(token) + + assert token.session["_csrf_token"] + end + end + + describe "validate a token" do + test "ignores GET" do + token = %{request: %{method: :GET}, session: %{}} + + assert CSRF.validate(token) + end + + test "validates POST" do + assert_raise RuntimeError, fn -> + token = %{params: %{}, request: %{method: :POST}, session: %{"_csrf_token" => "token"}} + CSRF.validate(token) + end + + assert_raise RuntimeError, fn -> + token = %{params: %{}, request: %{method: :POST}, session: %{}} + CSRF.validate(token) + end + + token = %{ + params: %{"_csrf_token" => "token"}, + request: %{method: :POST}, + session: %{"_csrf_token" => "token"} + } + + assert CSRF.validate(token) + end + + test "validates PUT" do + assert_raise RuntimeError, fn -> + token = %{params: %{}, request: %{method: :POST}, session: %{"_csrf_token" => "token"}} + CSRF.validate(token) + end + + token = %{ + params: %{"_csrf_token" => "token"}, + request: %{method: :POST}, + session: %{"_csrf_token" => "token"} + } + + assert CSRF.validate(token) + end + end +end