-
Notifications
You must be signed in to change notification settings - Fork 577
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
Csrf plug #136
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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") | ||
|> 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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes. :)