Return-Path: X-Original-To: apmail-couchdb-commits-archive@www.apache.org Delivered-To: apmail-couchdb-commits-archive@www.apache.org Received: from mail.apache.org (hermes.apache.org [140.211.11.3]) by minotaur.apache.org (Postfix) with SMTP id B3B4C189C4 for ; Wed, 5 Aug 2015 13:27:08 +0000 (UTC) Received: (qmail 54168 invoked by uid 500); 5 Aug 2015 13:27:08 -0000 Delivered-To: apmail-couchdb-commits-archive@couchdb.apache.org Received: (qmail 54112 invoked by uid 500); 5 Aug 2015 13:27:08 -0000 Mailing-List: contact commits-help@couchdb.apache.org; run by ezmlm Precedence: bulk List-Help: List-Unsubscribe: List-Post: List-Id: Reply-To: dev@couchdb.apache.org Delivered-To: mailing list commits@couchdb.apache.org Received: (qmail 54102 invoked by uid 99); 5 Aug 2015 13:27:08 -0000 Received: from git1-us-west.apache.org (HELO git1-us-west.apache.org) (140.211.11.23) by apache.org (qpsmtpd/0.29) with ESMTP; Wed, 05 Aug 2015 13:27:08 +0000 Received: by git1-us-west.apache.org (ASF Mail Server at git1-us-west.apache.org, from userid 33) id 44954DFD9C; Wed, 5 Aug 2015 13:27:08 +0000 (UTC) Content-Type: text/plain; charset="us-ascii" MIME-Version: 1.0 Content-Transfer-Encoding: 7bit From: rnewson@apache.org To: commits@couchdb.apache.org Message-Id: <19d35686314840629b9767ea69355a46@git.apache.org> X-Mailer: ASF-Git Admin Mailer Subject: couch commit: updated refs/heads/master to d5a3fc2 Date: Wed, 5 Aug 2015 13:27:08 +0000 (UTC) Repository: couchdb-couch Updated Branches: refs/heads/master 4708e3271 -> d5a3fc2a5 Add CSRF protection If the request parameter `csrf` is set to `true` when successfully acquiring a session cookie from `_session` an additional cookie (`Csrf-token`) is returned. All requests that send this new cookie must also send a header (`X-Csrf-Token`) with the same value. If the cookie is sent and the header is missing or different, a 403 response is generated. Note that the CSRF token is signed by the server so tampering is detected and also results in a 403 response. closes COUCHDB-2762 Project: http://git-wip-us.apache.org/repos/asf/couchdb-couch/repo Commit: http://git-wip-us.apache.org/repos/asf/couchdb-couch/commit/d5a3fc2a Tree: http://git-wip-us.apache.org/repos/asf/couchdb-couch/tree/d5a3fc2a Diff: http://git-wip-us.apache.org/repos/asf/couchdb-couch/diff/d5a3fc2a Branch: refs/heads/master Commit: d5a3fc2a511baff81bdbde64973fe0ca598942d5 Parents: 4708e32 Author: Robert Newson Authored: Fri Jul 31 16:25:36 2015 +0100 Committer: Robert Newson Committed: Tue Aug 4 11:52:51 2015 +0100 ---------------------------------------------------------------------- src/couch_httpd.erl | 18 +++-- src/couch_httpd_csrf.erl | 179 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 191 insertions(+), 6 deletions(-) ---------------------------------------------------------------------- http://git-wip-us.apache.org/repos/asf/couchdb-couch/blob/d5a3fc2a/src/couch_httpd.erl ---------------------------------------------------------------------- diff --git a/src/couch_httpd.erl b/src/couch_httpd.erl index 57ca39a..16fffcd 100644 --- a/src/couch_httpd.erl +++ b/src/couch_httpd.erl @@ -295,6 +295,7 @@ handle_request_int(MochiReq, DefaultFun, {ok, Resp} = try validate_host(HttpReq), + couch_httpd_csrf:validate(HttpReq), check_request_uri_length(RawUri), case couch_httpd_cors:is_preflight_request(HttpReq) of #httpd{} -> @@ -488,8 +489,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). @@ -661,7 +663,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 @@ -674,7 +677,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 @@ -709,7 +713,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 @@ -740,8 +745,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")). http://git-wip-us.apache.org/repos/asf/couchdb-couch/blob/d5a3fc2a/src/couch_httpd_csrf.erl ---------------------------------------------------------------------- diff --git a/src/couch_httpd_csrf.erl b/src/couch_httpd_csrf.erl new file mode 100644 index 0000000..5f8c708 --- /dev/null +++ b/src/couch_httpd_csrf.erl @@ -0,0 +1,179 @@ +% 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_lib("couch/include/couch_db.hrl"). + +validate(#httpd{} = Req) -> + Cookie = csrf_from_req(Req), + Header = couch_httpd:header_value(Req, "X-CouchDB-CSRF"), + case {Cookie, Header} of + {undefined, undefined} -> + ok; + {undefined, "true"} -> + ok; + {"deleted", "true"} -> + ok; + {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. + + +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 config:get("couch_httpd_csrf", "secret", undefined) of + undefined -> + NewSecret = ?b2l(couch_uuids:random()), + config:set("couch_httpd_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.5. + + +max_age() -> + config:get_integer("couch_httpd_csrf", "timeout", 3600). + + +timestamp() -> + couch_httpd_auth:make_cookie_time().