Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Csrf plug #136

Merged
merged 4 commits into from
Dec 10, 2014
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions lib/plug/csrf_protection.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
defmodule Plug.CsrfProtection do
alias Plug.Conn

@moduledoc """
Plug to protect from cross-site request forgery.

For this plug to work, it expects a session to have been previously fetched.
If a CSRF token in the session does not previously exist, a CSRF token will
be generated and put into the session.

When a token is invalid, an `InvalidAuthenticityToken` error is raised.

The session's CSRF token will be compared with a token in the params with key
"csrf-token" or a token in the request headers with key 'x-csrf-token'.

Only POST, PUT, PATCH and DELETE are protected methods. DELETE methods need
a token in the request header to be validated since it doesn't accept params.

Javascript GET requests are only allowed if they are XHR requests. Otherwise,
an `InvalidCrossOriginRequest` error will be raised.

You may disable this plug by doing `Plug.Conn.put_private(:plug_skip_csrf_protection, true)`.

## Examples

plug Plug.CsrfProtection

"""
@unprotected_methods ~w(HEAD GET)

defmodule InvalidAuthenticityToken do
@moduledoc "Error raised when CSRF token is invalid."
@invalid_token_error_message "Invalid authenticity token. Make sure that all " <>
"your non-HEAD and non-GET requests include the authenticity token as " <>
"part of form params or as a value in your request's headers with the key 'x-csrf-token'."

defexception message: @invalid_token_error_message, plug_status: 403
end

defmodule InvalidCrossOriginRequest do
@moduledoc "Error raised when non-XHR requests are used for Javascript responses."
@cross_origin_javascript_error_message "Security warning: an embedded " <>
"<script> tag on another site requested protected JavaScript. " <>
"If you know what you're doing, you may disable cross origin protection."

defexception message: @cross_origin_javascript_error_message, plug_status: 403
end

def init(opts), do: opts

def call(%Conn{private: %{plug_skip_csrf_protection: true}} = conn, _opts), do: conn
def call(%Conn{method: method} = conn, _opts) when not method in @unprotected_methods do
if verified_request?(conn) do
conn
else
raise InvalidAuthenticityToken
end
end
def call(conn, _opts) do
if conn.method == "GET" && non_xhr_javascript?(conn) do
raise InvalidCrossOriginRequest
end
ensure_csrf_token(conn)
end

defp verified_request?(conn) do
valid_authenticity_token?(conn, conn.params["csrf_token"]) ||
valid_token_in_header?(conn)
end

defp valid_token_in_header?(conn) do
header_token = Conn.get_req_header(conn, "x-csrf-token") |> Enum.at(0)
valid_authenticity_token?(conn, header_token)
end
defp valid_authenticity_token?(_conn, nil), do: false
defp valid_authenticity_token?(conn, token), do: get_csrf_token(conn) == token

def get_csrf_token(conn), do: Conn.get_session(conn, :csrf_token)

defp non_xhr_javascript?(conn) do
xhr? = Conn.get_req_header(conn, "x-requested-with")
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@josevalim - I saw in Rack that they look for a "HTTP_X_REQUESTED_WITH" header, but JQuery sets a "X-Requested-With" header. Is it correct to look for "x-requested-with" header in Plug?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. :)

|> Enum.member?("XMLHttpRequest")
content_type = Conn.get_req_header(conn, "accept") |> Enum.join(",")
js? = (content_type =~ "text/javascript" || content_type =~ "application/javascript")
!xhr? && js?
end

# TOKEN GENERATION

defp ensure_csrf_token(conn) do
if get_csrf_token(conn) do
conn
else
Conn.put_session(conn, :csrf_token, generate_token(token_length))
end
end

defp generate_token(n) when is_integer(n) do
:crypto.strong_rand_bytes(n) |> Base.encode64
end

defp token_length, do: 32
end
141 changes: 141 additions & 0 deletions test/plug/csrf_protection_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
defmodule Plug.CsrfProtectionTest do
use ExUnit.Case, async: true
use Plug.Test

alias Plug.CsrfProtection
alias Plug.CsrfProtection.InvalidAuthenticityToken
alias Plug.CsrfProtection.InvalidCrossOriginRequest
alias Plug.Conn

