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

[couchdb] branch feat/csp-for-attachments created (now 6584b4c)

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

jan pushed a change to branch feat/csp-for-attachments
in repository https://gitbox.apache.org/repos/asf/couchdb.git.


      at 6584b4c  feat: add more fine-grained CSP support

This branch includes the following new commits:

     new 6584b4c  feat: add more fine-grained CSP support

The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.


[couchdb] 01/01: feat: add more fine-grained CSP support

Posted by ja...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

jan pushed a commit to branch feat/csp-for-attachments
in repository https://gitbox.apache.org/repos/asf/couchdb.git

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

    feat: add more fine-grained CSP support
    
    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 funcitonality and default values remain the same.
    
    In addition, these new config options are added, along with their
    default values:
    
    ```
    [csp]
    attachments_enable = true
    attachments_header_value = sandbox
    showlist_enable = true
    showlist_header_value = sandbox
    ```
    
    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>
---
 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             |  40 ++++-
 src/chttpd/test/eunit/chttpd_csp_tests.erl | 245 +++++++++++++++++++++++++----
 src/chttpd/test/eunit/chttpd_db_test.erl   |  45 ++++++
 src/couch_mrview/src/couch_mrview_show.erl |   3 +-
 8 files changed, 316 insertions(+), 46 deletions(-)

diff --git a/rel/overlay/etc/default.ini b/rel/overlay/etc/default.ini
index 5f916a0..4f547f4 100644
--- a/rel/overlay/etc/default.ini
+++ b/rel/overlay/etc/default.ini
@@ -311,10 +311,14 @@ authentication_db = _users
 ; max_iterations, password_scheme, password_regexp, proxy_use_secret,
 ; public_fields, secret, users_db_public, cookie_domain, 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 = true
+;attachments_header_value = sandbox
+;showlist_enable = true
+;showlist_header_value = sandbox
 
 [cors]
 ;credentials = false
diff --git a/src/chttpd/src/chttpd_db.erl b/src/chttpd/src/chttpd_db.erl
index 312adae..e2c8012 100644
--- a/src/chttpd/src/chttpd_db.erl
+++ b/src/chttpd/src/chttpd_db.erl
@@ -1469,7 +1469,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)}
@@ -1486,6 +1486,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 b44109e..12d1fe5 100644
--- a/src/chttpd/src/chttpd_external.erl
+++ b/src/chttpd/src/chttpd_external.erl
@@ -142,7 +142,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 ffe0344..25a0fa7 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", "true"),
-        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
index bbfc1ad..8a98972 100644
--- a/src/chttpd/src/chttpd_util.erl
+++ b/src/chttpd/src/chttpd_util.erl
@@ -21,7 +21,8 @@
     get_chttpd_auth_config/1,
     get_chttpd_auth_config/2,
     get_chttpd_auth_config_integer/2,
-    get_chttpd_auth_config_boolean/2
+    get_chttpd_auth_config_boolean/2,
+    maybe_add_csp_header/3
 ]).
 
 
@@ -60,3 +61,40 @@ get_chttpd_auth_config_integer(Key, Default) ->
 get_chttpd_auth_config_boolean(Key, Default) ->
     config:get_boolean("chttpd_auth", Key,
         config:get_boolean("couch_httpd_auth", Key, Default)).
+
+
+maybe_add_csp_header(Component, OriginalHeaders, DefaultHeaderValue) ->
+    Enabled = config:get_boolean("csp", Component ++ "_enable", true),
+    couch_log:info("~n CSP for ~p: Enabled~p~n", [Component, Enabled]),
+    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.
+            couch_log:info("~n adding CSP for ~p: ~p~n", [Component, HeaderValue]),
+            [{"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),
+    couch_log:info("~n Legacy CSP: Enabled~p~n", [LegacyUtilsEnabled]),
+    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..27935ee 100644
--- a/src/chttpd/test/eunit/chttpd_csp_tests.erl
+++ b/src/chttpd/test/eunit/chttpd_csp_tests.erl
@@ -12,70 +12,257 @@
 
 -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,
+    ?assertEqual({200, false}, req(get, ?ACC, Url)),
+    config:set("csp", "attachments_enable", "false", false),
+    ?assertEqual({200, false}, req(get, ?ACC, Url)).
+
+plain_ddocs_not_sandboxed(DbName) ->
+    DbUrl = base_url() ++ "/" ++ DbName,
+    Url = DbUrl ++ "/" ++ ?DDOC1,
+    ?assertEqual({200, false}, req(get, ?ACC, Url)),
+    config:set("csp", "attachments_enable", "false", false),
+    ?assertEqual({200, false}, req(get, ?ACC, Url)).
+
+local_docs_not_sandboxed(DbName) ->
+    DbUrl = base_url() ++ "/" ++ DbName,
+    Url = DbUrl ++ "/" ++ ?LDOC1,
+    ?assertEqual({200, false}, req(get, ?ACC, Url)),
+    config:set("csp", "attachments_enable", "false", false),
+    ?assertEqual({200, false}, req(get, ?ACC, Url)).
+
+sandbox_doc_attachments(DbName) ->
+    DbUrl = base_url() ++ "/" ++ DbName,
+    Url = DbUrl ++ "/" ++ ?DOC1 ++ "/" ++ ?ATT1,
+    ?assertEqual({200, true}, req(get, ?ACC, Url)),
+    config:set("csp", "attachments_enable", "false", false),
+    ?assertEqual({200, false}, req(get, ?ACC, Url)).
+
+sandbox_ddoc_attachments(DbName) ->
+    DbUrl = base_url() ++ "/" ++ DbName,
+    Url = DbUrl ++ "/" ++ ?DDOC1 ++ "/" ++ ?ATT1,
+    ?assertEqual({200, true}, req(get, ?ACC, Url)),
+    config:set("csp", "attachments_enable", "false", false),
+    ?assertEqual({200, false}, req(get, ?ACC, Url)).
+
+sandbox_shows(DbName) ->
+    DbUrl = base_url() ++ "/" ++ DbName,
+    DDocUrl =  DbUrl ++ "/" ++ ?DDOC1,
+    Url = DDocUrl ++ "/_show/" ++ ?SHOW1 ++ "/" ++ ?DOC1,
+    ?assertEqual({200, true}, req(get, ?ACC, Url)),
+    config:set("csp", "showlist_enable", "false", 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,
+    ?assertEqual({200, true}, req(get, ?ACC, Url)),
+    config:set("csp", "showlist_enable", "false", 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:set("csp", "utils_enable", "true", false),
+    config:set("csp", "attachments_enable", "true", false),
+    config:set("csp", "showlist_enable", "true", 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..7e8f197 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_send_csp_header_with_att/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_send_csp_header_with_att(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("sandbox", 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("attachments", "csp_enable", false),
+
+      {ok, _, Headers, _} = test_request:get(
+          Url ++ "/testdoc6/file.erl",
+          [?AUTH],
+          []
+      ),
+      CSPHeader = couch_util:get_value("Content-Security-Policy", Headers),
+      ?assertEqual(undefined, CSPHeader),
+
+      config:set_boolean("attachments", "csp_enable", true)
+
+    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..12ff400 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