couchdb-commits mailing list archives

Site index · List index
Message view « Date » · « Thread »
Top « Date » · « Thread »
From j..@apache.org
Subject [4/4] git commit: Experimental support for Cross-Origin Resource Sharing (CORS).
Date Wed, 05 Dec 2012 22:23:35 GMT
Experimental support for Cross-Origin Resource Sharing (CORS).

Closes COUCHDB-431

Patch by:

 - Dale Harvey
 - Benoit Chesneau
 - Jan Lehnardt
 - Robert Newson

See `etc/couchdb/default.ini.tpl.in` for configuration examples.


Project: http://git-wip-us.apache.org/repos/asf/couchdb/repo
Commit: http://git-wip-us.apache.org/repos/asf/couchdb/commit/b90e4021
Tree: http://git-wip-us.apache.org/repos/asf/couchdb/tree/b90e4021
Diff: http://git-wip-us.apache.org/repos/asf/couchdb/diff/b90e4021

Branch: refs/heads/master
Commit: b90e40212663474e873fde6cab343c31c1e635e7
Parents: 56f969b
Author: Jan Lehnardt <jan@apache.org>
Authored: Wed Dec 5 19:18:25 2012 +0100
Committer: Jan Lehnardt <jan@apache.org>
Committed: Wed Dec 5 23:01:57 2012 +0100

----------------------------------------------------------------------
 CHANGES                           |    2 +
 NEWS                              |    1 +
 etc/couchdb/default.ini.tpl.in    |   25 ++
 src/couchdb/Makefile.am           |    2 +
 src/couchdb/couch_httpd.erl       |   42 +++-
 src/couchdb/couch_httpd_cors.erl  |  343 +++++++++++++++++++++++++++
 src/couchdb/couch_httpd_vhost.erl |   25 ++-
 test/etap/231-cors.t              |  402 ++++++++++++++++++++++++++++++++
 test/etap/Makefile.am             |    3 +-
 9 files changed, 825 insertions(+), 20 deletions(-)
----------------------------------------------------------------------


http://git-wip-us.apache.org/repos/asf/couchdb/blob/b90e4021/CHANGES
----------------------------------------------------------------------
diff --git a/CHANGES b/CHANGES
index 5009227..a394414 100644
--- a/CHANGES
+++ b/CHANGES
@@ -21,6 +21,8 @@ HTTP Interface:
    See http://www.w3.org/TR/eventsource/ for details.
  * Make password hashing synchronous when using the /_config/admins API.
  * Include user name in show/list ETags.
+ * Experimental support for Cross-Origin Resource Sharing (CORS).
+   See http://www.w3.org/TR/cors/ for details.
 
 Replicator:
 

http://git-wip-us.apache.org/repos/asf/couchdb/blob/b90e4021/NEWS
----------------------------------------------------------------------
diff --git a/NEWS b/NEWS
index f8b986f..fc578b5 100644
--- a/NEWS
+++ b/NEWS
@@ -29,6 +29,7 @@ This version has not been released yet.
  * Server-wide UUID in some replication ids.
  * E4X support in views is now deprecated and will be removed
    in a future version.
+ * Experimental support for Cross-Origin Resource Sharing (CORS).
 
 Version 1.2.1
 -------------

http://git-wip-us.apache.org/repos/asf/couchdb/blob/b90e4021/etc/couchdb/default.ini.tpl.in
----------------------------------------------------------------------
diff --git a/etc/couchdb/default.ini.tpl.in b/etc/couchdb/default.ini.tpl.in
index 79ece5c..2562123 100644
--- a/etc/couchdb/default.ini.tpl.in
+++ b/etc/couchdb/default.ini.tpl.in
@@ -49,6 +49,7 @@ allow_jsonp = false
 ; For more socket options, consult Erlang's module 'inet' man page.
 ;socket_options = [{recbuf, 262144}, {sndbuf, 262144}, {nodelay, true}]
 log_max_chunk_size = 1000000
+enable_cors = false
 
 [ssl]
 port = 6984
@@ -67,6 +68,30 @@ auth_cache_size = 50 ; size is number of cache entries
 allow_persistent_cookies = false ; set to true to allow persistent cookies
 iterations = 10000 ; iterations for password hashing
 