@default_opts Plug.Session.init(
store: :cookie,
key: "foobar",
encryption_salt: "cookie store encryption salt",
signing_salt: "cookie store signing salt",
encrypt: true
)

@secret String.duplicate("abcdef0123456789", 8)
@csrf_token "hello123"

def call_with_token(method, path, params \\ nil) do
call(method, path, params)
|> put_session(:csrf_token, @csrf_token)
|> send_resp(200, "ok")
end

defp call(method, path, params \\ nil) do
conn(method, path, params)
|> sign_cookie(@secret)
|> Plug.Session.call(@default_opts)
|> fetch_session
|> fetch_params
end

defp recycle_data(conn, old_conn) do
opts = Plug.Parsers.init(parsers: [:urlencoded, :multipart, :json], pass: ["*/*"])

sign_cookie(conn, @secret)
|> recycle_cookies(old_conn)
|> Plug.Parsers.call(opts)
|> Plug.Session.call(@default_opts)
|> fetch_session
end

defp sign_cookie(conn, secret) do
put_in conn.secret_key_base, secret
end

test "raise error for invalid authenticity token" do
old_conn = call_with_token(:get, "/")

assert_raise InvalidAuthenticityToken, fn ->
conn(:post, "/", %{csrf_token: "foo"})
|> recycle_data(old_conn)
|> CsrfProtection.call([])
end

assert_raise InvalidAuthenticityToken, fn ->
conn(:post, "/", %{})
|> recycle_data(old_conn)
|> CsrfProtection.call([])
end
end

test "unprotected requests are always valid" do
conn = call(:get, "/") |> CsrfProtection.call([])
assert conn.halted == false

conn = call(:head, "/") |> CsrfProtection.call([])
assert conn.halted == false
end

test "protected requests with valid token in params are allowed except DELETE" do
old_conn = call_with_token(:get, "/")
params = %{csrf_token: @csrf_token}

conn = conn(:post, "/", params) |> recycle_data(old_conn) |> CsrfProtection.call([])
assert conn.halted == false

conn = conn(:put, "/", params) |> recycle_data(old_conn) |> CsrfProtection.call([])
assert conn.halted == false

conn = conn(:patch, "/", params) |> recycle_data(old_conn) |> CsrfProtection.call([])
assert conn.halted == false
end

test "protected requests with valid token in header are allowed" do
old_conn = call_with_token(:get, "/")

conn = conn(:post, "/")
|> recycle_data(old_conn)
|> put_req_header("x-csrf-token", @csrf_token)
|> CsrfProtection.call([])
assert conn.halted == false

conn = conn(:put, "/")
|> recycle_data(old_conn)
|> put_req_header("x-csrf-token", @csrf_token)
|> CsrfProtection.call([])
assert conn.halted == false

conn = conn(:patch, "/")
|> recycle_data(old_conn)
|> put_req_header("x-csrf-token", @csrf_token)
|> CsrfProtection.call([])
assert conn.halted == false

conn = conn(:delete, "/")
|> recycle_data(old_conn)
|> put_req_header("x-csrf-token", @csrf_token)
|> CsrfProtection.call([])
assert conn.halted == false
end

test "csrf_token is generated when it isn't available" do
conn = call(:get, "/") |> CsrfProtection.call([])
assert !!Conn.get_session(conn, :csrf_token)
end

test "csrf plug is skipped when plug_skip_csrf_protection is true" do
conn = call(:get, "/")
|> Conn.put_private(:plug_skip_csrf_protection, true)
|> CsrfProtection.call([])
assert !Conn.get_session(conn, :csrf_token)
end

test "non-XHR Javascript GET requests are forbidden" do
headers = [{"accept", "application/javascript"}]
conn = %{call(:get, "/") | req_headers: headers}
assert_raise InvalidCrossOriginRequest, fn ->
CsrfProtection.call(conn, [])
end
end

test "only XHR Javascript GET requests are allowed" do
headers = [{"x-requested-with", "XMLHttpRequest"}, {"accept", "application/javascript"}]
conn = %{call(:get, "/") | req_headers: headers}
conn = CsrfProtection.call(conn, [])
assert !!Conn.get_session(conn, :csrf_token)
end
end