diff options
author | Jan Lehnardt <jan@apache.org> | 2012-12-05 19:18:25 +0100 |
---|---|---|
committer | Jan Lehnardt <jan@apache.org> | 2012-12-05 23:01:57 +0100 |
commit | b90e40212663474e873fde6cab343c31c1e635e7 (patch) | |
tree | dc008f2ff9c5048ca6ec34125cf53d53f13404c4 | |
parent | 56f969b3c49375c5321eb3cceb0fe346f8535c22 (diff) | |
download | couchdb-b90e40212663474e873fde6cab343c31c1e635e7.tar.gz |
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.
-rw-r--r-- | CHANGES | 2 | ||||
-rw-r--r-- | NEWS | 1 | ||||
-rw-r--r-- | etc/couchdb/default.ini.tpl.in | 25 | ||||
-rw-r--r-- | src/couchdb/Makefile.am | 2 | ||||
-rw-r--r-- | src/couchdb/couch_httpd.erl | 42 | ||||
-rw-r--r-- | src/couchdb/couch_httpd_cors.erl | 343 | ||||
-rw-r--r-- | src/couchdb/couch_httpd_vhost.erl | 25 | ||||
-rw-r--r-- | test/etap/231-cors.t | 402 | ||||
-rw-r--r-- | test/etap/Makefile.am | 3 |
9 files changed, 825 insertions, 20 deletions
@@ -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: @@ -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 ------------- diff --git a/etc/couchdb/default.ini.tpl.in b/etc/couchdb/default.ini.tpl.in index 79ece5cd3..25621238d 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 diff --git a/src/couchdb/Makefile.am b/src/couchdb/Makefile.am index 570597648..2b067b412 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 \ diff --git a/src/couchdb/couch_httpd.erl b/src/couchdb/couch_httpd.erl index cfca0cdaf..64d1cf114 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")). diff --git a/src/couchdb/couch_httpd_cors.erl b/src/couchdb/couch_httpd_cors.erl new file mode 100644 index 000000000..2581a887f --- /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. diff --git a/src/couchdb/couch_httpd_vhost.erl b/src/couchdb/couch_httpd_vhost.erl index 59f05ce79..96804cb5b 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) -> diff --git a/test/etap/231-cors.t b/test/etap/231-cors.t new file mode 100644 index 000000000..7fd724f20 --- /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) -> + []. diff --git a/test/etap/Makefile.am b/test/etap/Makefile.am index c969758e8..957d1a15b 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 |