diff --git a/app/controllers/concerns/idv/document_capture_concern.rb b/app/controllers/concerns/idv/document_capture_concern.rb index d28f0d68398..3eb49969f0f 100644 --- a/app/controllers/concerns/idv/document_capture_concern.rb +++ b/app/controllers/concerns/idv/document_capture_concern.rb @@ -3,6 +3,14 @@ module DocumentCaptureConcern def override_document_capture_step_csp return if params[:step] != 'document_capture' + if FeatureManagement.rails_csp_tooling_enabled? + override_document_capture_step_csp_with_rails_csp_tooling + else + override_document_capture_step_csp_with_secure_headers + end + end + + def override_document_capture_step_csp_with_secure_headers SecureHeaders.append_content_security_policy_directives( request, # required to run wasm until wasm-eval is available @@ -13,5 +21,13 @@ def override_document_capture_step_csp img_src: ['blob:'], ) end + + def override_document_capture_step_csp_with_rails_csp_tooling + policy = current_content_security_policy + policy.script_src(*policy.script_src, :unsafe_eval) + policy.style_src(*policy.style_src, :unsafe_inline) + policy.img_src(*policy.img_src, 'blob:') + request.content_security_policy = policy + end end end diff --git a/app/controllers/concerns/piv_cac_concern.rb b/app/controllers/concerns/piv_cac_concern.rb index a0d426d2fa4..a305c4d42a9 100644 --- a/app/controllers/concerns/piv_cac_concern.rb +++ b/app/controllers/concerns/piv_cac_concern.rb @@ -28,10 +28,7 @@ def piv_session end def set_piv_cac_setup_csp_form_action_uris - override_content_security_policy_directives( - form_action: piv_cac_setup_csp_form_action_uris, - preserve_schemes: true, - ) + override_form_action_csp(piv_cac_setup_csp_form_action_uris) end def piv_cac_setup_csp_form_action_uris diff --git a/app/controllers/concerns/secure_headers_concern.rb b/app/controllers/concerns/secure_headers_concern.rb index c1f2c9e478d..ff3e271151e 100644 --- a/app/controllers/concerns/secure_headers_concern.rb +++ b/app/controllers/concerns/secure_headers_concern.rb @@ -5,19 +5,31 @@ def apply_secure_headers_override return if stored_url_for_user.blank? authorize_form = OpenidConnectAuthorizeForm.new(authorize_params) - return unless authorize_form.valid? - override_csp_with_uris + override_form_action_csp(csp_uris) + end + + def override_form_action_csp(uris) + if FeatureManagement.rails_csp_tooling_enabled? + apply_secure_headers_override_with_rails_csp_tooling(uris) + else + apply_secure_headers_override_with_secure_headers(uris) + end end - def override_csp_with_uris + def apply_secure_headers_override_with_secure_headers(uris) override_content_security_policy_directives( - form_action: csp_uris, - preserve_schemes: true, + form_action: uris, ) end + def apply_secure_headers_override_with_rails_csp_tooling(uris) + policy = current_content_security_policy + policy.form_action(*uris) + request.content_security_policy = policy + end + def csp_uris return ["'self'"] if stored_url_for_user.blank? # Returns fully formed CSP array w/"'self'" and redirect_uris diff --git a/app/controllers/openid_connect/authorization_controller.rb b/app/controllers/openid_connect/authorization_controller.rb index b5b423110bc..eda2da11c66 100644 --- a/app/controllers/openid_connect/authorization_controller.rb +++ b/app/controllers/openid_connect/authorization_controller.rb @@ -12,7 +12,7 @@ class AuthorizationController < ApplicationController before_action :sign_out_if_prompt_param_is_login_and_user_is_signed_in, only: [:index] before_action :store_request, only: [:index] before_action :check_sp_active, only: [:index] - before_action :override_csp_with_uris, only: [:index] + before_action :apply_secure_headers_override, only: [:index] before_action :confirm_user_is_authenticated_with_fresh_mfa, only: :index before_action :prompt_for_password_if_ial2_request_and_pii_locked, only: [:index] before_action :bump_auth_count, only: [:index] diff --git a/app/controllers/saml_idp_controller.rb b/app/controllers/saml_idp_controller.rb index ecfb0878783..d6d4d0d1565 100644 --- a/app/controllers/saml_idp_controller.rb +++ b/app/controllers/saml_idp_controller.rb @@ -11,6 +11,7 @@ class SamlIdpController < ApplicationController include VerifyProfileConcern include AuthorizationCountConcern include BillableEventTrackable + include SecureHeadersConcern prepend_before_action :skip_session_load, only: :metadata prepend_before_action :skip_session_expiration, only: :metadata @@ -118,7 +119,7 @@ def render_template_for(message, action_url, type) csp_uris = SecureHeadersAllowList.csp_with_sp_redirect_uris( action_url, decorated_session.sp_redirect_uris ) - override_content_security_policy_directives(form_action: csp_uris) + override_form_action_csp(csp_uris) render( template: 'saml_idp/shared/saml_post_binding', diff --git a/app/helpers/secure_headers_helper.rb b/app/helpers/secure_headers_helper.rb new file mode 100644 index 00000000000..0c018da20d7 --- /dev/null +++ b/app/helpers/secure_headers_helper.rb @@ -0,0 +1,34 @@ +module SecureHeadersHelper + def backwards_compatible_javascript_tag(*args, **opts, &block) + if FeatureManagement.rails_csp_tooling_enabled? + javascript_tag(*args, opts.merge(nonce: true), &block) + else + nonced_javascript_tag(*args, **opts, &block) + end + end + + def add_document_capture_image_urls_to_csp(request, urls) + cleaned_urls = urls.compact.map do |url| + URI(url).tap { |uri| uri.query = nil }.to_s + end + + if FeatureManagement.rails_csp_tooling_enabled? + add_document_capture_image_urls_to_csp_with_rails_csp_tooling(request, cleaned_urls) + else + add_document_capture_image_urls_to_csp_with_secure_headers(request, cleaned_urls) + end + end + + def add_document_capture_image_urls_to_csp_with_secure_headers(request, urls) + SecureHeaders.append_content_security_policy_directives( + request, + connect_src: urls, + ) + end + + def add_document_capture_image_urls_to_csp_with_rails_csp_tooling(request, urls) + policy = request.content_security_policy.clone + policy.connect_src(*policy.connect_src, *urls) + request.content_security_policy = policy + end +end diff --git a/app/views/idv/shared/_document_capture.html.erb b/app/views/idv/shared/_document_capture.html.erb index 2652a17d180..6bfe52e6503 100644 --- a/app/views/idv/shared/_document_capture.html.erb +++ b/app/views/idv/shared/_document_capture.html.erb @@ -4,17 +4,10 @@ <%= tag.meta name: 'acuant-sdk-initialization-creds', content: IdentityConfig.store.acuant_sdk_initialization_creds %> <%= stylesheet_link_tag 'document-capture' %> <% end %> -<% SecureHeaders.append_content_security_policy_directives( +<% add_document_capture_image_urls_to_csp( request, - connect_src: [ - front_image_upload_url, - back_image_upload_url, - selfie_image_upload_url, - ]. - compact. - map { |url| URI(url).tap { |uri| uri.query = nil }.to_s }, + [front_image_upload_url, back_image_upload_url, selfie_image_upload_url], ) - session_id = flow_session[:document_capture_session_uuid] %> <%= tag.div id: 'document-capture-form', data: { @@ -147,7 +140,7 @@ ) %> -<%= nonced_javascript_tag do %> +<%= backwards_compatible_javascript_tag do %> <% asset_keys = [ 'close-white-alt.svg', 'id-card.svg', diff --git a/app/views/layouts/base.html.erb b/app/views/layouts/base.html.erb index 0bc90419613..6e1a48d1f7d 100644 --- a/app/views/layouts/base.html.erb +++ b/app/views/layouts/base.html.erb @@ -23,7 +23,7 @@ ) %> - <%= nonced_javascript_tag do %> + <%= backwards_compatible_javascript_tag do %> document.documentElement.className = document.documentElement.className.replace(/\bno-js\b/, 'js'); <% end %> <%= preload_link_tag font_url('identity-style-guide/dist/assets/fonts/source-sans-pro/sourcesanspro-regular-webfont.woff2') %> diff --git a/app/views/shared/_banner.html.erb b/app/views/shared/_banner.html.erb index 292ff01cc30..1799d82dcef 100644 --- a/app/views/shared/_banner.html.erb +++ b/app/views/shared/_banner.html.erb @@ -22,7 +22,7 @@
- <%= nonced_javascript_tag do %> + <%= backwards_compatible_javascript_tag do %> document.getElementById('gov-banner').setAttribute('hidden', ''); <% end %>
diff --git a/app/views/shared/_dap_analytics.html.erb b/app/views/shared/_dap_analytics.html.erb index 951e42b3f02..effe6144aa8 100644 --- a/app/views/shared/_dap_analytics.html.erb +++ b/app/views/shared/_dap_analytics.html.erb @@ -1,4 +1,4 @@ <% dap_source = 'https://dap.digitalgov.gov/Universal-Federated-Analytics-Min.js?agency=GSA&subagency=TTS' %> -<%= nonced_javascript_tag({ src: dap_source, async: true, id: '_fed_an_ua_tag' }) do %> +<%= backwards_compatible_javascript_tag({ src: dap_source, async: true, id: '_fed_an_ua_tag' }) do %> <% end %> diff --git a/app/views/shared/_failure.html.erb b/app/views/shared/_failure.html.erb index ad33067e6ca..884f0910377 100644 --- a/app/views/shared/_failure.html.erb +++ b/app/views/shared/_failure.html.erb @@ -23,5 +23,5 @@ <% presenter.next_steps.each do |step| %>

<%== step %>

<% end; if presenter.js %> - <%= nonced_javascript_tag presenter.js %> + <%= backwards_compatible_javascript_tag presenter.js %> <% end %> diff --git a/app/views/shared/newrelic/_browser_instrumentation.html.erb b/app/views/shared/newrelic/_browser_instrumentation.html.erb index a544f6c2ddb..c5289006f6e 100644 --- a/app/views/shared/newrelic/_browser_instrumentation.html.erb +++ b/app/views/shared/newrelic/_browser_instrumentation.html.erb @@ -1,4 +1,4 @@ -<%= nonced_javascript_tag do %> +<%= backwards_compatible_javascript_tag do %> window.NREUM||(NREUM={}),__nr_require=function(t,e,n){function r(n){if(!e[n]){var o=e[n]={exports:{}};t[n][0].call(o.exports,function(e){var o=t[n][1][e];return r(o||e)},o,o.exports)}return e[n].exports}if("function"==typeof __nr_require)return __nr_require;for(var o=0;o0&&(d-=1)}),c.on("internal-error",function(t){i("ierr",[t,(new Date).getTime(),!0])})},{}],3:[function(t,e,n){t("loader").features.ins=!0},{}],4:[function(t,e,n){function r(t){}if(window.performance&&window.performance.timing&&window.performance.getEntriesByType){var o=t("ee"),i=t("handle"),a=t(8),c=t(7),s="learResourceTimings",f="addEventListener",u="resourcetimingbufferfull",d="bstResource",l="resource",p="-start",h="-end",m="fn"+p,w="fn"+h,v="bstTimer",y="pushState";t("loader").features.stn=!0,t(6);var g=NREUM.o.EV;o.on(m,function(t,e){var n=t[0];n instanceof g&&(this.bstStart=Date.now())}),o.on(w,function(t,e){var n=t[0];n instanceof g&&i("bst",[n,e,this.bstStart,Date.now()])}),a.on(m,function(t,e,n){this.bstStart=Date.now(),this.bstType=n}),a.on(w,function(t,e){i(v,[e,this.bstStart,Date.now(),this.bstType])}),c.on(m,function(){this.bstStart=Date.now()}),c.on(w,function(t,e){i(v,[e,this.bstStart,Date.now(),"requestAnimationFrame"])}),o.on(y+p,function(t){this.time=Date.now(),this.startPath=location.pathname+location.hash}),o.on(y+h,function(t){i("bstHist",[location.pathname+location.hash,this.startPath,this.time])}),f in window.performance&&(window.performance["c"+s]?window.performance[f](u,function(t){i(d,[window.performance.getEntriesByType(l)]),window.performance["c"+s]()},!1):window.performance[f]("webkit"+u,function(t){i(d,[window.performance.getEntriesByType(l)]),window.performance["webkitC"+s]()},!1)),document[f]("scroll",r,!1),document[f]("keypress",r,!1),document[f]("click",r,!1)}},{}],5:[function(t,e,n){function r(t){for(var e=t;e&&!e.hasOwnProperty(u);)e=Object.getPrototypeOf(e);e&&o(e)}function o(t){c.inPlace(t,[u,d],"-",i)}function i(t,e){return t[1]}var a=t("ee").get("events"),c=t(17)(a,!0),s=t("gos"),f=XMLHttpRequest,u="addEventListener",d="removeEventListener";e.exports=a,"getPrototypeOf"in Object?(r(document),r(window),r(f.prototype)):f.prototype.hasOwnProperty(u)&&(o(window),o(f.prototype)),a.on(u+"-start",function(t,e){var n=t[1],r=s(n,"nr@wrapped",function(){function t(){if("function"==typeof n.handleEvent)return n.handleEvent.apply(n,arguments)}var e={object:t,"function":n}[typeof n];return e?c(e,"fn-",null,e.name||"anonymous"):n});this.wrapped=t[1]=r}),a.on(d+"-start",function(t){t[1]=this.wrapped||t[1]})},{}],6:[function(t,e,n){var r=t("ee").get("history"),o=t(17)(r);e.exports=r,o.inPlace(window.history,["pushState","replaceState"],"-")},{}],7:[function(t,e,n){var r=t("ee").get("raf"),o=t(17)(r),i="equestAnimationFrame";e.exports=r,o.inPlace(window,["r"+i,"mozR"+i,"webkitR"+i,"msR"+i],"raf-"),r.on("raf-start",function(t){t[0]=o(t[0],"fn-")})},{}],8:[function(t,e,n){function r(t,e,n){t[0]=a(t[0],"fn-",null,n)}function o(t,e,n){this.method=n,this.timerDuration="number"==typeof t[1]?t[1]:0,t[0]=a(t[0],"fn-",this,n)}var i=t("ee").get("timer"),a=t(17)(i),c="setTimeout",s="setInterval",f="clearTimeout",u="-start",d="-";e.exports=i,a.inPlace(window,[c,"setImmediate"],c+d),a.inPlace(window,[s],s+d),a.inPlace(window,[f,"clearImmediate"],f+d),i.on(s+u,r),i.on(c+u,o)},{}],9:[function(t,e,n){function r(t,e){d.inPlace(e,["onreadystatechange"],"fn-",c)}function o(){var t=this,e=u.context(t);t.readyState>3&&!e.resolved&&(e.resolved=!0,u.emit("xhr-resolved",[],t)),d.inPlace(t,w,"fn-",c)}function i(t){v.push(t),h&&(g=-g,b.data=g)}function a(){for(var t=0;t34||p<10)||window.opera||t.addEventListener("progress",function(t){e.lastSize=t.loaded},!1)}),f.on("open-xhr-start",function(t){this.params={method:t[0]},i(this,t[1]),this.metrics={}}),f.on("open-xhr-end",function(t,e){"loader_config"in NREUM&&"xpid"in NREUM.loader_config&&this.sameOrigin&&e.setRequestHeader("X-NewRelic-ID",NREUM.loader_config.xpid)}),f.on("send-xhr-start",function(t,e){var n=this.metrics,r=t[0],o=this;if(n&&r){var i=h(r);i&&(n.txSize=i)}this.startTime=(new Date).getTime(),this.listener=function(t){try{"abort"===t.type&&(o.params.aborted=!0),("load"!==t.type||o.called===o.totalCbs&&(o.onloadCalled||"function"!=typeof e.onload))&&o.end(e)}catch(n){try{f.emit("internal-error",[n])}catch(r){}}};for(var a=0;a",applicationID:"<%= IdentityConfig.store.newrelic_browser_app_id %>",sa:1} <% end %> diff --git a/config/application.yml.default b/config/application.yml.default index bbd8b8948d1..fe75f67e10f 100644 --- a/config/application.yml.default +++ b/config/application.yml.default @@ -174,6 +174,7 @@ push_notifications_enabled: 'false' pwned_passwords_file_path: 'pwned_passwords/pwned_passwords.txt' rack_mini_profiler: 'false' rack_timeout_service_timeout_seconds: '15' +rails_csp_tooling_enabled: 'true' rails_mailer_previews_enabled: 'false' reauthn_window: '120' recovery_code_length: '4' diff --git a/config/initializers/content_security_policy.rb b/config/initializers/content_security_policy.rb new file mode 100644 index 00000000000..98cc6aa2336 --- /dev/null +++ b/config/initializers/content_security_policy.rb @@ -0,0 +1,67 @@ +require 'feature_management' + +if FeatureManagement.rails_csp_tooling_enabled? + # rubocop:disable Metrics/BlockLength + Rails.application.config.content_security_policy do |policy| + connect_src = ["'self'", '*.nr-data.net', '*.google-analytics.com', 'us.acas.acuant.net'] + + font_src = [:self, :data, IdentityConfig.store.asset_host.presence].compact + + image_src = [ + "'self'", + 'data:', + 'login.gov', + IdentityConfig.store.asset_host.presence, + 'idscangoweb.acuant.com', + IdentityConfig.store.aws_region.presence && + "https://s3.#{IdentityConfig.store.aws_region}.amazonaws.com", + ].select(&:present?) + + script_src = [ + :self, + 'js-agent.newrelic.com', + '*.nr-data.net', + 'dap.digitalgov.gov', + '*.google-analytics.com', + IdentityConfig.store.asset_host.presence, + ].compact + + script_src = [:self, :unsafe_eval] if !Rails.env.production? + + style_src = [:self, IdentityConfig.store.asset_host.presence].compact + + if ENV['WEBPACK_PORT'] + connect_src << "ws://localhost:#{ENV['WEBPACK_PORT']}" + script_src << "localhost:#{ENV['WEBPACK_PORT']}" + end + + if !IdentityConfig.store.disable_csp_unsafe_inline + script_src << :unsafe_inline + style_src << :unsafe_inline + end + + if IdentityConfig.store.rails_mailer_previews_enabled + style_src << :unsafe_inline + # CSP 2.0 only; overriden by x_frame_options in some browsers + policy.frame_ancestors :self + end + + policy.default_src :self + policy.child_src :self # CSP 2.0 only; replaces frame_src + policy.form_action :self + policy.block_all_mixed_content true # CSP 2.0 only; + policy.connect_src(*connect_src.flatten.compact) + policy.font_src(*font_src) + policy.img_src(*image_src) + policy.media_src :self + policy.object_src :none + policy.script_src(*script_src) + policy.style_src(*style_src) + policy.base_uri :self + end + # rubocop:enable Metrics/BlockLength + Rails.application.configure do + config.content_security_policy_nonce_generator = ->(request) { request.session.id.to_s } + config.content_security_policy_nonce_directives = ['script-src'] + end +end diff --git a/config/initializers/secure_headers.rb b/config/initializers/secure_headers.rb index f805ff60a33..49ce7b42ecb 100644 --- a/config/initializers/secure_headers.rb +++ b/config/initializers/secure_headers.rb @@ -1,3 +1,5 @@ +require 'feature_management' + SecureHeaders::Configuration.default do |config| # rubocop:disable Metrics/BlockLength config.hsts = "max-age=#{365.days.to_i}; includeSubDomains; preload" config.x_frame_options = 'DENY' @@ -40,21 +42,22 @@ } if IdentityConfig.store.rails_mailer_previews_enabled + default_csp_config[:style_src] << "'unsafe-inline'" # CSP 2.0 only; overriden by x_frame_options in some browsers default_csp_config[:frame_ancestors] = %w['self'] end default_csp_config[:script_src] = ["'self'", "'unsafe-eval'"] if !Rails.env.production? - if IdentityConfig.store.rails_mailer_previews_enabled - default_csp_config[:style_src] << "'unsafe-inline'" + if ENV['WEBPACK_PORT'] + default_csp_config[:connect_src] << "ws://localhost:#{ENV['WEBPACK_PORT']}" + default_csp_config[:script_src] << "localhost:#{ENV['WEBPACK_PORT']}" end - config.csp = default_csp_config - - if ENV['WEBPACK_PORT'] - config.csp[:connect_src] << "ws://localhost:#{ENV['WEBPACK_PORT']}" - config.csp[:script_src] << "localhost:#{ENV['WEBPACK_PORT']}" + if FeatureManagement.rails_csp_tooling_enabled? + config.csp = SecureHeaders::OPT_OUT + else + config.csp = default_csp_config end config.cookies = { diff --git a/lib/feature_management.rb b/lib/feature_management.rb index a26e92bbe38..9d78ffee3cd 100644 --- a/lib/feature_management.rb +++ b/lib/feature_management.rb @@ -120,4 +120,8 @@ def self.voip_allowed_phones allowed_phones.map { |p| Phonelib.parse(p).e164 }.to_set end end + + def self.rails_csp_tooling_enabled? + IdentityConfig.store.rails_csp_tooling_enabled == true + end end diff --git a/lib/identity_config.rb b/lib/identity_config.rb index d7d7be1c243..b5c29e2cdfd 100644 --- a/lib/identity_config.rb +++ b/lib/identity_config.rb @@ -257,6 +257,7 @@ def self.build_store(config_map) config.add(:pwned_passwords_file_path, type: :string) config.add(:rack_mini_profiler, type: :boolean) config.add(:rack_timeout_service_timeout_seconds, type: :integer) + config.add(:rails_csp_tooling_enabled, type: :boolean) config.add(:rails_mailer_previews_enabled, type: :boolean) config.add(:reauthn_window, type: :integer) config.add(:recovery_code_length, type: :integer) diff --git a/spec/controllers/concerns/idv/document_capture_concern_spec.rb b/spec/controllers/concerns/idv/document_capture_concern_spec.rb index 1dc62cafa4d..7b111605230 100644 --- a/spec/controllers/concerns/idv/document_capture_concern_spec.rb +++ b/spec/controllers/concerns/idv/document_capture_concern_spec.rb @@ -13,7 +13,7 @@ def index; end it 'sets the headers for the document capture step' do get :index, params: { step: 'document_capture' } - csp = response.request.headers.env['secure_headers_request_config'].csp + csp = response.request.content_security_policy expect(csp.script_src).to include("'unsafe-eval'") expect(csp.style_src).to include("'unsafe-inline'") expect(csp.img_src).to include('blob:') diff --git a/spec/controllers/saml_idp_controller_spec.rb b/spec/controllers/saml_idp_controller_spec.rb index 9b904cb454f..458a9b2424b 100644 --- a/spec/controllers/saml_idp_controller_spec.rb +++ b/spec/controllers/saml_idp_controller_spec.rb @@ -1267,7 +1267,7 @@ def name_id_version(format_urn) end it 'sets correct CSP config that includes any custom app scheme uri from SP redirect_uris' do - form_action = response.request.headers.env['secure_headers_request_config'].csp.form_action + form_action = response.request.content_security_policy.form_action csp_array = ["'self'", 'http://localhost:3000', 'x-example-app:'] expect(form_action).to match_array(csp_array) end diff --git a/spec/requests/csp_spec.rb b/spec/requests/csp_spec.rb index 911e163c568..27c160d429c 100644 --- a/spec/requests/csp_spec.rb +++ b/spec/requests/csp_spec.rb @@ -49,7 +49,9 @@ ) expect(content_security_policy['media-src']).to eq("'self'") expect(content_security_policy['object-src']).to eq("'none'") - expect(content_security_policy['script-src']).to eq("'self' 'unsafe-eval'") + expect(content_security_policy['script-src']).to match( + /'self' 'unsafe-eval' 'nonce-[\w\d=\/+]+'/, + ) expect(content_security_policy['style-src']).to eq("'self'") end end diff --git a/spec/views/idv/shared/_document_capture.html.erb_spec.rb b/spec/views/idv/shared/_document_capture.html.erb_spec.rb index c8eb6ae747a..72a77f389aa 100644 --- a/spec/views/idv/shared/_document_capture.html.erb_spec.rb +++ b/spec/views/idv/shared/_document_capture.html.erb_spec.rb @@ -37,13 +37,12 @@ let(:async_uploads_enabled) { false } it 'does not modify CSP connect_src headers' do - allow(SecureHeaders).to receive(:append_content_security_policy_directives).with(any_args) - expect(SecureHeaders).to receive(:append_content_security_policy_directives).with( - controller.request, - connect_src: [], - ) - render_partial + + connect_src = controller.request.content_security_policy.connect_src + expect(connect_src).to eq( + ["'self'", '*.nr-data.net', '*.google-analytics.com', 'us.acas.acuant.net'], + ) end end @@ -54,17 +53,12 @@ let(:selfie_image_upload_url) { 'https://s3.example.com/bucket/c?X-Amz-Security-Token=UAOL2' } it 'does modifies CSP connect_src headers to include upload urls' do - allow(SecureHeaders).to receive(:append_content_security_policy_directives).with(any_args) - expect(SecureHeaders).to receive(:append_content_security_policy_directives).with( - controller.request, - connect_src: [ - 'https://s3.example.com/bucket/a', - 'https://s3.example.com/bucket/b', - 'https://s3.example.com/bucket/c', - ], - ) - render_partial + + connect_src = controller.request.content_security_policy.connect_src + expect(connect_src).to include('https://s3.example.com/bucket/a') + expect(connect_src).to include('https://s3.example.com/bucket/b') + expect(connect_src).to include('https://s3.example.com/bucket/c') end end end