require 'rack' require 'rack/response' require 'rollbar/request_data_extractor' require 'rollbar/util' module Rollbar module Middleware # Middleware to inject the rollbar.js snippet into a 200 html response class Js include Rollbar::RequestDataExtractor attr_reader :app attr_reader :config JS_IS_INJECTED_KEY = 'rollbar.js_is_injected'.freeze SNIPPET = File.read(File.expand_path('../../../../data/rollbar.snippet.js', __FILE__)) def initialize(app, config) @app = app @config = config end def call(env) app_result = app.call(env) begin return app_result unless add_js?(env, app_result[1]) response_string = add_js(env, app_result[2]) build_response(env, app_result, response_string) rescue StandardError => e Rollbar.log_error("[Rollbar] Rollbar.js could not be added because #{e} exception") app_result end end private def enabled? !!config[:enabled] end def add_js?(env, headers) enabled? && !env[JS_IS_INJECTED_KEY] && html?(headers) && !attachment?(headers) && !streaming?(env) end def html?(headers) headers['Content-Type'] && headers['Content-Type'].include?('text/html') end def attachment?(headers) headers['Content-Disposition'].to_s.include?('attachment') end def streaming?(env) return false unless defined?(ActionController::Live) env['action_controller.instance'].class.included_modules.include?(ActionController::Live) end def add_js(env, response) body = join_body(response) close_old_response(response) return nil unless body insert_after_idx = find_insertion_point(body) return nil unless insert_after_idx build_body_with_js(env, body, insert_after_idx) rescue StandardError => e Rollbar.log_error("[Rollbar] Rollbar.js could not be added because #{e} exception") nil end def build_response(env, app_result, response_string) return app_result unless response_string env[JS_IS_INJECTED_KEY] = true response = ::Rack::Response.new(response_string, app_result[0], app_result[1]) finished = response.finish # Rack < 2.x Response#finish returns self in array[2]. Rack >= 2.x returns self.body. # Always return with the response object here regardless of rack version. finished[2] = response finished end def build_body_with_js(env, body, head_open_end) return body unless head_open_end body[0..head_open_end] << config_js_tag(env) << snippet_js_tag(env) << body[head_open_end + 1..-1] end def find_insertion_point(body) find_end_after_regex(body, /', open_idx) if open_idx end def join_body(response) response.to_enum.reduce('') do |acc, fragment| acc << fragment.to_s acc end end def close_old_response(response) response.close if response.respond_to?(:close) end def config_js_tag(env) js_config = Rollbar::Util.deep_copy(config[:options]) add_person_data(js_config, env) # MUST use the Ruby JSON encoder (JSON#generate). # See lib/rollbar/middleware/js/json_value json = ::JSON.generate(js_config) script_tag("var _rollbarConfig = #{json};", env) end def add_person_data(js_config, env) person_data = extract_person_data_from_controller(env) return if person_data && person_data.empty? js_config[:payload] ||= {} js_config[:payload][:person] = person_data if person_data end def snippet_js_tag(env) script_tag(js_snippet, env) end def js_snippet SNIPPET end def script_tag(content, env) if (nonce = rails5_nonce(env)) script_tag_content = "\n" elsif secure_headers_nonce? nonce = ::SecureHeaders.content_security_policy_script_nonce(::Rack::Request.new(env)) script_tag_content = "\n" else script_tag_content = "\n" end html_safe_if_needed(script_tag_content) end def html_safe_if_needed(string) string = string.html_safe if string.respond_to?(:html_safe) string end # Rails 5.2 Secure Content Policy def rails5_nonce(env) # The nonce is the preferred method, however 'unsafe-inline' is also possible. # The app gets to decide, so we handle both. If the script_src key is missing, # Rails will not add the nonce to the headers, so we should not add it either. # If the 'unsafe-inline' value is present, the app should not add a nonce and # we should ignore it if they do. req = ::ActionDispatch::Request.new env req.respond_to?(:content_security_policy) && req.content_security_policy && req.content_security_policy.directives['script-src'] && !req.content_security_policy.directives['script-src'].include?("'unsafe-inline'") && req.content_security_policy_nonce end # Secure Headers gem def secure_headers_nonce? secure_headers.append_nonce? end def secure_headers return SecureHeadersFalse.new unless defined?(::SecureHeaders::Configuration) config = ::SecureHeaders::Configuration secure_headers_cls = nil secure_headers_cls = if !::SecureHeaders.respond_to?(:content_security_policy_script_nonce) SecureHeadersFalse elsif config.respond_to?(:get) SecureHeaders3To5 elsif config.dup.respond_to?(:csp) SecureHeaders6 else SecureHeadersFalse end secure_headers_cls.new end class SecureHeadersResolver def append_nonce? csp_needs_nonce?(find_csp) end private def find_csp raise NotImplementedError end def csp_needs_nonce?(csp) !opt_out?(csp) && !unsafe_inline?(csp) end def opt_out?(_csp) raise NotImplementedError end def unsafe_inline?(csp) csp[:script_src].to_a.include?("'unsafe-inline'") end end class SecureHeadersFalse < SecureHeadersResolver def append_nonce? false end end class SecureHeaders3To5 < SecureHeadersResolver private def find_csp ::SecureHeaders::Configuration.get.csp end def opt_out?(csp) if csp.respond_to?(:opt_out?) && csp.opt_out? csp.opt_out? # secure_headers csp 3.0.x-3.4.x doesn't respond to 'opt_out?' elsif defined?(::SecureHeaders::OPT_OUT) && ::SecureHeaders::OPT_OUT.is_a?(Symbol) csp == ::SecureHeaders::OPT_OUT end end end class SecureHeaders6 < SecureHeadersResolver private def find_csp ::SecureHeaders::Configuration.dup.csp end def opt_out?(csp) csp.opt_out? end end end end end