Skip to content

Commit e9147dc

Browse files
committed
MB-48058:[BP] REST API for setting "Strict-Transport-Security"
response header. We can now set responseHeaders as below, curl -u Administrator:password \ -H "Content-Type: application/json" \ -X POST http://localhost:9000/settings/security/responseHeaders \ -d '{"Strict-Transport-Security": "max-age=10;includeSubDomains;preload"}' Currrently only the "Strict-Transport-Security" header is supported. Backports change related to MB-47096. Reviewed-on: http://review.couchbase.org/c/ns_server/+/156383 Change-Id: Ie98fee6f4f03705ea7c8023407086698577a0e6b Reviewed-on: http://review.couchbase.org/c/ns_server/+/165402 Well-Formed: Restriction Checker Tested-by: Build Bot <[email protected]> Reviewed-by: Steve Watanabe <[email protected]>
1 parent fc3b6e3 commit e9147dc

File tree

2 files changed

+110
-9
lines changed

2 files changed

+110
-9
lines changed

src/menelaus_web_settings.erl

Lines changed: 108 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,63 @@ get_tls_version(SV, Service) ->
123123
{error, lists:flatten(M)}
124124
end.
125125

126+
verify_hsts(Str) ->
127+
Props = lists:map(
128+
fun (P) ->
129+
P1 = [string:trim(S) || S <- string:split(P, "=")],
130+
case P1 of
131+
[K] ->
132+
{K, undefined};
133+
[K, V] ->
134+
{K, V}
135+
end
136+
end, string:split(Str, ";", all)),
137+
{Valid, Invalid} = lists:partition(
138+
fun ({"includeSubDomains", undefined}) ->
139+
true;
140+
({"preload", undefined}) ->
141+
true;
142+
({"max-age", Val}) ->
143+
Int = (catch erlang:list_to_integer(Val)),
144+
(is_integer(Int) andalso (Int >= 0));
145+
(_) ->
146+
false
147+
end, Props),
148+
case Invalid of
149+
[] ->
150+
case proplists:get_value("max-age", Valid) of
151+
undefined ->
152+
{error, "max-age directive is required"};
153+
_ ->
154+
case length(lists:ukeysort(1, Valid)) =:= length(Valid) of
155+
false ->
156+
{error, "Cannot have duplicate directives"};
157+
true ->
158+
ok
159+
end
160+
end;
161+
_ ->
162+
InvalidDir = [K || {K, _V} <- Invalid],
163+
M = io_lib:format("Invalid directives ~s",
164+
[lists:join("; ", InvalidDir)]),
165+
{error, lists:flatten(M)}
166+
end.
167+
168+
get_secure_headers(Json) ->
169+
try ejson:decode(Json) of
170+
{[{<<"Strict-Transport-Security">>, BinStr}]} ->
171+
Str = binary_to_list(BinStr),
172+
case verify_hsts(Str) of
173+
ok -> {ok, [{"Strict-Transport-Security", Str}]};
174+
Err -> Err
175+
end;
176+
_ ->
177+
{error, "Only \"Strict-Transport-Security\" header allowed"}
178+
catch
179+
_:_ ->
180+
{error, "Invalid format. Expecting a json."}
181+
end.
182+
126183
get_cipher_suites(Str) ->
127184
try ejson:decode(Str) of
128185
L when is_list(L) ->
@@ -177,10 +234,22 @@ services_with_security_settings() ->
177234
{cbas, analytics},
178235
{ns_server, clusterManager}].
179236

237+
is_allowed_on_cluster([secure_headers]) ->
238+
misc:is_strict_possible();
239+
is_allowed_on_cluster(_) ->
240+
true.
241+
180242
is_allowed_setting(K) ->
181243
case cluster_compat_mode:is_enterprise() orelse not ee_only_settings(K) of
182-
true -> ok;
183-
false -> {error, <<"not supported in community edition">>}
244+
true ->
245+
case is_allowed_on_cluster(K) of
246+
true ->
247+
ok;
248+
false ->
249+
{error, <<"Not supported in mixed version clusters.">>}
250+
end;
251+
false ->
252+
{error, <<"not supported in community edition">>}
184253
end.
185254

