wohali closed pull request #334: Backport CSRF feature
URL: https://github.com/apache/couchdb/pull/334
 
 
   

This is a PR merged from a forked repository.
As GitHub hides the original diff on merge, it is displayed below for
the sake of provenance:

As this is a foreign pull request (from a fork), the diff is supplied
below (as it won't show otherwise due to GitHub magic):

diff --git a/share/Makefile.am b/share/Makefile.am
index f168bf2713..0f4550c054 100644
--- a/share/Makefile.am
+++ b/share/Makefile.am
@@ -174,6 +174,7 @@ nobase_dist_localdata_DATA = \
     www/script/test/content_negotiation.js \
     www/script/test/cookie_auth.js \
     www/script/test/copy_doc.js \
+    www/script/test/csrf.js \
     www/script/test/delayed_commits.js \
     www/script/test/design_docs.js \
     www/script/test/design_options.js \
diff --git a/share/doc/src/config/http.rst b/share/doc/src/config/http.rst
index 6854a240e3..358709d0e1 100644
--- a/share/doc/src/config/http.rst
+++ b/share/doc/src/config/http.rst
@@ -553,7 +553,84 @@ with the vhost name prefixed by ``cors:``. Example case 
for the vhost
   ; List of accepted methods
   methods = HEAD, GET
 
+Cross-site Request Forgery protection
+=====================================
 
+.. config:section:: csrf :: Cross-site Request Forgery
+
+    .. versionadded:: 1.7 added CSRF protection, see JIRA :issue:`2762`
+
+    `CSRF`, or "Cross-site Request Forgery" is a web-based exploit
+    where an attacker can cause a user agent to make an authenticated
+    form post or XHR request against a foreign site without their
+    consent. The attack works because a user agent will send any
+    cookies it has along with the request. The attacker does not see
+    the response, nor can they see the user agent's cookies. The
+    attacker hopes to gain indirectly, e.g, by posting to a password
+    reset form or cause damage by issuing a database delete request.
+
+    To prevent this, CouchDB can require a matching request header
+    before processing any request. The correct value of this header is
+    unknown to the attacker and so their attack fails.
+
+    To enable CSRF protection, add the custom request header
+    `X-CouchDB-CSRF` wih value `true` to any request. The response will
+    return a cookie named `CouchDB-CSRF`.
+
+    If CouchDB sees the `CouchDB-CSRF` cookie in a request it expects
+    the same value to be sent in the `X-CouchDB-CSRF` header. If the
+    header is missing or does not match the cookie, a `403 Forbidden`
+    response is generated. Additionally, CouchDB logs a warning, to
+    allow administrators to detect potential CSRF attacks in progress.
+
+    Careful clients can verify whether their requests were protected
+    from CSRF by examining the `X-CouchDB-CSRF-Valid` response
+    header. It should be present and its value should be `true`.
+
+    CSRF cookies expire after a configurable period of time but will
+    automatically be refreshed by CouchDB on subsequent requests. An
+    expired CSRF cookie is equivalent to not sending the cookie (and
+    thus the request will not be protected from CSRF).
+
+    The following pseudo-code shows how to use the CSRF protection in
+    an opportunistic fashion, gracefully degrading when the mechanism
+    is not available.
+
+    .. code-block:: javascript
+
+        if (hasCookie("CouchDB-CSRF")) {
+          setRequestHeader("X-CouchDB-CSRF", cookieValue("CouchDB-CSRF"));
+        } else {
+          setRequestHeader("X-CouchDB-CSRF", "true");
+        }
+
+    .. config:option:: mandatory
+
+        CouchDB can insist on CSRF Cookie/Header for all requests
+        (except those to the welcome handler, /, so you can acquire a
+        cookie) with this setting. The default is false::
+
+            [csrf]
+            mandatory = true
+
+    .. config:option:: secret
+
+        All CSRF cookies are signed by the server using this value. A
+        random value will be chosen if you don't specify it, but we
+        recommend setting it yourself, especially if you are running a
+        cluster of more than one node. The secret must match on all
+        nodes in a cluster to avoid sadness::
+
+            [csrf]
+            secret = b6fdf2e8213a36dbcca34e61e4000967
+
+    .. config:option:: timeout
+
+        All CSRF cookies expire after `timeout` seconds. The default
+        is an hour::
+
+            [csrf]
+            timeout = 3600
 
 .. _config/vhosts:
 
diff --git a/share/www/script/test/csrf.js b/share/www/script/test/csrf.js
new file mode 100644
index 0000000000..9799d6f567
--- /dev/null
+++ b/share/www/script/test/csrf.js
@@ -0,0 +1,63 @@
+// Licensed under the Apache License, Version 2.0 (the "License"); you may not
+// use this file except in compliance with the License. You may obtain a copy 
of
+// the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+// License for the specific language governing permissions and limitations 
under
+// the License.
+
+couchTests.csrf = function(debug) {
+  var db = new CouchDB("test_suite_db", {"X-Couch-Full-Commit":"false"});
+  db.deleteDb();
+  db.createDb();
+
+  if (debug) debugger;
+
+  // Handy function to cause CouchDB to delete the CSRF cookie
+  var deleteCsrf = function() {
+    var xhr = CouchDB.request("POST", "/test_suite_db/_all_docs", {
+                              body: '{"keys": []}',
+                              headers: {'X-CouchDB-CSRF': 'foo', 'Cookie': 
'CouchDB-CSRF=foo'}});
+    TEquals(403, xhr.status);
+  };
+
+  // Shouldn't receive header if we didn't ask for it
+  var xhr = CouchDB.request("GET", "/");
+  TEquals(null, xhr.getResponseHeader("X-CouchDB-CSRF-Valid"), "Didn't ask for 
CSRF");
+  TEquals(200, xhr.status);
+
+  // Matching but invalid cookie/header should 403
+  xhr = CouchDB.request("POST", "/test_suite_db/_all_docs", {
+                        body: '{"keys": []}',
+                        headers: {'X-CouchDB-CSRF': 'foo', 'Cookie': 
'CouchDB-CSRF=foo'}});
+  TEquals(403, xhr.status);
+  TEquals(null, xhr.getResponseHeader("X-CouchDB-CSRF-Valid"), "We sent 
invalid cookie and header");
+
+  // Can I acquire a CouchDB-CSRF cookie?
+  xhr = CouchDB.request("GET", "/", {headers: {'X-CouchDB-CSRF': 'true'}});
+  var cookie = 
xhr.getResponseHeader("Set-Cookie").match('^CouchDB-CSRF=([^;]+)');
+  T(cookie, "Should receive cookie");
+
+  // If I have a cookie, do I get a 403 if I don't send the header?
+  xhr = CouchDB.request("POST", "/test_suite_db/_all_docs", {body: '{"keys": 
[]}'});
+  TEquals(403, xhr.status);
+  TEquals(null, xhr.getResponseHeader("X-CouchDB-CSRF-Valid"), "We didn't send 
the header");
+
+  // If I have a cookie, do I get a 200 if I send a matching header?
+  xhr = CouchDB.request("POST", "/test_suite_db/_all_docs", {body: '{"keys": 
[]}',
+                                                             headers: 
{"X-CouchDB-CSRF": cookie[1]}});
+  TEquals(200, xhr.status);
+  TEquals("true", xhr.getResponseHeader("X-CouchDB-CSRF-Valid"), "Server 
should have sent this");
+
+  // How about the wrong header?
+  xhr = CouchDB.request("POST", "/test_suite_db/_all_docs", {body: '{"keys": 
[]}',
+                                                             headers: 
{'X-CouchDB-CSRF': 'foo'}});
+  TEquals(403, xhr.status);
+  TEquals(null, xhr.getResponseHeader("X-CouchDB-CSRF-Valid"), "We sent a 
mismatched header");
+
+  deleteCsrf();
+};
diff --git a/src/couchdb/Makefile.am b/src/couchdb/Makefile.am
index 9fe19bcd3d..9741496902 100644
--- a/src/couchdb/Makefile.am
+++ b/src/couchdb/Makefile.am
@@ -50,6 +50,7 @@ source_files = \
     couch_httpd_db.erl \
     couch_httpd_auth.erl \
     couch_httpd_cors.erl \
+    couch_httpd_csrf.erl \
     couch_httpd_oauth.erl \
     couch_httpd_external.erl \
     couch_httpd_misc_handlers.erl \
@@ -108,6 +109,7 @@ compiled_files = \
     couch_httpd_auth.beam \
     couch_httpd_oauth.beam \
     couch_httpd_cors.beam \
+    couch_httpd_csrf.beam \
     couch_httpd_proxy.beam \
     couch_httpd_external.beam \
     couch_httpd_misc_handlers.beam \
diff --git a/src/couchdb/couch_httpd.erl b/src/couchdb/couch_httpd.erl
index 94eab7d8d7..1bac12ac85 100644
--- a/src/couchdb/couch_httpd.erl
+++ b/src/couchdb/couch_httpd.erl
@@ -320,6 +320,7 @@ handle_request_int(MochiReq, DefaultFun,
 
     {ok, Resp} =
     try
+        couch_httpd_csrf:validate(HttpReq),
         check_request_uri_length(RawUri),
         case couch_httpd_cors:is_preflight_request(HttpReq) of
         #httpd{} ->
@@ -483,8 +484,9 @@ serve_file(#httpd{mochi_req=MochiReq}=Req, RelativePath, 
DocumentRoot,
     ResponseHeaders = server_header()
         ++ couch_httpd_auth:cookie_auth_header(Req, [])
         ++ ExtraHeaders,
-    {ok, MochiReq:serve_file(RelativePath, DocumentRoot,
-            couch_httpd_cors:cors_headers(Req, ResponseHeaders))}.
+    ResponseHeaders1 = couch_httpd_cors:cors_headers(Req, ResponseHeaders),
+    ResponseHeaders2 = couch_httpd_csrf:headers(Req, ResponseHeaders1),
+    {ok, MochiReq:serve_file(RelativePath, DocumentRoot, ResponseHeaders2)}.
 
 qs_value(Req, Key) ->
     qs_value(Req, Key, undefined).
@@ -647,7 +649,8 @@ start_response_length(#httpd{mochi_req=MochiReq}=Req, Code, 
Headers, Length) ->
     Headers1 = Headers ++ server_header() ++
                couch_httpd_auth:cookie_auth_header(Req, Headers),
     Headers2 = couch_httpd_cors:cors_headers(Req, Headers1),
-    Resp = MochiReq:start_response_length({Code, Headers2, Length}),
+    Headers3 = couch_httpd_csrf:headers(Req, Headers2),
+    Resp = MochiReq:start_response_length({Code, Headers3, Length}),
     case MochiReq:get(method) of
     'HEAD' -> throw({http_head_abort, Resp});
     _ -> ok
@@ -660,7 +663,8 @@ start_response(#httpd{mochi_req=MochiReq}=Req, Code, 
Headers) ->
     CookieHeader = couch_httpd_auth:cookie_auth_header(Req, Headers),
     Headers1 = Headers ++ server_header() ++ CookieHeader,
     Headers2 = couch_httpd_cors:cors_headers(Req, Headers1),
-    Resp = MochiReq:start_response({Code, Headers2}),
+    Headers3 = couch_httpd_csrf:headers(Req, Headers2),
+    Resp = MochiReq:start_response({Code, Headers3}),
     case MochiReq:get(method) of
         'HEAD' -> throw({http_head_abort, Resp});
         _ -> ok
@@ -695,7 +699,8 @@ start_chunked_response(#httpd{mochi_req=MochiReq}=Req, 
Code, Headers) ->
     Headers2 = Headers1 ++ server_header() ++
                couch_httpd_auth:cookie_auth_header(Req, Headers1),
     Headers3 = couch_httpd_cors:cors_headers(Req, Headers2),
-    Resp = MochiReq:respond({Code, Headers3, chunked}),
+    Headers4 = couch_httpd_csrf:headers(Req, Headers3),
+    Resp = MochiReq:respond({Code, Headers4, chunked}),
     case MochiReq:get(method) of
     'HEAD' -> throw({http_head_abort, Resp});
     _ -> ok
@@ -726,8 +731,9 @@ send_response(#httpd{mochi_req=MochiReq}=Req, Code, 
Headers, Body) ->
     Headers2 = Headers1 ++ server_header() ++
                couch_httpd_auth:cookie_auth_header(Req, Headers1),
     Headers3 = couch_httpd_cors:cors_headers(Req, Headers2),
+    Headers4 = couch_httpd_csrf:headers(Req, Headers3),
 
-    {ok, MochiReq:respond({Code, Headers3, Body})}.
+    {ok, MochiReq:respond({Code, Headers4, Body})}.
 
 send_method_not_allowed(Req, Methods) ->
     send_error(Req, 405, [{"Allow", Methods}], <<"method_not_allowed">>, 
?l2b("Only " ++ Methods ++ " allowed")).
diff --git a/src/couchdb/couch_httpd_cors.erl b/src/couchdb/couch_httpd_cors.erl
index d9462d1a18..03ee2967dd 100644
--- a/src/couchdb/couch_httpd_cors.erl
+++ b/src/couchdb/couch_httpd_cors.erl
@@ -27,7 +27,7 @@
 -define(SUPPORTED_HEADERS, "Accept, Accept-Language, Content-Type," ++
         "Expires, Last-Modified, Pragma, Origin, Content-Length," ++
         "If-Match, Destination, X-Requested-With, " ++
-        "X-Http-Method-Override, Content-Range").
+        "X-Http-Method-Override, Content-Range, X-CouchDB-CSRF").
 
 -define(SUPPORTED_METHODS, "GET, HEAD, POST, PUT, DELETE," ++
         "TRACE, CONNECT, COPY, OPTIONS").
@@ -36,7 +36,7 @@
 -define(SIMPLE_HEADERS, ["Cache-Control", "Content-Language",
         "Content-Type", "Expires", "Last-Modified", "Pragma"]).
 -define(ALLOWED_HEADERS, lists:sort(["Server", "Etag",
-        "Accept-Ranges" | ?SIMPLE_HEADERS])).
+        "Accept-Ranges", "X-CouchDB-CSRF-Valid" | ?SIMPLE_HEADERS])).
 -define(SIMPLE_CONTENT_TYPE_VALUES, ["application/x-www-form-urlencoded",
         "multipart/form-data", "text/plain"]).
 
diff --git a/src/couchdb/couch_httpd_csrf.erl b/src/couchdb/couch_httpd_csrf.erl
new file mode 100644
index 0000000000..d592319c83
--- /dev/null
+++ b/src/couchdb/couch_httpd_csrf.erl
@@ -0,0 +1,201 @@
+% Licensed under the Apache License, Version 2.0 (the "License"); you may not
+% use this file except in compliance with the License.  You may obtain a copy 
of
+% the License at
+%
+%   http://www.apache.org/licenses/LICENSE-2.0
+%
+% Unless required by applicable law or agreed to in writing, software
+% distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+% WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.  See the
+% License for the specific language governing permissions and limitations under
+% the License.
+
+%% This module provides optional CSRF protection to any client
+%%
+%% Clients should use the following pseudo code;
+%% if (hasCookie("CouchDB-CSRF")) {
+%%   setRequestHeader("X-CouchDB-CSRF", cookieValue("CouchDB-CSRF"));
+%% } else {
+%%   setRequestHeader("X-CouchDB-CSRF", "true")
+%% }
+%%
+%% If CouchDB sees the CouchDB-CSRF cookie then it checks its validity
+%% and whether the X-CouchDB-CSRF request header exists and matches.
+%% A 403 is returned if those checks fail.
+%% If CouchDB does not see the CouchDB-CSRF cookie but does see
+%% the X-CouchDB-CSRF header with value "true", a CouchDB-CSRF cookie
+%% is generated and returned.
+
+-module(couch_httpd_csrf).
+
+-export([validate/1, headers/2]).
+
+-include("couch_db.hrl").
+
+validate(#httpd{method = 'GET'}) ->
+    ok;
+validate(#httpd{method = 'HEAD'}) ->
+    ok;
+validate(#httpd{method = 'OPTIONS'}) ->
+    ok;
+validate(#httpd{} = Req) ->
+    Cookie = csrf_from_req(Req),
+    Header = couch_httpd:header_value(Req, "X-CouchDB-CSRF"),
+    case {Cookie, Header} of
+        {undefined, undefined} ->
+            throw_if_mandatory(Req);
+        {undefined, "true"} ->
+            throw_if_mandatory(Req);
+        {"deleted", "true"} ->
+            throw_if_mandatory(Req);
+        {undefined, _} ->
+            throw({forbidden, <<"CSRF header sent without Cookie">>});
+        {Csrf, Csrf} ->
+            ok = validate(Csrf);
+        _ ->
+            throw({forbidden, <<"CSRF Cookie/Header mismatch">>})
+    end;
+%% Check that we generated this CSRF token
+validate(Csrf) when is_list(Csrf) ->
+    case decode_cookie(Csrf) of
+        malformed ->
+            throw({forbidden, <<"Malformed CSRF Cookie">>});
+        Cookie ->
+            case validate_cookie(Cookie) of
+                true ->
+                    ok;
+                false ->
+                    throw({forbidden, <<"CSRF Cookie invalid or expired">>})
+            end
+    end.
+
+throw_if_mandatory(#httpd{path_parts = []}) ->
+    ok; %% Welcome message is public / entrypoint
+throw_if_mandatory(_) ->
+    case csrf_mandatory() of
+        true ->
+            throw({forbidden, <<"CSRF Cookie/Header is mandatory">>});
+        false ->
+            ok
+    end.
+
+
+headers(#httpd{} = Req, Headers) ->
+    Header = couch_httpd:header_value(Req, "X-CouchDB-CSRF"),
+    case {csrf_from_req(Req), csrf_in_headers(Headers), Header} of
+        {undefined, false, "true"} ->
+            [make_cookie() | Headers];
+        {"deleted", false, "true"} ->
+            [make_cookie() | Headers];
+        {Csrf, false, Csrf} when Csrf /= undefined ->
+            case decode_cookie(Csrf) of
+                malformed ->
+                    [delete_cookie() | Headers];
+                Cookie ->
+                    case validate_cookie(Cookie) of
+                        true ->
+                            case refresh_cookie(Cookie) of
+                                true ->
+                                    valid([make_cookie() | Headers]);
+                                false ->
+                                    valid(Headers)
+                            end;
+                        false ->
+                            [delete_cookie() | Headers]
+                    end
+            end;
+        _ ->
+            Headers
+    end.
+
+
+make_cookie() ->
+    Secret = ?l2b(ensure_csrf_secret()),
+    Token = crypto:rand_bytes(8),
+    Timestamp = timestamp(),
+    Data = <<Token/binary, Timestamp:32>>,
+    Hmac = crypto:sha_mac(Secret, Data),
+    mochiweb_cookies:cookie("CouchDB-CSRF",
+        couch_util:encodeBase64Url(<<Data/binary, Hmac/binary>>),
+        [{path, "/"}, {max_age, max_age()}]).
+
+
+delete_cookie() ->
+    mochiweb_cookies:cookie("CouchDB-CSRF", "deleted",
+        [{path, "/"}, {max_age, 0}]).
+
+csrf_from_req(#httpd{} = Req) ->
+    case couch_httpd:header_value(Req, "Cookie") of
+        undefined ->
+            undefined;
+        Value ->
+            Cookies = mochiweb_cookies:parse_cookie(Value),
+            couch_util:get_value("CouchDB-CSRF", Cookies)
+    end.
+
+
+valid(Headers) ->
+    case lists:keyfind("X-CouchDB-CSRF-Valid", 1, Headers) of
+        false ->
+            [{"X-CouchDB-CSRF-Valid", "true"} | Headers];
+        _ ->
+            Headers
+    end.
+
+csrf_in_headers(Headers) when is_list(Headers) ->
+    lists:any(fun is_csrf_header/1, Headers).
+
+
+is_csrf_header({"Set-Cookie", [$C, $o, $u, $c, $h, $D, $B, $-, $C, $S, $R, $F, 
$= | _]}) ->
+    true;
+is_csrf_header(_) ->
+    false.
+
+
+ensure_csrf_secret() ->
+    case couch_config:get("csrf", "secret", undefined) of
+        undefined ->
+            NewSecret = ?b2l(couch_uuids:random()),
+            couch_config:set("csrf", "secret", NewSecret),
+            NewSecret;
+        Secret -> Secret
+    end.
+
+
+decode_cookie(Cookie) ->
+    try
+        Cookie1 = couch_util:decodeBase64Url(Cookie),
+        <<Token:8/binary, Timestamp:32, Hmac:20/binary>> = Cookie1,
+        {Token, Timestamp, Hmac}
+    catch
+        error:_ ->
+            malformed
+    end.
+
+
+validate_cookie({Token, Timestamp, ActualHmac}) ->
+    Secret = ensure_csrf_secret(),
+    ExpectedHmac = crypto:sha_mac(Secret, <<Token/binary, Timestamp:32>>),
+    MaxAge = max_age(),
+    Expired = Timestamp + MaxAge < timestamp(),
+    couch_passwords:verify(ActualHmac, ExpectedHmac) and not Expired.
+
+
+refresh_cookie({_, Timestamp, _}) ->
+    MaxAge = max_age(),
+    TimeLeft = Timestamp + MaxAge - timestamp(),
+    TimeLeft < MaxAge * 0.9.
+
+
+max_age() ->
+    list_to_integer(couch_config:get("csrf", "timeout", "3600")).
+
+
+timestamp() ->
+    couch_httpd_auth:make_cookie_time().
+
+csrf_mandatory() ->
+    case couch_config:get("csrf", "mandatory", "false") of
+        "true" -> true;
+        _ -> false
+    end.


 

----------------------------------------------------------------
This is an automated message from the Apache Git Service.
To respond to the message, please log on GitHub and use the
URL above to go to the specific comment.
 
For queries about this service, please contact Infrastructure at:
[email protected]


With regards,
Apache Git Services

Reply via email to