From 846a73de331fb9d00acd3673ca504c95904b0893 Mon Sep 17 00:00:00 2001 From: Alexander Shorin Date: Thu, 13 Aug 2015 03:32:02 +0300 Subject: [PATCH] Backport CSRF feature This includes: https://git-wip-us.apache.org/repos/asf?p=couchdb-couch.git;h=d5a3fc2a5 https://git-wip-us.apache.org/repos/asf?p=couchdb-couch.git;h=2f14e59ee https://git-wip-us.apache.org/repos/asf?p=couchdb-couch.git;h=8fc187d79 https://git-wip-us.apache.org/repos/asf?p=couchdb-couch.git;h=553ab08b8 https://git-wip-us.apache.org/repos/asf?p=couchdb-couch.git;h=bda4957ad https://git-wip-us.apache.org/repos/asf?p=couchdb-chttpd.git;h=ebf6028f https://git-wip-us.apache.org/repos/asf?p=couchdb-chttpd.git;h=8aa7adfb https://git-wip-us.apache.org/repos/asf?p=couchdb-documentation.git;h=13e9832 https://git-wip-us.apache.org/repos/asf?p=couchdb-documentation.git;h=18cc926 https://git-wip-us.apache.org/repos/asf?p=couchdb.git;h=09b9a722fe4c297 https://git-wip-us.apache.org/repos/asf?p=couchdb.git;h=b3c53672d1a8543 https://git-wip-us.apache.org/repos/asf?p=couchdb.git;h=68e83c23b0ac24f COUCHDB-2762 --- share/Makefile.am | 1 + share/doc/src/config/http.rst | 77 +++++++++++++++ share/www/script/test/csrf.js | 63 ++++++++++++ src/couchdb/Makefile.am | 2 + src/couchdb/couch_httpd.erl | 18 ++-- src/couchdb/couch_httpd_cors.erl | 4 +- src/couchdb/couch_httpd_csrf.erl | 201 +++++++++++++++++++++++++++++++++++++++ 7 files changed, 358 insertions(+), 8 deletions(-) create mode 100644 share/www/script/test/csrf.js create mode 100644 src/couchdb/couch_httpd_csrf.erl 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 = <>, + Hmac = crypto:sha_mac(Secret, Data), + mochiweb_cookies:cookie("CouchDB-CSRF", + couch_util:encodeBase64Url(<>), + [{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), + <> = Cookie1, + {Token, Timestamp, Hmac} + catch + error:_ -> + malformed + end. + + +validate_cookie({Token, Timestamp, ActualHmac}) -> + Secret = ensure_csrf_secret(), + ExpectedHmac = crypto:sha_mac(Secret, <>), + 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.