186255
ee_only_settings([ssl_minimum_protocol]) -> true;
@@ -193,6 +262,7 @@ conf(security) ->
193262
[{disable_ui_over_http, disableUIOverHttp, false, fun get_bool/1},
194263
{disable_ui_over_https, disableUIOverHttps, false, fun get_bool/1},
195264
{disable_www_authenticate, disableWWWAuthenticate, false, fun get_bool/1},
265+
{secure_headers, responseHeaders, [], fun get_secure_headers/1},
196266
{ui_session_timeout, uiSessionTimeout, undefined,
197267
get_number(60, 1000000, undefined)},
198268
{ssl_minimum_protocol, tlsMinVersion,
@@ -261,7 +331,18 @@ build_kvs(Conf, Config, Filter) ->
261331
undefined -> DV;
262332
V -> V
263333
end,
264-
Filter([CK], Val) andalso {true, {JK, Val}};
334+
case Filter([CK], Val) of
335+
true ->
336+
case Val of
337+
[{_K, _V} | _] ->
338+
CVal = [{K, list_to_binary(V)} || {K, V} <- Val],
339+
{true, {JK, {CVal}}};
340+
_ ->
341+
{true, {JK, Val}}
342+
end;
343+
false ->
344+
false
345+
end;
265346
({CK, JK, SubKeys}) when is_list(SubKeys) ->
266347
List = lists:filtermap(
267348
fun ({SubCK, SubJK, DV, _}) ->
@@ -869,24 +950,31 @@ handle_settings_rebalance_post(Req) ->
869950

870951
-ifdef(TEST).
871952
build_kvs_test() ->
872-
Cfg = [[{key2, value}, {key3, [{sub_key2, value}]}]],
953+
Cfg = [[{key2, value},
954+
{key3, [{sub_key2, value}]},
955+
{key4, [{key1, "value1"}, {key2, "value2"}]}]],
873956
Conf = [{key1, jsonKey1, default, fun (V) -> {ok, V} end},
874957
{key2, jsonKey2, default, fun (V) -> {ok, V} end},
875958
{key3, jsonKey3,
876959
[{sub_key1, subKey1, default, fun (V) -> {ok, V} end},
877-
{sub_key2, subKey2, default, fun (V) -> {ok, V} end}]}],
960+
{sub_key2, subKey2, default, fun (V) -> {ok, V} end}]},
961+
{key4, jsonKey4, default, fun (V) -> {ok, V} end}],
878962
?assertEqual([], build_kvs([], [], fun (_, _) -> true end)),
879963
?assertEqual([{jsonKey1, default}, {jsonKey2, value},
880-
{jsonKey3, {[{subKey1, default}, {subKey2, value}]}}],
964+
{jsonKey3, {[{subKey1, default}, {subKey2, value}]}},
965+
{jsonKey4, {[{key1, <<"value1">>}, {key2, <<"value2">>}]}}],
881966
build_kvs(Conf, Cfg, fun (_, _) -> true end)),
882-
?assertEqual([{jsonKey2, value}, {jsonKey3, {[{subKey2, value}]}}],
967+
?assertEqual([{jsonKey2, value},
968+
{jsonKey3, {[{subKey2, value}]}},
969+
{jsonKey4, {[{key1, <<"value1">>}, {key2, <<"value2">>}]}}],
883970
build_kvs(Conf, Cfg,
884971
fun (_, default) -> false; (_, _) -> true end)),
885972
ok.
886973

887974
test_conf() ->
888975
[{ssl_minimum_protocol,tlsMinVersion,unused, get_tls_version(_, all)},
889976
{cipher_suites,cipherSuites,unused, fun get_cipher_suites/1},
977+
{secure_headers, responseHeaders, [], fun get_secure_headers/1},
890978
{honor_cipher_order,honorCipherOrder,unused, fun get_bool/1},
891979
{{security_settings, kv}, data,
892980
[{cipher_suites, cipherSuites, unused, fun get_cipher_suites/1},
@@ -898,19 +986,26 @@ test_conf() ->
898986

899987
parse_post_data_test() ->
900988
Conf = test_conf(),
989+
RH = ejson:encode({[{"Strict-Transport-Security",
990+
<<"max-age=10%3Bpreload%3BincludeSubDomains">>}]}),
991+
ResponseHeaders = <<"responseHeaders=", RH/binary, "&">>,
901992
KeyValidator = fun ([not_allowed]) -> {error, <<"not allowed">>};
902993
(_) -> ok
903994
end,
904995
?assertEqual({ok, []}, parse_post_data(Conf, [], <<>>, KeyValidator)),
905-
?assertEqual({ok, [{[ssl_minimum_protocol], 'tlsv1.2'},
996+
?assertEqual({ok, [{[secure_headers],
997+
[{"Strict-Transport-Security",
998+
"max-age=10;preload;includeSubDomains"}]},
999+
{[ssl_minimum_protocol], 'tlsv1.2'},
9061000
{[cipher_suites], []},
9071001
{[honor_cipher_order], true},
9081002
{[{security_settings, kv}, ssl_minimum_protocol],
9091003
'tlsv1.3'},
9101004
{[{security_settings, kv}, cipher_suites], []},
9111005
{[{security_settings, kv}, honor_cipher_order], false}]},
9121006
parse_post_data(Conf, [],
913-
<<"tlsMinVersion=tlsv1.2&"
1007+
<<ResponseHeaders/binary,
1008+
"tlsMinVersion=tlsv1.2&"
9141009
"cipherSuites=[]&"
9151010
"honorCipherOrder=true&"
9161011
"data.tlsMinVersion=tlsv1.3&"
@@ -951,6 +1046,10 @@ parse_post_data_test() ->
9511046
"cipherSuites=[]&"
9521047
"cipherSuites=bad">>,
9531048
KeyValidator)),
1049+
?assertEqual({error, [<<"responseHeaders - Invalid format. "
1050+
"Expecting a json.">>]},
1051+
parse_post_data(Conf, ["responseHeaders"],
1052+
<<"bad">>, KeyValidator)),
9541053
?assertEqual({error, [<<"data.cipherSuites - Invalid format. "
9551054
"Expecting a list of ciphers.">>]},
9561055
parse_post_data(Conf, ["data", "cipherSuites"],

src/ns_audit.erl

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -828,6 +828,8 @@ client_cert_auth(Req, ClientCertAuth) ->
828828
security_settings(Req, Settings) ->
829829
Format = fun ({cipher_suites, deleted}) -> {cipher_suites, deleted};
830830
({cipher_suites, List}) -> {cipher_suites, {list, List}};
831+
({secure_headers, deleted}) -> {secure_headers, deleted};
832+
({secure_headers, List}) -> {secure_headers, {propset, List}};
831833
(KV) -> KV
832834
end,
833835
put(security_settings, Req,

0 commit comments

Comments
 (0)