Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Browse files

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.
  • Loading branch information...
commit b90e40212663474e873fde6cab343c31c1e635e7 1 parent 56f969b
@janl janl authored
View
2  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:
View
1  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
-------------
View
25 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
View
2  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 \
View
42 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")).
View
343 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",
@janl
janl added a note

Thanks, good catch! :)

fixed in 1.3.x and master

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
+ 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.
View
25 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) ->
View
402 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) ->
+ [].
View
3  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
@janl

Thanks, good catch! :)

fixed in 1.3.x and master

Please sign in to comment.
Something went wrong with that request. Please try again.