You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@couchdb.apache.org by va...@apache.org on 2021/09/10 06:01:29 UTC

[couchdb] 01/01: feat: backport fine-grained CSP support to 3.1.x

This is an automated email from the ASF dual-hosted git repository.

vatamane pushed a commit to branch port-csp-patch-to-3.1.x
in repository https://gitbox.apache.org/repos/asf/couchdb.git

commit 6d4822d4932ed191c5ae4dfd0d8be6b601829a34
Author: Jan Lehnardt <ja...@apache.org>
AuthorDate: Fri Jul 23 22:28:42 2021 +0200

    feat: backport fine-grained CSP support to 3.1.x
    
    The original commit [0] was in 3.x branch
    
    This introduces CSP settings for attachments and show/list funs and
    streamlines the configuration with the existing Fauxton CSP options.
    
    Deprecates the old `[csp] enable` and `[csp] header_value` config
    options, but they are honoured going forward.
    
    They are replaced with `[csp] utils_enable` and `[csp] utils_header_value`
    respectively. The functionality and default values remain the same.
    
    In addition, these new config options are added, along with their
    default values:
    
    ```
    [csp]
    attachments_enable = false
    attachments_header_value = sandbox
    showlist_enable = false
    showlist_header_value = sandbox
    ```
    
    When enabled, these add `Content-Security-Policy` headers to all attachment
    requests and to all non-JSON show and all list function responses.
    
    Co-authored-by: Nick Vatamaniuc <va...@gmail.com>
    Co-authored-by: Robert Newson <rn...@apache.org>
    
    [0] https://github.com/apache/couchdb/commit/64281c0358e206a54e3b1386a7bc3b3e7c30547f
---
 rel/overlay/etc/default.ini                |  10 +-
 src/chttpd/src/chttpd_db.erl               |   3 +-
 src/chttpd/src/chttpd_external.erl         |   3 +-
 src/chttpd/src/chttpd_misc.erl             |  13 +-
 src/chttpd/src/chttpd_util.erl             |  56 +++++++
 src/chttpd/test/eunit/chttpd_csp_tests.erl | 253 +++++++++++++++++++++++++----
 src/chttpd/test/eunit/chttpd_db_test.erl   |  45 +++++
 src/couch_mrview/src/couch_mrview_show.erl |   4 +-
 8 files changed, 342 insertions(+), 45 deletions(-)

diff --git a/rel/overlay/etc/default.ini b/rel/overlay/etc/default.ini
index 8010626..8cf470f 100644
--- a/rel/overlay/etc/default.ini
+++ b/rel/overlay/etc/default.ini
@@ -281,10 +281,14 @@ iterations = 10 ; iterations for password hashing
 ; Set the SameSite cookie property for the auth cookie. If empty, the SameSite property is not set.
 ; same_site =
 
-; CSP (Content Security Policy) Support for _utils
+; CSP (Content Security Policy) Support
 [csp]
-enable = true
-; header_value = default-src 'self'; img-src 'self'; font-src *; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline';
+;utils_enable = true
+;utils_header_value = default-src 'self'; img-src 'self'; font-src *; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline';
+;attachments_enable = false
+;attachments_header_value = sandbox
+;showlist_enable = false
+;showlist_header_value = sandbox
 
 [cors]
 credentials = false
diff --git a/src/chttpd/src/chttpd_db.erl b/src/chttpd/src/chttpd_db.erl
index b995460..a2e9315 100644
--- a/src/chttpd/src/chttpd_db.erl
+++ b/src/chttpd/src/chttpd_db.erl
@@ -1447,7 +1447,7 @@ db_attachment_req(#httpd{method='GET',mochi_req=MochiReq}=Req, Db, DocId, FileNa
            atom_to_list(Enc),
            couch_httpd:accepted_encodings(Req)
         ),