+[cors]
+credentials = false
+; List of origins separated by a comma, * means accept all
+; Origins must include the scheme: http://example.com
+; You can’t set origins: * and credentials = true at the same time.
+;origins = *
+; List of accepted headers separated by a comma
+; headers =
+; List of accepted methods
+; methods =
+
+
+; Configuration for a vhost
+;[cors:http://example.com]
+; credentials = false
+; List of origins separated by a comma
+; Origins must include the scheme: http://example.com
+; You can’t set origins: * and credentials = true at the same time.
+;origins =
+; List of accepted headers separated by a comma
+; headers =
+; List of accepted methods
+; methods =
+
 [couch_httpd_oauth]
 ; If set to 'true', oauth token and consumer secrets will be looked up
 ; in the authentication database (_users). These secrets are stored in

http://git-wip-us.apache.org/repos/asf/couchdb/blob/b90e4021/src/couchdb/Makefile.am
----------------------------------------------------------------------
diff --git a/src/couchdb/Makefile.am b/src/couchdb/Makefile.am
index 5705976..2b067b4 100644
--- a/src/couchdb/Makefile.am
+++ b/src/couchdb/Makefile.am
@@ -49,6 +49,7 @@ source_files = \
     couch_httpd.erl \
     couch_httpd_db.erl \
     couch_httpd_auth.erl \
+    couch_httpd_cors.erl \
     couch_httpd_oauth.erl \
     couch_httpd_external.erl \
     couch_httpd_misc_handlers.erl \
@@ -106,6 +107,7 @@ compiled_files = \
     couch_httpd_db.beam \
     couch_httpd_auth.beam \
     couch_httpd_oauth.beam \
+    couch_httpd_cors.beam \
     couch_httpd_proxy.beam \
     couch_httpd_external.beam \
     couch_httpd_misc_handlers.beam \

http://git-wip-us.apache.org/repos/asf/couchdb/blob/b90e4021/src/couchdb/couch_httpd.erl
----------------------------------------------------------------------
diff --git a/src/couchdb/couch_httpd.erl b/src/couchdb/couch_httpd.erl
index cfca0cd..64d1cf1 100644
--- a/src/couchdb/couch_httpd.erl
+++ b/src/couchdb/couch_httpd.erl
@@ -30,6 +30,7 @@
 -export([send_response/4,send_method_not_allowed/2,send_error/4, send_redirect/2,send_chunked_error/2]).
 -export([send_json/2,send_json/3,send_json/4,last_chunk/1,parse_multipart_request/3]).
 -export([accepted_encodings/1,handle_request_int/5,validate_referer/1,validate_ctype/2]).
+-export([http_1_0_keep_alive/2]).
 
 start_link() ->
     start_link(http).
@@ -279,7 +280,10 @@ handle_request_int(MochiReq, DefaultFun,
 
     % allow broken HTTP clients to fake a full method vocabulary with an X-HTTP-METHOD-OVERRIDE
header
     MethodOverride = MochiReq:get_primary_header_value("X-HTTP-Method-Override"),
-    Method2 = case lists:member(MethodOverride, ["GET", "HEAD", "POST", "PUT", "DELETE",
"TRACE", "CONNECT", "COPY"]) of
+    Method2 = case lists:member(MethodOverride, ["GET", "HEAD", "POST",
+                                                 "PUT", "DELETE",
+                                                 "TRACE", "CONNECT",
+                                                 "COPY"]) of
     true ->
         ?LOG_INFO("MethodOverride: ~s (real method was ~s)", [MethodOverride, Method1]),
         case Method1 of
@@ -318,11 +322,16 @@ handle_request_int(MochiReq, DefaultFun,
 
     {ok, Resp} =
     try
+        case couch_httpd_cors:is_preflight_request(HttpReq) of
+        #httpd{} ->
         case authenticate_request(HttpReq, AuthHandlers) of
         #httpd{} = Req ->
             HandlerFun(Req);
         Response ->
             Response
+            end;
+        Response ->
+            Response
         end
     catch
         throw:{http_head_abort, Resp0} ->
@@ -454,10 +463,14 @@ accepted_encodings(#httpd{mochi_req=MochiReq}) ->
 serve_file(Req, RelativePath, DocumentRoot) ->
     serve_file(Req, RelativePath, DocumentRoot, []).
 
-serve_file(#httpd{mochi_req=MochiReq}=Req, RelativePath, DocumentRoot, ExtraHeaders) ->
+serve_file(#httpd{mochi_req=MochiReq}=Req, RelativePath, DocumentRoot,
+           ExtraHeaders) ->
     log_request(Req, 200),
+    ResponseHeaders = server_header()
+        ++ couch_httpd_auth:cookie_auth_header(Req, [])
+        ++ ExtraHeaders,
     {ok, MochiReq:serve_file(RelativePath, DocumentRoot,
-        server_header() ++ couch_httpd_auth:cookie_auth_header(Req, []) ++ ExtraHeaders)}.
+            couch_httpd_cors:cors_headers(Req, ResponseHeaders))}.
 
 qs_value(Req, Key) ->
     qs_value(Req, Key, undefined).
@@ -607,7 +620,10 @@ log_request(#httpd{mochi_req=MochiReq,peer=Peer}, Code) ->
 start_response_length(#httpd{mochi_req=MochiReq}=Req, Code, Headers, Length) ->
     log_request(Req, Code),
     couch_stats_collector:increment({httpd_status_codes, Code}),
-    Resp = MochiReq:start_response_length({Code, Headers ++ server_header() ++ couch_httpd_auth:cookie_auth_header(Req,
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}),
     case MochiReq:get(method) of
     'HEAD' -> throw({http_head_abort, Resp});
     _ -> ok
@@ -618,7 +634,8 @@ start_response(#httpd{mochi_req=MochiReq}=Req, Code, Headers) ->
     log_request(Req, Code),
     couch_stats_collector:increment({httpd_status_codes, Code}),
     CookieHeader = couch_httpd_auth:cookie_auth_header(Req, Headers),
-    Headers2 = Headers ++ server_header() ++ CookieHeader,
+    Headers1 = Headers ++ server_header() ++ CookieHeader,
+    Headers2 = couch_httpd_cors:cors_headers(Req, Headers1),
     Resp = MochiReq:start_response({Code, Headers2}),
     case MochiReq:get(method) of
         'HEAD' -> throw({http_head_abort, Resp});
@@ -650,8 +667,11 @@ http_1_0_keep_alive(Req, Headers) ->
 start_chunked_response(#httpd{mochi_req=MochiReq}=Req, Code, Headers) ->
     log_request(Req, Code),
     couch_stats_collector:increment({httpd_status_codes, Code}),
-    Headers2 = http_1_0_keep_alive(MochiReq, Headers),
-    Resp = MochiReq:respond({Code, Headers2 ++ server_header() ++ couch_httpd_auth:cookie_auth_header(Req,
Headers2), chunked}),
+    Headers1 = http_1_0_keep_alive(MochiReq, 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}),
     case MochiReq:get(method) of
     'HEAD' -> throw({http_head_abort, Resp});
     _ -> ok
@@ -672,14 +692,18 @@ last_chunk(Resp) ->
 send_response(#httpd{mochi_req=MochiReq}=Req, Code, Headers, Body) ->
     log_request(Req, Code),
     couch_stats_collector:increment({httpd_status_codes, Code}),
-    Headers2 = http_1_0_keep_alive(MochiReq, Headers),
+    Headers1 = http_1_0_keep_alive(MochiReq, Headers),
     if Code >= 500 ->
         ?LOG_ERROR("httpd ~p error response:~n ~s", [Code, Body]);
     Code >= 400 ->
         ?LOG_DEBUG("httpd ~p error response:~n ~s", [Code, Body]);
     true -> ok
     end,
-    {ok, MochiReq:respond({Code, Headers2 ++ server_header() ++ couch_httpd_auth:cookie_auth_header(Req,
Headers2), Body})}.
+    Headers2 = Headers1 ++ server_header() ++
+               couch_httpd_auth:cookie_auth_header(Req, Headers1),
+    Headers3 = couch_httpd_cors:cors_headers(Req, Headers2),
+
+    {ok, MochiReq:respond({Code, Headers3, 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/blob/b90e4021/src/couchdb/couch_httpd_cors.erl
----------------------------------------------------------------------
diff --git a/src/couchdb/couch_httpd_cors.erl b/src/couchdb/couch_httpd_cors.erl
new file mode 100644
index 0000000..2581a88
--- /dev/null
+++ b/src/couchdb/couch_httpd_cors.erl
@@ -0,0 +1,343 @@
+% 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.
+
+%% @doc module to handle Cross-Origin Resource Sharing
+%%
+%% This module handles CROSS requests and preflight request for a
+%% couchdb Node. The config is done in the ini file.
+
+
+-module(couch_httpd_cors).
+
+-include("couch_db.hrl").
+
+-export([is_preflight_request/1, cors_headers/2]).
+
+-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").
+
+-define(SUPPORTED_METHODS, "GET, HEAD, POST, PUT, DELETE," ++
+        "TRACE, CONNECT, COPY, OPTIONS").
+
+% as defined in http://www.w3.org/TR/cors/#terminology
+-define(SIMPLE_HEADERS, ["Cache-Control", "Content-Language",
+        "Content-Type", "Expires", "Last-Modified", "Pragma"]).
+-define(SIMPLE_CONTENT_TYPE_VALUES, ["application/x-www-form-urlencoded",
+        "multipart/form-data", "text/plain"]).
+
+% TODO: - pick a sane default
+-define(CORS_DEFAULT_MAX_AGE, 12345).
+
+%% is_preflight_request/1
+
+% http://www.w3.org/TR/cors/#resource-preflight-requests
+
+is_preflight_request(#httpd{method=Method}=Req) when Method /= 'OPTIONS' ->
+    Req;
+is_preflight_request(Req) ->
+    EnableCors = enable_cors(),
+    is_preflight_request(Req, EnableCors).
+
+is_preflight_request(Req, false) ->
+    Req;
+is_preflight_request(#httpd{mochi_req=MochiReq}=Req, true) ->
+    case preflight_request(MochiReq) of
+    {ok, PreflightHeaders} ->
+        send_preflight_response(Req, PreflightHeaders);
+    _ ->
+        Req
+    end.
+
+
+preflight_request(MochiReq) ->
+    Origin = MochiReq:get_header_value("Origin"),
+    preflight_request(MochiReq, Origin).
+
+preflight_request(MochiReq, undefined) ->
+    % If the Origin header is not present terminate this set of
+    % steps. The request is outside the scope of this specification.
+    % http://www.w3.org/TR/cors/#resource-preflight-requests
+    MochiReq;
+preflight_request(MochiReq, Origin) ->
+    Host = couch_httpd_vhost:host(MochiReq),
+    AcceptedOrigins = get_accepted_origins(Host),
+    AcceptAll = lists:member("*", AcceptedOrigins),
+
+    HandlerFun = fun() ->
+        OriginList = couch_util:to_list(Origin),
+        handle_preflight_request(OriginList, Host, MochiReq)
+    end,
+
+    case AcceptAll of
+    true ->
+        % Always matching is acceptable since the list of
+        % origins can be unbounded.
+        % http://www.w3.org/TR/cors/#resource-preflight-requests
+        HandlerFun();
+    false ->
+        case lists:member(Origin, AcceptedOrigins) of
+        % The Origin header can only contain a single origin as
+        % the user agent will not follow redirects.
+        % http://www.w3.org/TR/cors/#resource-preflight-requests
+        % TODO: Square against multi origin thinger in Security Considerations
+        true ->
+            HandlerFun();
+        false ->
+            % If the value of the Origin header is not a
+            % case-sensitive match for any of the values
+            % in list of origins do not set any additional
+            % headers and terminate this set of steps.
+            % http://www.w3.org/TR/cors/#resource-preflight-requests
+            false
+        end
+    end.
+
+
+handle_preflight_request(Origin, Host, MochiReq) ->
+    %% get supported methods
+    SupportedMethods = split_list(cors_config(Host, "methods",
+                                              ?SUPPORTED_METHODS)),
+
+    % get supported headers
+    AllSupportedHeaders = split_list(cors_config(Host, "headers",
+                                                 ?SUPPORTED_HEADERS)),
+
+    SupportedHeaders = [string:to_lower(H) || H <- AllSupportedHeaders],
+
+    % get max age
+    MaxAge = cors_config(Host, "max_age", ?CORS_DEFAULT_MAX_AGE),
+
+    PreflightHeaders0 = maybe_add_credentials(Origin, Host, [
+        {"Access-Control-Allow-Origin", Origin},
+        {"Access-Control-Max-Age", MaxAge},
+        {"Access-Control-Allow-Methods",
+            string:join(SupportedMethods, ", ")}]),
+
+    case MochiReq:get_header_value("Access-Control-Request-Method") of
+    undefined ->
+        % If there is no Access-Control-Request-Method header
+        % or if parsing failed, do not set any additional headers
+        % and terminate this set of steps. The request is outside
+        % the scope of this specification.
+        % http://www.w3.org/TR/cors/#resource-preflight-requests
+        {ok, PreflightHeaders0};
+    Method ->
+        case lists:member(Method, SupportedMethods) of
+        true ->
+            % method ok , check headers
+            AccessHeaders = MochiReq:get_header_value(
+                    "Access-Control-Request-Headers"),
+            {FinalReqHeaders, ReqHeaders} = case AccessHeaders of
+                undefined -> {"", []};
+                Headers ->
+                    % transform header list in something we
+                    % could check. make sure everything is a
+                    % list
+                    RH = [string:to_lower(H)
+                          || H <- split_headers(Headers)],
+                    {Headers, RH}
+            end,
+            % check if headers are supported
+            case ReqHeaders -- SupportedHeaders of
+            [] ->
+                PreflightHeaders = PreflightHeaders0 ++
+                                   [{"Access-Control-Allow-Headers",
+                                     FinalReqHeaders}],
+                {ok, PreflightHeaders};
+            _ ->
+                false
+            end;
+        false ->
+        % If method is not a case-sensitive match for any of
+        % the values in list of methods do not set any additional
+        % headers and terminate this set of steps.
+        % http://www.w3.org/TR/cors/#resource-preflight-requests
+            false
+        end
+    end.
+
+
+send_preflight_response(#httpd{mochi_req=MochiReq}=Req, Headers) ->
+    couch_httpd:log_request(Req, 204),
+    couch_stats_collector:increment({httpd_status_codes, 204}),
+    Headers1 = couch_httpd:http_1_0_keep_alive(MochiReq, Headers),
+    Headers2 = Headers1 ++ couch_httpd:server_header() ++
+               couch_httpd_auth:cookie_auth_header(Req, Headers1),
+    {ok, MochiReq:respond({204, Headers2, <<>>})}.
+
+
+% cors_headers/1
+
+cors_headers(MochiReq, RequestHeaders) ->
+    EnableCors = enable_cors(),
+    CorsHeaders = do_cors_headers(MochiReq, EnableCors),
+    maybe_apply_cors_headers(CorsHeaders, RequestHeaders).
+
+do_cors_headers(#httpd{mochi_req=MochiReq}, true) ->
+    Host = couch_httpd_vhost:host(MochiReq),
+    AcceptedOrigins = get_accepted_origins(Host),
+    case MochiReq:get_header_value("Origin") of
+    undefined ->
+        % If the Origin header is not present terminate
+        % this set of steps. The request is outside the scope
+        % of this specification.
+        % http://www.w3.org/TR/cors/#resource-processing-model
+        [];
+    Origin ->
+        handle_cors_headers(couch_util:to_list(Origin),
+                            Host, AcceptedOrigins)
+    end;
+do_cors_headers(_MochiReq, false) ->
+    [].
+
+maybe_apply_cors_headers([], RequestHeaders) ->
+    RequestHeaders;
+maybe_apply_cors_headers(CorsHeaders, RequestHeaders0) ->
+    % for each RequestHeader that isn't in SimpleHeaders,
+    % (or Content-Type with SIMPLE_CONTENT_TYPE_VALUES)
+    % append to Access-Control-Exposed-Headers
+    % return: RequestHeaders ++ CorsHeaders ++ ACEH
+
+    RequestHeaders = [K || {K,_V} <- RequestHeaders0],
+    ExposedHeaders0 = reduce_headers(RequestHeaders, ?SIMPLE_HEADERS),
+
+    % here we may have not moved Content-Type into ExposedHeaders,
+    % now we need to check whether the Content-Type valus is
+    % in ?SIMPLE_CONTENT_TYPE_VALUES and if it isn’t add Content-
+    % Type to to ExposedHeaders
+    ContentType = string:to_lower(
+        proplists:get_value("Content-Type", RequestHeaders0)),
+
+    IncludeContentType = lists:member(ContentType, ?SIMPLE_CONTENT_TYPE_VALUES),
+    ExposedHeaders = case IncludeContentType of
+    false ->
+        lists:umerge(ExposedHeaders0, ["Content-Type"]);
+    true ->
+        ExposedHeaders0
+    end,
+    CorsHeaders
+    ++ RequestHeaders0
+    ++ [{"Access-Control-Exposed-Headers",
+            string:join(ExposedHeaders, ", ")}].
+
+
+reduce_headers(A, B) ->
+    reduce_headers0(A, B, []).
+
+reduce_headers0([], _B, Result) ->
+    Result;
+reduce_headers0([ElmA|RestA], B, Result) ->
+    R = case member_nocase(ElmA, B) of
+    true -> Result;
+    _Else -> [ElmA | Result]
+    end,
+    reduce_headers0(RestA, B, R).
+
+member_nocase(ElmA, List) ->
+    lists:any(fun(ElmB) ->
+        string:to_lower(ElmA) =:= string:to_lower(ElmB)
+    end, List).
+
+handle_cors_headers(_Origin, _Host, []) ->
+    [];
+handle_cors_headers(Origin, Host, AcceptedOrigins) ->
+    AcceptAll = lists:member("*", AcceptedOrigins),
+    case {AcceptAll, lists:member(Origin, AcceptedOrigins)} of
+    {true, _} ->
+        make_cors_header(Origin, Host);
+    {false, true}  ->
+        make_cors_header(Origin, Host);
+    _ ->
+        % If the value of the Origin header is not a
+        % case-sensitive match for any of the values
+        % in list of origins, do not set any additional
+        % headers and terminate this set of steps.
+        % http://www.w3.org/TR/cors/#resource-requests
+        []
+    end.
+
+
+make_cors_header(Origin, Host) ->
+    Headers = [{"Access-Control-Allow-Origin", Origin}],
+    maybe_add_credentials(Origin, Host, Headers).
+
+
+%% util
+
+maybe_add_credentials(Origin, Host, Headers) ->
+    maybe_add_credentials(Headers, allow_credentials(Origin, Host)).
+
+maybe_add_credentials(Headers, false) ->
+    Headers;
+maybe_add_credentials(Headers, true) ->
+    Headers ++ [{"Access-Control-Allow-Credentials", "true"}].
+
+
+allow_credentials("*", _Host) ->
+    false;
+allow_credentials(_Origin, Host) ->
+    Default = get_bool_config("cors", "credentials", false),
+    get_bool_config(cors_section(Host), "credentials", Default).
+
+
+
+cors_config(Host, Key, Default) ->
+    couch_config:get(cors_section(Host), Key,
+                     couch_config:get("cors", Key, Default)).
+
+cors_section(Host0) ->
+    {Host, _Port} = split_host_port(Host0),
+    "cors:" ++ Host.
+
+enable_cors() ->
+    get_bool_config("httpd", "enable_cors", false).
+
+get_bool_config(Section, Key, Default) ->
+    case couch_config:get(Section, Key) of
+    undefined ->
+        Default;
+    "true" ->
+        true;
+    "false" ->
+        false
+    end.
+
+get_accepted_origins(Host) ->
+    split_list(cors_config(Host, "origins", [])).
+
+split_list(S) ->
+    re:split(S, "\\s*,\\s*", [trim, {return, list}]).
+
+split_headers(H) ->
+    re:split(H, ",\\s*", [{return,list}, trim]).
+
+split_host_port(HostAsString) ->
+    % split at semicolon ":"
+    Split = string:rchr(HostAsString, $:),
+    split_host_port(HostAsString, Split).
+
+split_host_port(HostAsString, 0) ->
+    % no semicolon
+    {HostAsString, '*'};
+split_host_port(HostAsString, N) ->
+    HostPart = string:substr(HostAsString, 1, N-1),
+    % parse out port
+    % is there a nicer way?
+    case (catch erlang:list_to_integer(string:substr(HostAsString,
+                    N+1, length(HostAsString)))) of
+    {'EXIT', _} ->
+        {HostAsString, '*'};
+    Port ->
+        {HostPart, Port}
+    end.

http://git-wip-us.apache.org/repos/asf/couchdb/blob/b90e4021/src/couchdb/couch_httpd_vhost.erl
----------------------------------------------------------------------
diff --git a/src/couchdb/couch_httpd_vhost.erl b/src/couchdb/couch_httpd_vhost.erl
index 59f05ce..96804cb 100644
--- a/src/couchdb/couch_httpd_vhost.erl
+++ b/src/couchdb/couch_httpd_vhost.erl
@@ -15,7 +15,7 @@
 
 -export([start_link/0, config_change/2, reload/0, get_state/0, dispatch_host/1]).
 -export([urlsplit_netloc/2, redirect_to_vhost/2]).
-
+-export([host/1, split_host_port/1]).
 
 -export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]).
 
@@ -98,15 +98,7 @@ dispatch_host(MochiReq) ->
     {"/" ++ VPath, Query, Fragment} = mochiweb_util:urlsplit_path(MochiReq:get(raw_path)),
     VPathParts =  string:tokens(VPath, "/"),
 
-    XHost = couch_config:get("httpd", "x_forwarded_host", "X-Forwarded-Host"),
-    VHost = case MochiReq:get_header_value(XHost) of
-        undefined ->
-            case MochiReq:get_header_value("Host") of
-                undefined -> [];
-                Value1 -> Value1
-            end;
-        Value -> Value
-    end,
+    VHost = host(MochiReq),
     {VHostParts, VhostPort} = split_host_port(VHost),
     FinalMochiReq = case try_bind_vhost(VHosts, lists:reverse(VHostParts),
             VhostPort, VPathParts) of
@@ -243,6 +235,19 @@ bind_path(_, _) ->
 
 
 %% create vhost list from ini
+
+host(MochiReq) ->
+    XHost = couch_config:get("httpd", "x_forwarded_host",
+                             "X-Forwarded-Host"),
+    case MochiReq:get_header_value(XHost) of
+        undefined ->
+            case MochiReq:get_header_value("Host") of
+                undefined -> [];
+                Value1 -> Value1
+            end;
+        Value -> Value
+    end.
+
 make_vhosts() ->
     Vhosts = lists:foldl(fun
                 ({_, ""}, Acc) ->

http://git-wip-us.apache.org/repos/asf/couchdb/blob/b90e4021/test/etap/231-cors.t
----------------------------------------------------------------------
diff --git a/test/etap/231-cors.t b/test/etap/231-cors.t
new file mode 100644
index 0000000..7fd724f
--- /dev/null
+++ b/test/etap/231-cors.t
@@ -0,0 +1,402 @@
+#!/usr/bin/env escript
+%% -*- erlang -*-
+
+% 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.
+
+-record(user_ctx, {
+    name = null,
+    roles = [],
+    handler
+}).
+
+
+-define(SUPPORTED_METHODS, "GET, HEAD, POST, PUT, DELETE, TRACE, CONNECT, COPY, OPTIONS").
+server() ->
+    lists:concat([
+        "http://127.0.0.1:",
+        mochiweb_socket_server:get(couch_httpd, port),
+        "/"
+    ]).
+
+
+main(_) ->
+    test_util:init_code_path(),
+
+    etap:plan(28),
+    case (catch test()) of
+        ok ->
+            etap:end_tests();
+        Other ->
+            etap:diag(io_lib:format("Test died abnormally: ~p", [Other])),
+            etap:bail(Other)
+    end,
+    ok.
+
+dbname() -> "etap-test-db".
+dbname1() -> "etap-test-db1".
+dbname2() -> "etap-test-db2".
+
+admin_user_ctx() -> {user_ctx, #user_ctx{roles=[<<"_admin">>]}}.
+
+set_admin_password(UserName, Password) ->
+    Hashed = couch_passwords:hash_admin_password(Password),
+    couch_config:set("admins", UserName, Hashed, false).
+
+cycle_db(DbName) ->
+    couch_server:delete(list_to_binary(DbName), [admin_user_ctx()]),
+    {ok, Db} = couch_db:create(list_to_binary(DbName), [admin_user_ctx()]),
+    Db.
+
+test() ->
+
+    ibrowse:start(),
+    crypto:start(),
+
+    %% launch couchdb
+    couch_server_sup:start_link(test_util:config_files()),
+
+    %% initialize db
+    timer:sleep(1000),
+    Db = cycle_db(dbname()),
+    Db1 = cycle_db(dbname1()),
+    Db2 = cycle_db(dbname2()),
+
+    % CORS is disabled by default
+    test_no_headers_server(),
+    test_no_headers_db(),
+
+    % Now enable CORS
+    ok = couch_config:set("httpd", "enable_cors", "true", false),
+    ok = couch_config:set("cors", "origins", "http://example.com", false),
+
+    %% do tests
+    test_incorrect_origin_simple_request(),
+    test_incorrect_origin_preflight_request(),
+
+    test_preflight_request(),
+    test_db_request(),
+    test_db_preflight_request(),
+    test_db_origin_request(),
+    test_db1_origin_request(),
+    test_preflight_with_port1(),
+    test_preflight_with_scheme1(),
+
+    ok = couch_config:set("cors", "origins", "http://example.com:5984", false),
+    test_preflight_with_port2(),
+
+    ok = couch_config:set("cors", "origins", "https://example.com:5984", false),
+    test_preflight_with_scheme2(),
+
+    ok = couch_config:set("cors", "origins", "*", false),
+    test_preflight_with_wildcard(),
+
+    ok = couch_config:set("cors", "origins", "http://example.com", false),
+    test_case_sensitive_mismatch_of_allowed_origins(),
+
+    % http://www.w3.org/TR/cors/#supports-credentials
+    % 6.1.3
+    % If the resource supports credentials add a single
+    % Access-Control-Allow-Origin header, with the value
+    % of the Origin header as value, and add a single
+    % Access-Control-Allow-Credentials header with the
+    % case-sensitive string "true" as value.
+    % Otherwise, add a single Access-Control-Allow-Origin
+    % header, with either the value of the Origin header
+    % or the string "*" as value.
+    % Note: The string "*" cannot be used for a resource
+    % that supports credentials.
+    test_db_request_credentials_header_off(),
+    ok = couch_config:set("cors", "credentials", "true", false),
+    test_db_request_credentials_header_on(),
+    % We don’t test wildcards & credentials as that would
+    % fall into the realm of validating config values
+    % which we don’t do at all yet
+
+    % test with vhosts
+    ok = couch_config:set("vhosts", "example.com", "/", false),
+    test_preflight_request(true),
+    test_db_request(true),
+    test_db_preflight_request(true),
+    test_db_origin_request(true),
+    test_db1_origin_request(true),
+    test_preflight_with_port1(true),
+    test_preflight_with_scheme1(true),
+
+    % TBD
+    % test multiple per-host configuration
+
+    %% do tests with auth
+    ok = set_admin_password("test", "test"),
+
+    test_db_preflight_auth_request(),
+    test_db_origin_auth_request(),
+
+
+    %% restart boilerplate
+    catch couch_db:close(Db),
+    catch couch_db:close(Db1),
+    catch couch_db:close(Db2),
+
+    couch_server:delete(list_to_binary(dbname()), [admin_user_ctx()]),
+    couch_server:delete(list_to_binary(dbname1()), [admin_user_ctx()]),
+    couch_server:delete(list_to_binary(dbname2()), [admin_user_ctx()]),
+
+    timer:sleep(3000),
+    couch_server_sup:stop(),
+    ok.
+
+test_preflight_request() -> test_preflight_request(false).
+test_db_request() -> test_db_request(false).
+test_db_preflight_request() -> test_db_preflight_request(false).
+test_db_origin_request() -> test_db_origin_request(false).
+test_db1_origin_request() -> test_db1_origin_request(false).
+test_preflight_with_port1() -> test_preflight_with_port1(false).
+test_preflight_with_scheme1() -> test_preflight_with_scheme1(false).
+
+%% Cors is disabled, should not return Access-Control-Allow-Origin
+test_no_headers_server() ->
+    Headers = [{"Origin", "http://127.0.0.1"}],
+    {ok, _, Resp, _} = ibrowse:send_req(server(), Headers, get, []),
+    etap:is(proplists:get_value("Access-Control-Allow-Origin", Resp),
+            undefined, "No CORS Headers when disabled").
+
+%% Cors is disabled, should not return Access-Control-Allow-Origin
+test_no_headers_db() ->
+    Headers = [{"Origin", "http://127.0.0.1"}],
+    Url = server() ++ "etap-test-db",
+    {ok, _, Resp, _} = ibrowse:send_req(Url, Headers, get, []),
+    etap:is(proplists:get_value("Access-Control-Allow-Origin", Resp),
+            undefined, "No CORS Headers when disabled").
+
+test_incorrect_origin_simple_request() ->
+    Headers = [{"Origin", "http://127.0.0.1"}],
+    {ok, _, RespHeaders, _} = ibrowse:send_req(server(), Headers, get, []),
+    etap:is(proplists:get_value("Access-Control-Allow-Origin", RespHeaders),
+            undefined,
+            "Specified invalid origin, no Access").
+
+test_incorrect_origin_preflight_request() ->
+    Headers = [{"Origin", "http://127.0.0.1"},
+               {"Access-Control-Request-Method", "GET"}],
+    {ok, _, RespHeaders, _} = ibrowse:send_req(server(), Headers, options, []),
+    etap:is(proplists:get_value("Access-Control-Allow-Origin", RespHeaders),
+            undefined,
+            "invalid origin").
+
+test_preflight_request(VHost) ->
+    Headers = [{"Origin", "http://example.com"},
+               {"Access-Control-Request-Method", "GET"}]
+               ++ maybe_append_vhost(VHost),
+
+    case ibrowse:send_req(server(), Headers, options, []) of
+    {ok, _, RespHeaders, _}  ->
+        etap:is(proplists:get_value("Access-Control-Allow-Methods", RespHeaders),
+            ?SUPPORTED_METHODS,
+            "test_preflight_request Access-Control-Allow-Methods ok");
+    _ ->
+        etap:is(false, true, "ibrowse failed")
+    end.
+
+test_db_request(VHost) ->
+    Headers = [{"Origin", "http://example.com"}]
+               ++ maybe_append_vhost(VHost),
+    Url = server() ++ "etap-test-db",
+    case ibrowse:send_req(Url, Headers, get, []) of
+    {ok, _, RespHeaders, _Body} ->
+        etap:is(proplists:get_value("Access-Control-Allow-Origin", RespHeaders),
+            "http://example.com",
+            "db Access-Control-Allow-Origin ok"),
+        etap:is(proplists:get_value("Access-Control-Exposed-Headers", RespHeaders),
+            "Content-Type, Server",
+            "db Access-Control-Exposed-Headers ok");
+    _ ->
+        etap:is(false, true, "ibrowse failed")
+    end.
+
+test_db_request_credentials_header_off() ->
+    Headers = [{"Origin", "http://example.com"}],
+    Url = server() ++ "etap-test-db",
+    case ibrowse:send_req(Url, Headers, get, []) of
+    {ok, _, RespHeaders, _Body} ->
+        etap:is(proplists:get_value("Access-Control-Allow-Credentials", RespHeaders),
+            undefined,
+            "db Access-Control-Allow-Credentials off");
+    _ ->
+        etap:is(false, true, "ibrowse failed")
+    end.
+
+test_db_request_credentials_header_on() ->
+    Headers = [{"Origin", "http://example.com"}],
+    Url = server() ++ "etap-test-db",
+    case ibrowse:send_req(Url, Headers, get, []) of
+    {ok, _, RespHeaders, _Body} ->
+        etap:is(proplists:get_value("Access-Control-Allow-Credentials", RespHeaders),
+            "true",
+            "db Access-Control-Allow-Credentials ok");
+    _ ->
+        etap:is(false, true, "ibrowse failed")
+    end.
+
+test_db_preflight_request(VHost) ->
+    Url = server() ++ "etap-test-db",
+    Headers = [{"Origin", "http://example.com"},
+               {"Access-Control-Request-Method", "GET"}]
+               ++ maybe_append_vhost(VHost),
+    case ibrowse:send_req(Url, Headers, options, []) of
+    {ok, _, RespHeaders, _} ->
+        etap:is(proplists:get_value("Access-Control-Allow-Methods", RespHeaders),
+                ?SUPPORTED_METHODS,
+                "db Access-Control-Allow-Methods ok");
+    _ ->
+        etap:is(false, true, "ibrowse failed")
+    end.
+
+
+test_db_origin_request(VHost) ->
+    Headers = [{"Origin", "http://example.com"}]
+               ++ maybe_append_vhost(VHost),
+    Url = server() ++ "etap-test-db",
+    case ibrowse:send_req(Url, Headers, get, []) of
+    {ok, _, RespHeaders, _Body} ->
+        etap:is(proplists:get_value("Access-Control-Allow-Origin", RespHeaders),
+            "http://example.com",
+            "db origin ok");
+    _ ->
+        etap:is(false, true, "ibrowse failed")
+    end.
+
+test_db1_origin_request(VHost) ->
+    Headers = [{"Origin", "http://example.com"}]
+               ++ maybe_append_vhost(VHost),
+    Url = server() ++ "etap-test-db1",
+    case ibrowse:send_req(Url, Headers, get, [], [{host_header, "example.com"}]) of
+    {ok, _, RespHeaders, _Body} ->
+        etap:is(proplists:get_value("Access-Control-Allow-Origin", RespHeaders),
+            "http://example.com",
+            "db origin ok");
+    _Else ->
+        io:format("else ~p~n", [_Else]),
+        etap:is(false, true, "ibrowse failed")
+    end.
+
+test_db_preflight_auth_request() ->
+    Url = server() ++ "etap-test-db2",
+    Headers = [{"Origin", "http://example.com"},
+               {"Access-Control-Request-Method", "GET"}],
+    case ibrowse:send_req(Url, Headers, options, []) of
+    {ok, _Status, RespHeaders, _} ->
+        etap:is(proplists:get_value("Access-Control-Allow-Methods", RespHeaders),
+                ?SUPPORTED_METHODS,
+                "db Access-Control-Allow-Methods ok");
+    _ ->
+        etap:is(false, true, "ibrowse failed")
+    end.
+
+
+test_db_origin_auth_request() ->
+    Headers = [{"Origin", "http://example.com"}],
+    Url = server() ++ "etap-test-db2",
+
+    case ibrowse:send_req(Url, Headers, get, [],
+        [{basic_auth, {"test", "test"}}]) of
+    {ok, _, RespHeaders, _Body} ->
+        etap:is(proplists:get_value("Access-Control-Allow-Origin", RespHeaders),
+            "http://example.com",
+            "db origin ok");
+    _ ->
+        etap:is(false, true, "ibrowse failed")
+    end.
+
+test_preflight_with_wildcard() ->
+    Headers = [{"Origin", "http://example.com"},
+               {"Access-Control-Request-Method", "GET"}],
+    case ibrowse:send_req(server(), Headers, options, []) of
+    {ok, _, RespHeaders, _}  ->
+        % I would either expect the current origin or a wildcard to be returned
+        etap:is(proplists:get_value("Access-Control-Allow-Origin", RespHeaders),
+            "http://example.com",
+            "db origin ok");
+    _ ->
+        etap:is(false, true, "ibrowse failed")
+    end.
+
+test_preflight_with_port1(VHost) ->
+    Headers = [{"Origin", "http://example.com:5984"},
+               {"Access-Control-Request-Method", "GET"}]
+               ++ maybe_append_vhost(VHost),
+    case ibrowse:send_req(server(), Headers, options, []) of
+    {ok, _, RespHeaders, _}  ->
+        % I would either expect the current origin or a wildcard to be returned
+        etap:is(proplists:get_value("Access-Control-Allow-Origin", RespHeaders),
+            undefined,
+            "check non defined host:port in origin ok");
+    _ ->
+        etap:is(false, true, "ibrowse failed")
+    end.
+
+test_preflight_with_port2() ->
+    Headers = [{"Origin", "http://example.com:5984"},
+               {"Access-Control-Request-Method", "GET"}],
+    case ibrowse:send_req(server(), Headers, options, []) of
+    {ok, _, RespHeaders, _}  ->
+        % I would either expect the current origin or a wildcard to be returned
+        etap:is(proplists:get_value("Access-Control-Allow-Origin", RespHeaders),
+            "http://example.com:5984",
+            "check host:port in origin ok");
+    _ ->
+        etap:is(false, true, "ibrowse failed")
+    end.
+
+test_preflight_with_scheme1(VHost) ->
+    Headers = [{"Origin", "https://example.com:5984"},
+               {"Access-Control-Request-Method", "GET"}]
+               ++ maybe_append_vhost(VHost),
+    case ibrowse:send_req(server(), Headers, options, []) of
+    {ok, _, RespHeaders, _}  ->
+        % I would either expect the current origin or a wildcard to be returned
+        etap:is(proplists:get_value("Access-Control-Allow-Origin", RespHeaders),
+            undefined,
+            "check non defined scheme in origin ok");
+    _ ->
+        etap:is(false, true, "ibrowse failed")
+    end.
+
+test_preflight_with_scheme2() ->
+    Headers = [{"Origin", "https://example.com:5984"},
+               {"Access-Control-Request-Method", "GET"}],
+    case ibrowse:send_req(server(), Headers, options, []) of
+    {ok, _, RespHeaders, _}  ->
+        % I would either expect the current origin or a wildcard to be returned
+        etap:is(proplists:get_value("Access-Control-Allow-Origin", RespHeaders),
+            "https://example.com:5984",
+            "check scheme in origin ok");
+    _ ->
+        etap:is(false, true, "ibrowse failed")
+    end.
+
+test_case_sensitive_mismatch_of_allowed_origins() ->
+    Headers = [{"Origin", "http://EXAMPLE.COM"}],
+    Url = server() ++ "etap-test-db",
+    case ibrowse:send_req(Url, Headers, get, []) of
+    {ok, _, RespHeaders, _Body} ->
+        etap:is(proplists:get_value("Access-Control-Allow-Origin", RespHeaders),
+            undefined,
+            "db access config case mismatch");
+    _ ->
+        etap:is(false, true, "ibrowse failed")
+    end.
+
+maybe_append_vhost(true) ->
+    [{"Host", "http://example.com"}];
+maybe_append_vhost(Else) ->
+    [].

http://git-wip-us.apache.org/repos/asf/couchdb/blob/b90e4021/test/etap/Makefile.am
----------------------------------------------------------------------
diff --git a/test/etap/Makefile.am b/test/etap/Makefile.am
index c969758..957d1a1 100644
--- a/test/etap/Makefile.am
+++ b/test/etap/Makefile.am
@@ -91,4 +91,5 @@ EXTRA_DIST = \
     200-view-group-no-db-leaks.t \
     201-view-group-shutdown.t \
     210-os-proc-pool.t \
-    220-compaction-daemon.t
+    220-compaction-daemon.t \
+    231-cors.t


Mime
View raw message