-        Headers = [
+        Headers0 = [
             {"ETag", Etag},
             {"Cache-Control", "must-revalidate"},
             {"Content-Type", binary_to_list(Type)}
@@ -1464,6 +1464,7 @@ db_attachment_req(#httpd{method='GET',mochi_req=MochiReq}=Req, Db, DocId, FileNa
             _ ->
                 [{"Accept-Ranges", "none"}]
         end,
+        Headers = chttpd_util:maybe_add_csp_header("attachments", Headers0, "sandbox"),
         Len = case {Enc, ReqAcceptsAttEnc} of
         {identity, _} ->
             % stored and served in identity form
diff --git a/src/chttpd/src/chttpd_external.erl b/src/chttpd/src/chttpd_external.erl
index 451d87d..70b5235 100644
--- a/src/chttpd/src/chttpd_external.erl
+++ b/src/chttpd/src/chttpd_external.erl
@@ -139,7 +139,8 @@ send_external_response(Req, Response) ->
     Headers1 = default_or_content_type(CType, Headers0),
     case Json of
     nil ->
-        chttpd:send_response(Req, Code, Headers1, Data);
+        Headers2 = chttpd_util:maybe_add_csp_header("showlist", Headers1, "sandbox"),
+        chttpd:send_response(Req, Code, Headers2, Data);
     Json ->
         chttpd:send_json(Req, Code, Headers1, Json)
     end.
diff --git a/src/chttpd/src/chttpd_misc.erl b/src/chttpd/src/chttpd_misc.erl
index 830fea3..f52210a 100644
--- a/src/chttpd/src/chttpd_misc.erl
+++ b/src/chttpd/src/chttpd_misc.erl
@@ -93,8 +93,9 @@ handle_utils_dir_req(#httpd{method='GET'}=Req, DocumentRoot) ->
     {_ActionKey, "/", RelativePath} ->
         % GET /_utils/path or GET /_utils/
         CachingHeaders = [{"Cache-Control", "private, must-revalidate"}],
-        EnableCsp = config:get("csp", "enable", "false"),
-        Headers = maybe_add_csp_headers(CachingHeaders, EnableCsp),
+        DefaultValues = "child-src 'self' data: blob:; default-src 'self'; img-src 'self' data:; font-src 'self'; "
+            "script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline';",
+        Headers = chttpd_util:maybe_add_csp_header("utils", CachingHeaders, DefaultValues),
         chttpd:serve_file(Req, RelativePath, DocumentRoot, Headers);
     {_ActionKey, "", _RelativePath} ->
         % GET /_utils
@@ -104,14 +105,6 @@ handle_utils_dir_req(#httpd{method='GET'}=Req, DocumentRoot) ->
 handle_utils_dir_req(Req, _) ->
     send_method_not_allowed(Req, "GET,HEAD").
 
-maybe_add_csp_headers(Headers, "true") ->
-    DefaultValues = "child-src 'self' data: blob:; default-src 'self'; img-src 'self' data:; font-src 'self'; "
-                    "script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline';",
-    Value = config:get("csp", "header_value", DefaultValues),
-    [{"Content-Security-Policy", Value} | Headers];
-maybe_add_csp_headers(Headers, _) ->
-    Headers.
-
 handle_all_dbs_req(#httpd{method='GET'}=Req) ->
     Args = couch_mrview_http:parse_params(Req, undefined),
     ShardDbName = config:get("mem3", "shards_db", "_dbs"),
diff --git a/src/chttpd/src/chttpd_util.erl b/src/chttpd/src/chttpd_util.erl
new file mode 100644
index 0000000..639299f
--- /dev/null
+++ b/src/chttpd/src/chttpd_util.erl
@@ -0,0 +1,56 @@
+% 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.
+
+-module(chttpd_util).
+
+
+-export([
+    maybe_add_csp_header/3
+]).
+
+
+maybe_add_csp_header(Component, OriginalHeaders, DefaultHeaderValue) ->
+    Default = case Component of
+        "utils" -> true;
+         _Other -> false
+    end,
+    Enabled = config:get_boolean("csp", Component ++ "_enable", Default),
+    case Enabled of
+        true ->
+            HeaderValue = config:get("csp", Component ++ "_header_value", DefaultHeaderValue),
+            % As per https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy#multiple_content_security_policies
+            % The top most CSP header defines the most open policy,
+            % subsequent CSP headers set by show/list functions can
+            % only further restrict the policy.
+            %
+            % Ours goes on top and we don’t have to worry about additional
+            % headers set by users.
+            [{"Content-Security-Policy", HeaderValue} | OriginalHeaders];
+        false ->
+            % Fallback for old config vars
+            case Component of
+                "utils" ->
+                    handle_legacy_config(OriginalHeaders, DefaultHeaderValue);
+                _ ->
+                    OriginalHeaders
+            end
+    end.
+
+handle_legacy_config(OriginalHeaders, DefaultHeaderValue) ->
+    LegacyUtilsEnabled = config:get_boolean("csp", "enable", true),
+    case LegacyUtilsEnabled of
+        true ->
+            LegacyUtilsHeaderValue = config:get("csp", "header_value", DefaultHeaderValue),
+            [{"Content-Security-Policy", LegacyUtilsHeaderValue} | OriginalHeaders];
+        false ->
+            OriginalHeaders
+    end.
diff --git a/src/chttpd/test/eunit/chttpd_csp_tests.erl b/src/chttpd/test/eunit/chttpd_csp_tests.erl
index b80e3fe..d1be06d 100644
--- a/src/chttpd/test/eunit/chttpd_csp_tests.erl
+++ b/src/chttpd/test/eunit/chttpd_csp_tests.erl
@@ -12,70 +12,265 @@
 
 -module(chttpd_csp_tests).
 
+-include_lib("couch/include/couch_db.hrl").
 -include_lib("couch/include/couch_eunit.hrl").
 
-
-setup() ->
-    ok = config:set("csp", "enable", "true", false),
-    Addr = config:get("chttpd", "bind_address", "127.0.0.1"),
-    Port = mochiweb_socket_server:get(chttpd, port),
-    lists:concat(["http://", Addr, ":", Port, "/_utils/"]).
-
-teardown(_) ->
-    ok.
-
-
+-define(ADM_USER, "adm_user").
+-define(ADM_PASS, "adm_pass").
+-define(ADM, {?ADM_USER, ?ADM_PASS}).
+-define(ACC_USER, "acc").
+-define(ACC_PASS, "pass").
+-define(ACC, {?ACC_USER, ?ACC_PASS}).
+-define(DOC1, "doc1").
+-define(DDOC1, "_design/ddoc1").
+-define(DDOC1_PATH_ENC, "_design%2Fddoc1").
+-define(LDOC1, "_local/ldoc1").
+-define(LDOC1_PATH_ENC, "_local%2Fldoc1").
+-define(ATT1, "att1").
+-define(VIEW1, "view1").
+-define(SHOW1, "show1").
+-define(LIST1, "list1").
+-define(SALT, <<"01234567890123456789012345678901">>).
+-define(TDEF(Name), {atom_to_list(Name), fun Name/1}).
+-define(TDEF(Name, Timeout), {atom_to_list(Name), Timeout, fun Name/1}).
+-define(TDEF_FE(Name), fun(Arg) -> {atom_to_list(Name), ?_test(Name(Arg))} end).
+-define(TDEF_FE(Name, Timeout), fun(Arg) -> {atom_to_list(Name), {timeout, Timeout, ?_test(Name(Arg))}} end).
 
 csp_test_() ->
     {
-        "Content Security Policy tests",
+        "CSP Tests",
         {
             setup,
-            fun chttpd_test_util:start_couch/0, fun chttpd_test_util:stop_couch/1,
+            fun setup_all/0,
+            fun teardown_all/1,
             {
                 foreach,
-                fun setup/0, fun teardown/1,
+                fun setup/0,
+                fun cleanup/1,
                 [
+                    ?TDEF_FE(plain_docs_not_sandboxed),
+                    ?TDEF_FE(plain_ddocs_not_sandboxed),
+                    ?TDEF_FE(local_docs_not_sandboxed),
+                    ?TDEF_FE(sandbox_doc_attachments),
+                    ?TDEF_FE(sandbox_ddoc_attachments),
+                    ?TDEF_FE(sandbox_shows),
+                    ?TDEF_FE(sandbox_lists),
                     fun should_not_return_any_csp_headers_when_disabled/1,
+                    fun should_apply_default_policy_with_legacy_config/1,
                     fun should_apply_default_policy/1,
-                    fun should_return_custom_policy/1,
-                    fun should_only_enable_csp_when_true/1
+                    fun should_return_custom_policy/1
                 ]
             }
         }
     }.
 
+plain_docs_not_sandboxed(DbName) ->
+    DbUrl = base_url() ++ "/" ++ DbName,
+    Url = DbUrl ++ "/" ++ ?DOC1,
+    config:set("csp", "attachments_enable", "true", false),
+    ?assertEqual({200, false}, req(get, ?ACC, Url)),
+    config:delete("csp", "attachments_enable", false),
+    ?assertEqual({200, false}, req(get, ?ACC, Url)).
+
+plain_ddocs_not_sandboxed(DbName) ->
+    DbUrl = base_url() ++ "/" ++ DbName,
+    Url = DbUrl ++ "/" ++ ?DDOC1,
+    config:set("csp", "attachments_enable", "true", false),
+    ?assertEqual({200, false}, req(get, ?ACC, Url)),
+    config:delete("csp", "attachments_enable", false),
+    ?assertEqual({200, false}, req(get, ?ACC, Url)).
+
+local_docs_not_sandboxed(DbName) ->
+    DbUrl = base_url() ++ "/" ++ DbName,
+    Url = DbUrl ++ "/" ++ ?LDOC1,
+    config:set("csp", "attachments_enable", "true", false),
+    ?assertEqual({200, false}, req(get, ?ACC, Url)),
+    config:delete("csp", "attachments_enable", false),
+    ?assertEqual({200, false}, req(get, ?ACC, Url)).
+
+sandbox_doc_attachments(DbName) ->
+    DbUrl = base_url() ++ "/" ++ DbName,
+    Url = DbUrl ++ "/" ++ ?DOC1 ++ "/" ++ ?ATT1,
+    ?assertEqual({200, false}, req(get, ?ACC, Url)),
+    config:set("csp", "attachments_enable", "true", false),
+    ?assertEqual({200, true}, req(get, ?ACC, Url)),
+    config:delete("csp", "attachments_enable", false),
+    ?assertEqual({200, false}, req(get, ?ACC, Url)).
+
+sandbox_ddoc_attachments(DbName) ->
+    DbUrl = base_url() ++ "/" ++ DbName,
+    Url = DbUrl ++ "/" ++ ?DDOC1 ++ "/" ++ ?ATT1,
+    config:set("csp", "attachments_enable", "true", false),
+    ?assertEqual({200, true}, req(get, ?ACC, Url)),
+    config:delete("csp", "attachments_enable", false),
+    ?assertEqual({200, false}, req(get, ?ACC, Url)).
+
+sandbox_shows(DbName) ->
+    DbUrl = base_url() ++ "/" ++ DbName,
+    DDocUrl =  DbUrl ++ "/" ++ ?DDOC1,
+    Url = DDocUrl ++ "/_show/" ++ ?SHOW1 ++ "/" ++ ?DOC1,
+    config:set("csp", "showlist_enable", "true", false),
+    ?assertEqual({200, true}, req(get, ?ACC, Url)),
+    config:delete("csp", "showlist_enable", false),
+    ?assertEqual({200, false}, req(get, ?ACC, Url)).
 
-should_not_return_any_csp_headers_when_disabled(Url) ->
+sandbox_lists(DbName) ->
+    DbUrl = base_url() ++ "/" ++ DbName,
+    DDocUrl =  DbUrl ++ "/" ++ ?DDOC1,
+    Url = DDocUrl ++ "/_list/" ++ ?LIST1 ++ "/" ++ ?VIEW1,
+    config:set("csp", "showlist_enable", "true", false),
+    ?assertEqual({200, true}, req(get, ?ACC, Url)),
+    config:delete("csp", "showlist_enable", false),
+    ?assertEqual({200, false}, req(get, ?ACC, Url)).
+
+
+should_not_return_any_csp_headers_when_disabled(_DbName) ->
     ?_assertEqual(undefined,
         begin
+            ok = config:set("csp", "utils_enable", "false", false),
             ok = config:set("csp", "enable", "false", false),
-            {ok, _, Headers, _} = test_request:get(Url),
+            {ok, _, Headers, _} = test_request:get(base_url() ++ "/_utils/"),
             proplists:get_value("Content-Security-Policy", Headers)
         end).
 
-should_apply_default_policy(Url) ->
+should_apply_default_policy(_DbName) ->
     ?_assertEqual(
         "child-src 'self' data: blob:; default-src 'self'; img-src 'self' data:; font-src 'self'; "
         "script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline';",
         begin
-            {ok, _, Headers, _} = test_request:get(Url),
+            {ok, _, Headers, _} = test_request:get(base_url() ++ "/_utils/"),
             proplists:get_value("Content-Security-Policy", Headers)
         end).
 
-should_return_custom_policy(Url) ->
-    ?_assertEqual("default-src 'http://example.com';",
+should_apply_default_policy_with_legacy_config(_DbName) ->
+    ?_assertEqual(
+        "child-src 'self' data: blob:; default-src 'self'; img-src 'self' data:; font-src 'self'; "
+        "script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline';",
         begin
-            ok = config:set("csp", "header_value",
-                                  "default-src 'http://example.com';", false),
-            {ok, _, Headers, _} = test_request:get(Url),
+            ok = config:set("csp", "utils_enable", "false", false),
+            ok = config:set("csp", "enable", "true", false),
+            {ok, _, Headers, _} = test_request:get(base_url() ++ "/_utils/"),
             proplists:get_value("Content-Security-Policy", Headers)
         end).
 
-should_only_enable_csp_when_true(Url) ->
-    ?_assertEqual(undefined,
+should_return_custom_policy(_DbName) ->
+    ?_assertEqual("default-src 'http://example.com';",
         begin
-            ok = config:set("csp", "enable", "tru", false),
-            {ok, _, Headers, _} = test_request:get(Url),
+            ok = config:set("csp", "utils_header_value",
+                                  "default-src 'http://example.com';", false),
+            {ok, _, Headers, _} = test_request:get(base_url() ++ "/_utils/"),
             proplists:get_value("Content-Security-Policy", Headers)
         end).
+
+
+% Utility functions
+
+setup_all() ->
+    Ctx = test_util:start_couch([chttpd]),
+    Hashed = couch_passwords:hash_admin_password(?ADM_PASS),
+    config:set("admins", ?ADM_USER, ?b2l(Hashed), false),
+    config:set("log", "level", "debug", false),
+    Ctx.
+
+teardown_all(Ctx) ->
+    test_util:stop_couch(Ctx).
+
+setup() ->
+    UsersDb = ?b2l(?tempdb()),
+    config:set("chttpd_auth", "authentication_db", UsersDb, false),
+    UsersDbUrl = base_url() ++ "/" ++ UsersDb,
+    {201, _} = req(put, ?ADM, UsersDbUrl),
+    % Since we're dealing with the auth cache and ets_lru, it's best to just
+    % restart the whole application.
+    application:stop(chttpd),
+    ok = application:start(chttpd, permanent),
+    ok = create_user(UsersDb, <<?ACC_USER>>, <<?ACC_PASS>>, []),
+    DbName = ?b2l(?tempdb()),
+    DbUrl = base_url() ++ "/" ++ DbName,
+    {201, _} = req(put, ?ADM, DbUrl),
+    ok = create_doc(?ACC, DbName, #{
+        <<"_id">> => <<?DOC1>>,
+        <<"_attachments">> => #{
+            <<?ATT1>> => #{
+                <<"data">> => base64:encode(<<"att1_data">>)
+            }
+        }
+    }),
+    ok = create_doc(?ADM, DbName, #{
+        <<"_id">> => <<?DDOC1>>,
+        <<"_attachments">> => #{
+            <<?ATT1>> => #{
+                <<"data">> => base64:encode(<<"att1_data">>)
+            }
+        },
+        <<"views">> => #{
+            <<?VIEW1>> => #{
+                <<"map">> => <<"function(doc) {emit(doc._id, doc._rev)}">>
+            }
+        },
+        <<"shows">> => #{
+            <<?SHOW1>> => <<"function(doc, req) {return '<h1>show1!</h1>';}">>
+        },
+        <<"lists">> => #{
+            <<?LIST1>> => <<"function(head, req) {",
+                "var row;",
+                "while(row = getRow()){ send(row.key); };",
+            "}">>
+        }
+    }),
+    ok = create_doc(?ACC, DbName, #{<<"_id">> => <<?LDOC1>>}),
+    DbName.
+
+cleanup(DbName) ->
+    config:delete("csp", "utils_enable", _Persist=false),
+    config:delete("csp", "attachments_enable", _Persist=false),
+    config:delete("csp", "showlist_enable", _Persist=false),
+    DbUrl = base_url() ++ "/" ++ DbName,
+    {200, _} = req(delete, ?ADM, DbUrl),
+    UsersDb = config:get("chttpd_auth", "authentication_db"),
+    config:delete("chttpd_auth", "authentication_db", false),
+    UsersDbUrl = base_url() ++ "/" ++ UsersDb,
+    {200, _} = req(delete, ?ADM, UsersDbUrl).
+
+base_url() ->
+    Addr = config:get("chttpd", "bind_address", "127.0.0.1"),
+    Port = integer_to_list(mochiweb_socket_server:get(chttpd, port)),
+    "http://" ++ Addr ++ ":" ++ Port.
+
+create_user(UsersDb, Name, Pass, Roles) when is_list(UsersDb),
+        is_binary(Name), is_binary(Pass), is_list(Roles) ->
+    Body = #{
+        <<"name">> => Name,
+        <<"type">> => <<"user">>,
+        <<"roles">> => Roles,
+        <<"password_sha">> => hash_password(Pass),
+        <<"salt">> => ?SALT
+    },
+    Url = base_url() ++ "/" ++ UsersDb ++ "/" ++ "org.couchdb.user:" ++ ?b2l(Name),
+    {201, _} = req(put, ?ADM, Url, Body),
+    ok.
+
+hash_password(Password) when is_binary(Password) ->
+    couch_passwords:simple(Password, ?SALT).
+
+create_doc(Auth, DbName, Body) ->
+    Url = base_url() ++ "/" ++ DbName,
+    {201, _} = req(post, Auth, Url, Body),
+    ok.
+
+req(Method, {_, _} = Auth, Url) ->
+    Hdrs = [{basic_auth, Auth}],
+    {ok, Code, RespHdrs, _} = test_request:request(Method, Url, Hdrs),
+    {Code, is_sandboxed(RespHdrs)}.
+
+req(Method, {_, _} = Auth, Url, #{} = Body) ->
+    req(Method, {_, _} = Auth, Url, "application/json", #{} = Body).
+
+req(Method, {_, _} = Auth, Url, ContentType, #{} = Body) ->
+    Hdrs = [{basic_auth, Auth}, {"Content-Type", ContentType}],
+    Body1 = jiffy:encode(Body),
+    {ok, Code, RespHdrs, _} = test_request:request(Method, Url, Hdrs, Body1),
+    {Code, is_sandboxed(RespHdrs)}.
+
+is_sandboxed(Headers) ->
+    lists:member({"Content-Security-Policy", "sandbox"}, Headers).
diff --git a/src/chttpd/test/eunit/chttpd_db_test.erl b/src/chttpd/test/eunit/chttpd_db_test.erl
index d844aa5..fae9faf 100644
--- a/src/chttpd/test/eunit/chttpd_db_test.erl
+++ b/src/chttpd/test/eunit/chttpd_db_test.erl
@@ -69,6 +69,8 @@ all_test_() ->
                     fun should_return_404_for_delete_att_on_notadoc/1,
                     fun should_return_409_for_del_att_without_rev/1,
                     fun should_return_200_for_del_att_with_rev/1,
+                    fun should_not_send_csp_header_with_att_by_default/1,
+                    fun should_send_not_csp_header_with_att_when_no_config/1,
                     fun should_return_409_for_put_att_nonexistent_rev/1,
                     fun should_return_update_seq_when_set_on_all_docs/1,
                     fun should_not_return_update_seq_when_unset_on_all_docs/1,
@@ -209,6 +211,49 @@ should_return_200_for_del_att_with_rev(Url) ->
     end)}.
 
 
+should_not_send_csp_header_with_att_by_default(Url) ->
+  {timeout, ?TIMEOUT, ?_test(begin
+      {ok, RC, _, _} = test_request:put(
+          Url ++ "/testdoc5",
+          [?CONTENT_JSON, ?AUTH],
+          jiffy:encode(attachment_doc())
+      ),
+      ?assertEqual(201, RC),
+
+      {ok, _, Headers, _} = test_request:get(
+          Url ++ "/testdoc5/file.erl",
+          [?AUTH],
+          []
+      ),
+      CSPHeader = couch_util:get_value("Content-Security-Policy", Headers),
+      ?assertEqual(undefined, CSPHeader)
+    end)}.
+
+
+should_send_not_csp_header_with_att_when_no_config(Url) ->
+  {timeout, ?TIMEOUT, ?_test(begin
+      {ok, RC, _, _} = test_request:put(
+          Url ++ "/testdoc6",
+          [?CONTENT_JSON, ?AUTH],
+          jiffy:encode(attachment_doc())
+      ),
+      ?assertEqual(201, RC),
+
+      config:set_boolean("csp", "attachments_enable", false, _Persist=false),
+
+      {ok, _, Headers, _} = test_request:get(
+          Url ++ "/testdoc6/file.erl",
+          [?AUTH],
+          []
+      ),
+      CSPHeader = couch_util:get_value("Content-Security-Policy", Headers),
+      ?assertEqual(undefined, CSPHeader),
+
+      config:delete("csp", "attachments_enable", _Persist=false)
+
+    end)}.
+
+
 should_return_409_for_put_att_nonexistent_rev(Url) ->
     {timeout, ?TIMEOUT, ?_test(begin
         {ok, RC, _Headers, RespBody} = test_request:put(
diff --git a/src/couch_mrview/src/couch_mrview_show.erl b/src/couch_mrview/src/couch_mrview_show.erl
index 9056907..0268b70 100644
--- a/src/couch_mrview/src/couch_mrview_show.erl
+++ b/src/couch_mrview/src/couch_mrview_show.erl
@@ -259,7 +259,8 @@ fixup_headers(Headers, #lacc{etag=ETag} = Acc) ->
         headers = ExtHeaders
     } = chttpd_external:parse_external_response(Headers2),
     Headers3 = chttpd_external:default_or_content_type(CType, ExtHeaders),
-    Acc#lacc{code=Code, headers=Headers3}.
+    Headers4 = chttpd_util:maybe_add_csp_header("showlist", Headers3, "sandbox"),
+    Acc#lacc{code=Code, headers=Headers4}.
 
 send_list_row(Row, #lacc{qserver = {Proc, _}, req = Req, resp = Resp} = Acc) ->
     RowObj = case couch_util:get_value(id, Row) of
@@ -449,6 +450,7 @@ send_list_row_test_() ->
     }.
 
 setup() ->
+    ok = application:start(config, permanent),
     ok = meck:expect(chttpd, send_chunk,
         fun(Resp, _) -> {ok, Resp} end),
     ok = meck:expect(chttpd, send_chunked_error,