secure_headers 1.4.1 → 2.0.0.pre
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of secure_headers might be problematic. Click here for more details.
- checksums.yaml +7 -0
- data/.gitignore +4 -8
- data/Gemfile +2 -2
- data/Guardfile +8 -0
- data/README.md +102 -48
- data/Rakefile +0 -116
- data/fixtures/rails_3_2_12/app/views/layouts/application.html.erb +1 -1
- data/fixtures/rails_3_2_12/app/views/other_things/index.html.erb +2 -1
- data/fixtures/rails_3_2_12/config/initializers/secure_headers.rb +1 -1
- data/fixtures/rails_3_2_12/config/script_hashes.yml +5 -0
- data/fixtures/rails_3_2_12/config.ru +3 -0
- data/fixtures/rails_3_2_12/spec/controllers/other_things_controller_spec.rb +50 -18
- data/fixtures/rails_3_2_12/spec/controllers/things_controller_spec.rb +1 -1
- data/fixtures/rails_3_2_12_no_init/app/controllers/other_things_controller.rb +1 -2
- data/lib/secure_headers/hash_helper.rb +7 -0
- data/lib/secure_headers/headers/content_security_policy/script_hash_middleware.rb +22 -0
- data/lib/secure_headers/headers/content_security_policy.rb +141 -137
- data/lib/secure_headers/railtie.rb +0 -22
- data/lib/secure_headers/version.rb +1 -1
- data/lib/secure_headers/view_helper.rb +68 -0
- data/lib/secure_headers.rb +51 -17
- data/lib/tasks/tasks.rake +48 -0
- data/spec/lib/secure_headers/headers/content_security_policy/script_hash_middleware_spec.rb +47 -0
- data/spec/lib/secure_headers/headers/content_security_policy_spec.rb +83 -208
- data/spec/lib/secure_headers_spec.rb +16 -62
- data/spec/spec_helper.rb +25 -1
- metadata +22 -24
- data/HISTORY.md +0 -162
- data/app/controllers/content_security_policy_controller.rb +0 -76
- data/config/curl-ca-bundle.crt +0 -5420
- data/config/routes.rb +0 -3
- data/spec/controllers/content_security_policy_controller_spec.rb +0 -90
| @@ -1,93 +1,154 @@ | |
| 1 1 | 
             
            require 'uri'
         | 
| 2 2 | 
             
            require 'base64'
         | 
| 3 | 
            +
            require 'securerandom'
         | 
| 3 4 |  | 
| 4 5 | 
             
            module SecureHeaders
         | 
| 5 6 | 
             
              class ContentSecurityPolicyBuildError < StandardError; end
         | 
| 6 7 | 
             
              class ContentSecurityPolicy < Header
         | 
| 7 8 | 
             
                module Constants
         | 
| 8 | 
            -
                  DEFAULT_CSP_HEADER = "default-src https: data: 'unsafe-inline' 'unsafe-eval'; frame-src https | 
| 9 | 
            -
                   | 
| 10 | 
            -
                   | 
| 11 | 
            -
                  DIRECTIVES = [ | 
| 12 | 
            -
             | 
| 9 | 
            +
                  DEFAULT_CSP_HEADER = "default-src https: data: 'unsafe-inline' 'unsafe-eval'; frame-src https: about: javascript:; img-src data:"
         | 
| 10 | 
            +
                  HEADER_NAME = "Content-Security-Policy"
         | 
| 11 | 
            +
                  ENV_KEY = 'secure_headers.content_security_policy'
         | 
| 12 | 
            +
                  DIRECTIVES = [
         | 
| 13 | 
            +
                    :default_src,
         | 
| 14 | 
            +
                    :connect_src,
         | 
| 15 | 
            +
                    :font_src,
         | 
| 16 | 
            +
                    :frame_src,
         | 
| 17 | 
            +
                    :img_src,
         | 
| 18 | 
            +
                    :media_src,
         | 
| 19 | 
            +
                    :object_src,
         | 
| 20 | 
            +
                    :script_src,
         | 
| 21 | 
            +
                    :style_src
         | 
| 22 | 
            +
                  ]
         | 
| 23 | 
            +
             | 
| 24 | 
            +
                  NON_DEFAULT_SOURCES = [
         | 
| 25 | 
            +
                    :base_uri,
         | 
| 26 | 
            +
                    :child_src,
         | 
| 27 | 
            +
                    :form_action,
         | 
| 28 | 
            +
                    :frame_ancestors,
         | 
| 29 | 
            +
                    :plugin_types,
         | 
| 30 | 
            +
                    :referrer,
         | 
| 31 | 
            +
                    :reflected_xss
         | 
| 32 | 
            +
                  ]
         | 
| 33 | 
            +
             | 
| 34 | 
            +
                  ALL_DIRECTIVES = DIRECTIVES + NON_DEFAULT_SOURCES
         | 
| 13 35 | 
             
                end
         | 
| 14 36 | 
             
                include Constants
         | 
| 15 37 |  | 
| 16 | 
            -
                 | 
| 17 | 
            -
                attr_reader :browser, :ssl_request, :report_uri, :request_uri, :experimental
         | 
| 18 | 
            -
             | 
| 19 | 
            -
                alias :disable_chrome_extension? :disable_chrome_extension
         | 
| 38 | 
            +
                attr_reader :disable_fill_missing, :ssl_request
         | 
| 20 39 | 
             
                alias :disable_fill_missing? :disable_fill_missing
         | 
| 21 40 | 
             
                alias :ssl_request? :ssl_request
         | 
| 22 41 |  | 
| 42 | 
            +
                class << self
         | 
| 43 | 
            +
                  def generate_nonce
         | 
| 44 | 
            +
                    SecureRandom.base64(32).chomp
         | 
| 45 | 
            +
                  end
         | 
| 46 | 
            +
             | 
| 47 | 
            +
                  def set_nonce(controller, nonce = generate_nonce)
         | 
| 48 | 
            +
                    controller.instance_variable_set(:@content_security_policy_nonce, nonce)
         | 
| 49 | 
            +
                  end
         | 
| 50 | 
            +
             | 
| 51 | 
            +
                  def add_to_env(request, controller, config)
         | 
| 52 | 
            +
                    set_nonce(controller)
         | 
| 53 | 
            +
                    options = options_from_request(request).merge(:controller => controller)
         | 
| 54 | 
            +
                    request.env[Constants::ENV_KEY] = {
         | 
| 55 | 
            +
                      :config => config,
         | 
| 56 | 
            +
                      :options => options,
         | 
| 57 | 
            +
                    }
         | 
| 58 | 
            +
                  end
         | 
| 59 | 
            +
             | 
| 60 | 
            +
                  def options_from_request(request)
         | 
| 61 | 
            +
                    {
         | 
| 62 | 
            +
                      :ssl => request.ssl?,
         | 
| 63 | 
            +
                      :ua => request.env['HTTP_USER_AGENT'],
         | 
| 64 | 
            +
                      :request_uri => request_uri_from_request(request),
         | 
| 65 | 
            +
                    }
         | 
| 66 | 
            +
                  end
         | 
| 67 | 
            +
             | 
| 68 | 
            +
                  def request_uri_from_request(request)
         | 
| 69 | 
            +
                    if request.respond_to?(:original_url)
         | 
| 70 | 
            +
                      # rails 3.1+
         | 
| 71 | 
            +
                      request.original_url
         | 
| 72 | 
            +
                    else
         | 
| 73 | 
            +
                      # rails 2/3.0
         | 
| 74 | 
            +
                      request.url
         | 
| 75 | 
            +
                    end
         | 
| 76 | 
            +
                  end
         | 
| 77 | 
            +
             | 
| 78 | 
            +
                  def symbol_to_hyphen_case sym
         | 
| 79 | 
            +
                    sym.to_s.gsub('_', '-')
         | 
| 80 | 
            +
                  end
         | 
| 81 | 
            +
                end
         | 
| 82 | 
            +
             | 
| 23 83 | 
             
                # +options+ param contains
         | 
| 24 | 
            -
                # : | 
| 84 | 
            +
                # :controller used for setting instance variables for nonces/hashes
         | 
| 25 85 | 
             
                # :ssl_request used to determine if http_additions should be used
         | 
| 26 | 
            -
                # :request_uri used to determine if firefox should send the report directly
         | 
| 27 | 
            -
                # or use the forwarding endpoint
         | 
| 28 86 | 
             
                # :ua the user agent (or just use Firefox/Chrome/MSIE/etc)
         | 
| 29 87 | 
             
                #
         | 
| 30 88 | 
             
                # :report used to determine what :ssl_request, :ua, and :request_uri are set to
         | 
| 31 89 | 
             
                def initialize(config=nil, options={})
         | 
| 32 | 
            -
                   | 
| 33 | 
            -
                  if @experimental
         | 
| 34 | 
            -
                    warn "[DEPRECATION] 'experimental' config is removed in 2.0"
         | 
| 35 | 
            -
                  end
         | 
| 36 | 
            -
                  @controller = options.delete(:controller)
         | 
| 90 | 
            +
                  return unless config
         | 
| 37 91 |  | 
| 38 92 | 
             
                  if options[:request]
         | 
| 39 | 
            -
                     | 
| 40 | 
            -
                  else
         | 
| 41 | 
            -
                    @ua = options[:ua]
         | 
| 42 | 
            -
                    # fails open, assumes http. Bad idea? Will always include http additions.
         | 
| 43 | 
            -
                    # could also fail if not supplied.
         | 
| 44 | 
            -
                    @ssl_request = !!options.delete(:ssl)
         | 
| 45 | 
            -
                    # a nil value here means we always assume we are not on the same host,
         | 
| 46 | 
            -
                    # which causes all FF csp reports to go through the forwarder
         | 
| 47 | 
            -
                    @request_uri = options.delete(:request_uri)
         | 
| 93 | 
            +
                    options = options.merge(self.class.options_from_request(options[:request]))
         | 
| 48 94 | 
             
                  end
         | 
| 49 95 |  | 
| 50 | 
            -
                   | 
| 51 | 
            -
             | 
| 96 | 
            +
                  @controller = options[:controller]
         | 
| 97 | 
            +
                  @ua = options[:ua]
         | 
| 98 | 
            +
                  @ssl_request = !!options.delete(:ssl)
         | 
| 99 | 
            +
                  @request_uri = options.delete(:request_uri)
         | 
| 52 100 |  | 
| 53 | 
            -
             | 
| 54 | 
            -
                  @ | 
| 55 | 
            -
             | 
| 101 | 
            +
                  # Config values can be string, array, or lamdba values
         | 
| 102 | 
            +
                  @config = config.inject({}) do |hash, (key, value)|
         | 
| 103 | 
            +
                    config_val = value.respond_to?(:call) ? value.call : value
         | 
| 56 104 |  | 
| 57 | 
            -
             | 
| 58 | 
            -
             | 
| 105 | 
            +
                    if ALL_DIRECTIVES.include?(key) # directives need to be normalized to arrays of strings
         | 
| 106 | 
            +
                      config_val = config_val.split if config_val.is_a? String
         | 
| 107 | 
            +
                      if config_val.is_a?(Array)
         | 
| 108 | 
            +
                        config_val = config_val.map do |val|
         | 
| 109 | 
            +
                          translate_dir_value(val)
         | 
| 110 | 
            +
                        end.flatten.uniq
         | 
| 111 | 
            +
                      end
         | 
| 112 | 
            +
                    end
         | 
| 59 113 |  | 
| 60 | 
            -
             | 
| 61 | 
            -
             | 
| 62 | 
            -
                    @config[:http_additions] = experimental_config[:http_additions]
         | 
| 63 | 
            -
                    @config.merge!(experimental_config)
         | 
| 114 | 
            +
                    hash[key] = config_val
         | 
| 115 | 
            +
                    hash
         | 
| 64 116 | 
             
                  end
         | 
| 65 117 |  | 
| 66 | 
            -
                  # these values don't support lambdas because this needs to be rewritten
         | 
| 67 118 | 
             
                  @http_additions = @config.delete(:http_additions)
         | 
| 68 119 | 
             
                  @app_name = @config.delete(:app_name)
         | 
| 120 | 
            +
                  @report_uri = @config.delete(:report_uri)
         | 
| 69 121 |  | 
| 70 | 
            -
                   | 
| 71 | 
            -
             | 
| 72 | 
            -
                  META.each do |meta|
         | 
| 73 | 
            -
                    self.send("#{meta}=", @config.delete(meta))
         | 
| 74 | 
            -
                  end
         | 
| 75 | 
            -
             | 
| 122 | 
            +
                  @disable_fill_missing = !!@config.delete(:disable_fill_missing)
         | 
| 76 123 | 
             
                  @enforce = !!@config.delete(:enforce)
         | 
| 77 | 
            -
                  @tag_report_uri =  | 
| 124 | 
            +
                  @tag_report_uri = !!@config.delete(:tag_report_uri)
         | 
| 125 | 
            +
                  @script_hashes = @config.delete(:script_hashes) || []
         | 
| 78 126 |  | 
| 79 | 
            -
                   | 
| 127 | 
            +
                  add_script_hashes if @script_hashes.any?
         | 
| 80 128 | 
             
                  fill_directives unless disable_fill_missing?
         | 
| 81 129 | 
             
                end
         | 
| 82 130 |  | 
| 131 | 
            +
                ##
         | 
| 132 | 
            +
                # Return or initialize the nonce value used for this header.
         | 
| 133 | 
            +
                # If a reference to a controller is passed in the config, this method
         | 
| 134 | 
            +
                # will check if a nonce has already been set and use it.
         | 
| 135 | 
            +
                def nonce
         | 
| 136 | 
            +
                  @nonce ||= @controller.instance_variable_get(:@content_security_policy_nonce) || self.class.generate_nonce
         | 
| 137 | 
            +
                end
         | 
| 138 | 
            +
             | 
| 139 | 
            +
                ##
         | 
| 140 | 
            +
                # Returns the name to use for the header. Either "Content-Security-Policy" or
         | 
| 141 | 
            +
                # "Content-Security-Policy-Report-Only"
         | 
| 83 142 | 
             
                def name
         | 
| 84 | 
            -
                  base =  | 
| 85 | 
            -
                  if !@enforce | 
| 143 | 
            +
                  base = HEADER_NAME
         | 
| 144 | 
            +
                  if !@enforce
         | 
| 86 145 | 
             
                    base += "-Report-Only"
         | 
| 87 146 | 
             
                  end
         | 
| 88 147 | 
             
                  base
         | 
| 89 148 | 
             
                end
         | 
| 90 149 |  | 
| 150 | 
            +
                ##
         | 
| 151 | 
            +
                # Return the value of the CSP header
         | 
| 91 152 | 
             
                def value
         | 
| 92 153 | 
             
                  return @config if @config.is_a?(String)
         | 
| 93 154 | 
             
                  if @config
         | 
| @@ -99,26 +160,28 @@ module SecureHeaders | |
| 99 160 |  | 
| 100 161 | 
             
                private
         | 
| 101 162 |  | 
| 163 | 
            +
                def add_script_hashes
         | 
| 164 | 
            +
                  @config[:script_src] << @script_hashes.map {|hash| "'#{hash}'"} << ["'unsafe-inline'"]
         | 
| 165 | 
            +
                end
         | 
| 166 | 
            +
             | 
| 102 167 | 
             
                def build_value
         | 
| 103 168 | 
             
                  raise "Expected to find default_src directive value" unless @config[:default_src]
         | 
| 104 169 | 
             
                  append_http_additions unless ssl_request?
         | 
| 105 170 | 
             
                  header_value = [
         | 
| 106 | 
            -
                    generic_directives | 
| 171 | 
            +
                    generic_directives,
         | 
| 172 | 
            +
                    non_default_directives,
         | 
| 107 173 | 
             
                    report_uri_directive
         | 
| 108 174 | 
             
                  ].join.strip
         | 
| 109 | 
            -
                rescue StandardError => e
         | 
| 110 | 
            -
                  raise ContentSecurityPolicyBuildError.new("Couldn't build CSP header :( #{e}")
         | 
| 111 175 | 
             
                end
         | 
| 112 176 |  | 
| 113 177 | 
             
                def fill_directives
         | 
| 114 | 
            -
                   | 
| 115 | 
            -
             | 
| 116 | 
            -
             | 
| 117 | 
            -
             | 
| 118 | 
            -
                       | 
| 178 | 
            +
                  if default = @config[:default_src]
         | 
| 179 | 
            +
                    DIRECTIVES.each do |directive|
         | 
| 180 | 
            +
                      unless @config[directive]
         | 
| 181 | 
            +
                        @config[directive] = default
         | 
| 182 | 
            +
                      end
         | 
| 119 183 | 
             
                    end
         | 
| 120 184 | 
             
                  end
         | 
| 121 | 
            -
                  @config
         | 
| 122 185 | 
             
                end
         | 
| 123 186 |  | 
| 124 187 | 
             
                def append_http_additions
         | 
| @@ -129,75 +192,20 @@ module SecureHeaders | |
| 129 192 | 
             
                  end
         | 
| 130 193 | 
             
                end
         | 
| 131 194 |  | 
| 132 | 
            -
                def normalize_csp_options
         | 
| 133 | 
            -
                  @config = @config.inject({}) do |hash, (key, value)|
         | 
| 134 | 
            -
                    # lambdas
         | 
| 135 | 
            -
                    config_val = value.respond_to?(:call) ? value.call : value
         | 
| 136 | 
            -
                    # space-delimeted strings
         | 
| 137 | 
            -
                    config_val = config_val.split if config_val.is_a? String
         | 
| 138 | 
            -
                    # array of strings
         | 
| 139 | 
            -
                    if config_val.respond_to?(:map) #skip booleans
         | 
| 140 | 
            -
                      config_val = config_val.map do |val|
         | 
| 141 | 
            -
                        translate_dir_value(val)
         | 
| 142 | 
            -
                      end.flatten.uniq
         | 
| 143 | 
            -
                    end
         | 
| 144 | 
            -
             | 
| 145 | 
            -
                    hash[key] = config_val
         | 
| 146 | 
            -
                    hash
         | 
| 147 | 
            -
                  end
         | 
| 148 | 
            -
             | 
| 149 | 
            -
                  @report_uri = @config.delete(:report_uri).join(" ") if @config[:report_uri]
         | 
| 150 | 
            -
                end
         | 
| 151 | 
            -
             | 
| 152 | 
            -
                # translates 'inline','self', 'none' and 'eval' to their respective impl-specific values.
         | 
| 153 195 | 
             
                def translate_dir_value val
         | 
| 154 196 | 
             
                  if %w{inline eval}.include?(val)
         | 
| 155 197 | 
             
                    val == 'inline' ? "'unsafe-inline'" : "'unsafe-eval'"
         | 
| 156 | 
            -
                    # self/none are special sources/src-dir-values and need to be quoted | 
| 198 | 
            +
                    # self/none are special sources/src-dir-values and need to be quoted
         | 
| 157 199 | 
             
                  elsif %{self none}.include?(val)
         | 
| 158 200 | 
             
                    "'#{val}'"
         | 
| 159 201 | 
             
                  elsif val == 'nonce'
         | 
| 160 | 
            -
                    @controller | 
| 202 | 
            +
                    self.class.set_nonce(@controller, nonce)
         | 
| 161 203 | 
             
                    ["'nonce-#{nonce}'", "'unsafe-inline'"]
         | 
| 162 204 | 
             
                  else
         | 
| 163 205 | 
             
                    val
         | 
| 164 206 | 
             
                  end
         | 
| 165 207 | 
             
                end
         | 
| 166 208 |  | 
| 167 | 
            -
                # if we have a forwarding endpoint setup and we are not on the same origin as our report_uri
         | 
| 168 | 
            -
                # or only a path was supplied (in which case we assume cross-host)
         | 
| 169 | 
            -
                # we need to forward the request for Firefox.
         | 
| 170 | 
            -
                def normalize_reporting_endpoint
         | 
| 171 | 
            -
                  if @ua && @ua =~ /Firefox/
         | 
| 172 | 
            -
                    if same_origin? || report_uri.nil? || URI.parse(report_uri).host.nil?
         | 
| 173 | 
            -
                      return
         | 
| 174 | 
            -
                    end
         | 
| 175 | 
            -
             | 
| 176 | 
            -
                    if forward_endpoint
         | 
| 177 | 
            -
                      warn "[DEPRECATION] forwarder is removed in 2.0"
         | 
| 178 | 
            -
                      @report_uri = FF_CSP_ENDPOINT
         | 
| 179 | 
            -
                    end
         | 
| 180 | 
            -
                  end
         | 
| 181 | 
            -
             | 
| 182 | 
            -
                  if @tag_report_uri
         | 
| 183 | 
            -
                    @report_uri = "#{@report_uri}?enforce=#{@enforce}"
         | 
| 184 | 
            -
                    @report_uri += "&app_name=#{@app_name}" if @app_name
         | 
| 185 | 
            -
                  end
         | 
| 186 | 
            -
                end
         | 
| 187 | 
            -
             | 
| 188 | 
            -
                def same_origin?
         | 
| 189 | 
            -
                  return unless report_uri && request_uri
         | 
| 190 | 
            -
             | 
| 191 | 
            -
                  begin
         | 
| 192 | 
            -
                    origin = URI.parse(request_uri)
         | 
| 193 | 
            -
                    uri = URI.parse(report_uri)
         | 
| 194 | 
            -
                  rescue URI::InvalidURIError
         | 
| 195 | 
            -
                    return false
         | 
| 196 | 
            -
                  end
         | 
| 197 | 
            -
             | 
| 198 | 
            -
                  uri.host == origin.host && origin.port == uri.port && origin.scheme == uri.scheme
         | 
| 199 | 
            -
                end
         | 
| 200 | 
            -
             | 
| 201 209 | 
             
                def report_uri_directive
         | 
| 202 210 | 
             
                  return '' if @report_uri.nil?
         | 
| 203 211 |  | 
| @@ -209,44 +217,40 @@ module SecureHeaders | |
| 209 217 | 
             
                                  end
         | 
| 210 218 | 
             
                  end
         | 
| 211 219 |  | 
| 220 | 
            +
                  if @tag_report_uri
         | 
| 221 | 
            +
                    @report_uri = "#{@report_uri}?enforce=#{@enforce}"
         | 
| 222 | 
            +
                    @report_uri += "&app_name=#{@app_name}" if @app_name
         | 
| 223 | 
            +
                  end
         | 
| 224 | 
            +
             | 
| 212 225 | 
             
                  "report-uri #{@report_uri};"
         | 
| 213 226 | 
             
                end
         | 
| 214 227 |  | 
| 215 | 
            -
                def generic_directives | 
| 228 | 
            +
                def generic_directives
         | 
| 216 229 | 
             
                  header_value = ''
         | 
| 217 | 
            -
                  if config[:img_src]
         | 
| 218 | 
            -
                    config[:img_src] = config[:img_src] + ['data:'] unless config[:img_src].include?('data:')
         | 
| 230 | 
            +
                  if @config[:img_src]
         | 
| 231 | 
            +
                    @config[:img_src] = @config[:img_src] + ['data:'] unless @config[:img_src].include?('data:')
         | 
| 219 232 | 
             
                  else
         | 
| 220 | 
            -
                    config[:img_src] = config[:default_src] + ['data:']
         | 
| 233 | 
            +
                    @config[:img_src] = @config[:default_src] + ['data:']
         | 
| 221 234 | 
             
                  end
         | 
| 222 235 |  | 
| 223 | 
            -
                   | 
| 224 | 
            -
             | 
| 225 | 
            -
                    header_value += build_directive(k)
         | 
| 236 | 
            +
                  DIRECTIVES.each do |directive_name|
         | 
| 237 | 
            +
                    header_value += build_directive(directive_name) if @config[directive_name]
         | 
| 226 238 | 
             
                  end
         | 
| 227 239 |  | 
| 228 240 | 
             
                  header_value
         | 
| 229 241 | 
             
                end
         | 
| 230 242 |  | 
| 231 | 
            -
                 | 
| 232 | 
            -
             | 
| 233 | 
            -
                   | 
| 234 | 
            -
             | 
| 243 | 
            +
                def non_default_directives
         | 
| 244 | 
            +
                  header_value = ''
         | 
| 245 | 
            +
                  NON_DEFAULT_SOURCES.each do |directive_name|
         | 
| 246 | 
            +
                    header_value += build_directive(directive_name) if @config[directive_name]
         | 
| 247 | 
            +
                  end
         | 
| 235 248 |  | 
| 236 | 
            -
             | 
| 237 | 
            -
                  sym.to_s.gsub('_', '-')
         | 
| 249 | 
            +
                  header_value
         | 
| 238 250 | 
             
                end
         | 
| 239 251 |  | 
| 240 | 
            -
                def  | 
| 241 | 
            -
                  @ | 
| 242 | 
            -
                  @ua = request.env['HTTP_USER_AGENT']
         | 
| 243 | 
            -
                  @request_uri = if request.respond_to?(:original_url)
         | 
| 244 | 
            -
                    # rails 3.1+
         | 
| 245 | 
            -
                    request.original_url
         | 
| 246 | 
            -
                  else
         | 
| 247 | 
            -
                    # rails 2/3.0
         | 
| 248 | 
            -
                    request.url
         | 
| 249 | 
            -
                  end
         | 
| 252 | 
            +
                def build_directive(key)
         | 
| 253 | 
            +
                  "#{self.class.symbol_to_hyphen_case(key)} #{@config[key].join(" ")}; "
         | 
| 250 254 | 
             
                end
         | 
| 251 255 | 
             
              end
         | 
| 252 256 | 
             
            end
         | 
| @@ -16,26 +16,4 @@ else | |
| 16 16 | 
             
                  include ::SecureHeaders
         | 
| 17 17 | 
             
                end
         | 
| 18 18 | 
             
              end
         | 
| 19 | 
            -
             | 
| 20 | 
            -
              module SecureHeaders
         | 
| 21 | 
            -
                module Routing
         | 
| 22 | 
            -
                  module MapperExtensions
         | 
| 23 | 
            -
                    def csp_endpoint
         | 
| 24 | 
            -
                      @set.add_route(ContentSecurityPolicy::FF_CSP_ENDPOINT, {:controller => "content_security_policy", :action => "scribe"})
         | 
| 25 | 
            -
                    end
         | 
| 26 | 
            -
                  end
         | 
| 27 | 
            -
                end
         | 
| 28 | 
            -
              end
         | 
| 29 | 
            -
             | 
| 30 | 
            -
              if defined?(ActiveSupport::Dependencies)
         | 
| 31 | 
            -
                if ActiveSupport::Dependencies.autoload_paths
         | 
| 32 | 
            -
                  ActiveSupport::Dependencies.autoload_paths << File.expand_path(File.join("..", "..", "..", "app", "controllers"), __FILE__)
         | 
| 33 | 
            -
                else
         | 
| 34 | 
            -
                  ActiveSupport::Dependencies.autoload_paths = [File.expand_path(File.join("..", "..", "..", "app", "controllers"), __FILE__)]
         | 
| 35 | 
            -
                end
         | 
| 36 | 
            -
              end
         | 
| 37 | 
            -
             | 
| 38 | 
            -
              if defined? ActionController::Routing
         | 
| 39 | 
            -
                ActionController::Routing::RouteSet::Mapper.send :include, ::SecureHeaders::Routing::MapperExtensions
         | 
| 40 | 
            -
              end
         | 
| 41 19 | 
             
            end
         | 
| @@ -0,0 +1,68 @@ | |
| 1 | 
            +
            module SecureHeaders
         | 
| 2 | 
            +
              class UnexpectedHashedScriptException < StandardError
         | 
| 3 | 
            +
             | 
| 4 | 
            +
              end
         | 
| 5 | 
            +
             | 
| 6 | 
            +
              module ViewHelpers
         | 
| 7 | 
            +
                include SecureHeaders::HashHelper
         | 
| 8 | 
            +
                SECURE_HEADERS_RAKE_TASK = "rake secure_headers:generate_hashes"
         | 
| 9 | 
            +
             | 
| 10 | 
            +
                def nonced_style_tag(content = nil, &block)
         | 
| 11 | 
            +
                  nonced_tag(content, :style, block)
         | 
| 12 | 
            +
                end
         | 
| 13 | 
            +
             | 
| 14 | 
            +
                def nonced_javascript_tag(content = nil, &block)
         | 
| 15 | 
            +
                  nonced_tag(content, :script, block)
         | 
| 16 | 
            +
                end
         | 
| 17 | 
            +
             | 
| 18 | 
            +
                def hashed_javascript_tag(raise_error_on_unrecognized_hash = false, &block)
         | 
| 19 | 
            +
                  content = capture(&block)
         | 
| 20 | 
            +
             | 
| 21 | 
            +
                  if ['development', 'test'].include?(ENV["RAILS_ENV"])
         | 
| 22 | 
            +
                    hash_value = hash_source(content)
         | 
| 23 | 
            +
                    file_path = File.join('app', 'views', self.instance_variable_get(:@virtual_path) + '.html.erb')
         | 
| 24 | 
            +
                    script_hashes = controller.instance_variable_get(:@script_hashes)[file_path]
         | 
| 25 | 
            +
                    unless script_hashes && script_hashes.include?(hash_value)
         | 
| 26 | 
            +
                      message = unexpected_hash_error_message(file_path, hash_value, content)
         | 
| 27 | 
            +
                      if raise_error_on_unrecognized_hash
         | 
| 28 | 
            +
                        raise UnexpectedHashedScriptException.new(message)
         | 
| 29 | 
            +
                      else
         | 
| 30 | 
            +
                        puts message
         | 
| 31 | 
            +
                        request.env[HASHES_ENV_KEY] = (request.env[HASHES_ENV_KEY] || []) << hash_value
         | 
| 32 | 
            +
                      end
         | 
| 33 | 
            +
                    end
         | 
| 34 | 
            +
                  end
         | 
| 35 | 
            +
             | 
| 36 | 
            +
                  content_tag :script, content
         | 
| 37 | 
            +
                end
         | 
| 38 | 
            +
             | 
| 39 | 
            +
                private
         | 
| 40 | 
            +
             | 
| 41 | 
            +
                def nonced_tag(content, type, block)
         | 
| 42 | 
            +
                  content = if block
         | 
| 43 | 
            +
                    capture(&block)
         | 
| 44 | 
            +
                  else
         | 
| 45 | 
            +
                    content.html_safe # :'(
         | 
| 46 | 
            +
                  end
         | 
| 47 | 
            +
             | 
| 48 | 
            +
                  content_tag type, content, :nonce => @content_security_policy_nonce
         | 
| 49 | 
            +
                end
         | 
| 50 | 
            +
             | 
| 51 | 
            +
                def unexpected_hash_error_message(file_path, hash_value, content)
         | 
| 52 | 
            +
                  <<-EOF
         | 
| 53 | 
            +
            \n\n*** WARNING: Unrecognized hash in #{file_path}!!! Value: #{hash_value} ***
         | 
| 54 | 
            +
            <script>#{content}</script>
         | 
| 55 | 
            +
            *** This is fine in dev/test, but will raise exceptions in production. ***
         | 
| 56 | 
            +
            *** Run #{SECURE_HEADERS_RAKE_TASK} or add the following to config/script_hashes.yml:***
         | 
| 57 | 
            +
            #{file_path}:
         | 
| 58 | 
            +
            - #{hash_value}\n\n
         | 
| 59 | 
            +
                  EOF
         | 
| 60 | 
            +
                end
         | 
| 61 | 
            +
              end
         | 
| 62 | 
            +
            end
         | 
| 63 | 
            +
             | 
| 64 | 
            +
            module ActionView #:nodoc:
         | 
| 65 | 
            +
              module Helpers #:nodoc:
         | 
| 66 | 
            +
                include SecureHeaders::ViewHelpers
         | 
| 67 | 
            +
              end
         | 
| 68 | 
            +
            end
         | 
    
        data/lib/secure_headers.rb
    CHANGED
    
    | @@ -1,11 +1,17 @@ | |
| 1 1 | 
             
            module SecureHeaders
         | 
| 2 | 
            +
              SCRIPT_HASH_CONFIG_FILE = 'config/script_hashes.yml'
         | 
| 3 | 
            +
              HASHES_ENV_KEY = 'secure_headers.script_hashes'
         | 
| 4 | 
            +
             | 
| 2 5 | 
             
              module Configuration
         | 
| 3 6 | 
             
                class << self
         | 
| 4 7 | 
             
                  attr_accessor :hsts, :x_frame_options, :x_content_type_options,
         | 
| 5 | 
            -
                    :x_xss_protection, :csp, :x_download_options
         | 
| 8 | 
            +
                    :x_xss_protection, :csp, :x_download_options, :script_hashes
         | 
| 6 9 |  | 
| 7 10 | 
             
                  def configure &block
         | 
| 8 11 | 
             
                    instance_eval &block
         | 
| 12 | 
            +
                    if File.exists?(SCRIPT_HASH_CONFIG_FILE)
         | 
| 13 | 
            +
                      ::SecureHeaders::Configuration.script_hashes = YAML.load(File.open(SCRIPT_HASH_CONFIG_FILE))
         | 
| 14 | 
            +
                    end
         | 
| 9 15 | 
             
                  end
         | 
| 10 16 | 
             
                end
         | 
| 11 17 | 
             
              end
         | 
| @@ -33,6 +39,7 @@ module SecureHeaders | |
| 33 39 |  | 
| 34 40 | 
             
                def ensure_security_headers options = {}
         | 
| 35 41 | 
             
                  self.secure_headers_options = options
         | 
| 42 | 
            +
                  before_filter :prep_script_hash
         | 
| 36 43 | 
             
                  before_filter :set_hsts_header
         | 
| 37 44 | 
             
                  before_filter :set_x_frame_options_header
         | 
| 38 45 | 
             
                  before_filter :set_csp_header
         | 
| @@ -49,7 +56,6 @@ module SecureHeaders | |
| 49 56 | 
             
              end
         | 
| 50 57 |  | 
| 51 58 | 
             
              module InstanceMethods
         | 
| 52 | 
            -
                # Re-added for backwards compat.
         | 
| 53 59 | 
             
                def set_security_headers(options = self.class.secure_headers_options)
         | 
| 54 60 | 
             
                  set_csp_header(request, options[:csp])
         | 
| 55 61 | 
             
                  set_hsts_header(options[:hsts])
         | 
| @@ -59,28 +65,54 @@ module SecureHeaders | |
| 59 65 | 
             
                  set_x_download_options_header(options[:x_download_options])
         | 
| 60 66 | 
             
                end
         | 
| 61 67 |  | 
| 62 | 
            -
                # backwards compatibility jank, to be removed in 1.0. Old API required a request
         | 
| 63 | 
            -
                # object when it didn't really need to.
         | 
| 64 68 | 
             
                # set_csp_header - uses the request accessor and SecureHeader::Configuration settings
         | 
| 65 69 | 
             
                # set_csp_header(+Rack::Request+) - uses the parameter and and SecureHeader::Configuration settings
         | 
| 66 70 | 
             
                # set_csp_header(+Hash+) - uses the request accessor and options from parameters
         | 
| 67 71 | 
             
                # set_csp_header(+Rack::Request+, +Hash+)
         | 
| 68 | 
            -
                def set_csp_header(req = nil,  | 
| 69 | 
            -
                   | 
| 70 | 
            -
             | 
| 71 | 
            -
                    options = req
         | 
| 72 | 
            +
                def set_csp_header(req = nil, config=nil)
         | 
| 73 | 
            +
                  if req.is_a?(Hash) || req.is_a?(FalseClass)
         | 
| 74 | 
            +
                    config = req
         | 
| 72 75 | 
             
                  end
         | 
| 73 76 |  | 
| 74 | 
            -
                   | 
| 75 | 
            -
                   | 
| 77 | 
            +
                  config = self.class.secure_headers_options[:csp] if config.nil?
         | 
| 78 | 
            +
                  config = self.class.options_for :csp, config
         | 
| 76 79 |  | 
| 77 | 
            -
                  return if  | 
| 80 | 
            +
                  return if config == false
         | 
| 81 | 
            +
             | 
| 82 | 
            +
                  if config && config[:script_hash_middleware]
         | 
| 83 | 
            +
                    ContentSecurityPolicy.add_to_env(request, self, config)
         | 
| 84 | 
            +
                  else
         | 
| 85 | 
            +
                    csp_header = ContentSecurityPolicy.new(config, :request => request, :controller => self)
         | 
| 86 | 
            +
                    set_header(csp_header)
         | 
| 87 | 
            +
                  end
         | 
| 88 | 
            +
                end
         | 
| 89 | 
            +
             | 
| 90 | 
            +
             | 
| 91 | 
            +
                def prep_script_hash
         | 
| 92 | 
            +
                  if ::SecureHeaders::Configuration.script_hashes
         | 
| 93 | 
            +
                    @script_hashes = ::SecureHeaders::Configuration.script_hashes.dup
         | 
| 94 | 
            +
                    ActiveSupport::Notifications.subscribe("render_partial.action_view") do |event_name, start_at, end_at, id, payload|
         | 
| 95 | 
            +
                      save_hash_for_later payload
         | 
| 96 | 
            +
                    end
         | 
| 78 97 |  | 
| 79 | 
            -
             | 
| 80 | 
            -
             | 
| 81 | 
            -
             | 
| 82 | 
            -
             | 
| 83 | 
            -
             | 
| 98 | 
            +
                    ActiveSupport::Notifications.subscribe("render_template.action_view") do |event_name, start_at, end_at, id, payload|
         | 
| 99 | 
            +
                      save_hash_for_later payload
         | 
| 100 | 
            +
                    end
         | 
| 101 | 
            +
                  end
         | 
| 102 | 
            +
                end
         | 
| 103 | 
            +
             | 
| 104 | 
            +
                def save_hash_for_later payload
         | 
| 105 | 
            +
                  matching_hashes = @script_hashes[payload[:identifier].gsub(Rails.root.to_s + "/", "")] || []
         | 
| 106 | 
            +
             | 
| 107 | 
            +
                  if payload[:layout]
         | 
| 108 | 
            +
                    # We're assuming an html.erb layout for now. Will need to handle mustache too, just not sure of the best way to do this
         | 
| 109 | 
            +
                    layout_hashes = @script_hashes[File.join("app", "views", payload[:layout]) + '.html.erb']
         | 
| 110 | 
            +
             | 
| 111 | 
            +
                    matching_hashes << layout_hashes if layout_hashes
         | 
| 112 | 
            +
                  end
         | 
| 113 | 
            +
             | 
| 114 | 
            +
                  if matching_hashes.any?
         | 
| 115 | 
            +
                    request.env[HASHES_ENV_KEY] = ((request.env[HASHES_ENV_KEY] || []) << matching_hashes).flatten
         | 
| 84 116 | 
             
                  end
         | 
| 85 117 | 
             
                end
         | 
| 86 118 |  | 
| @@ -126,7 +158,7 @@ module SecureHeaders | |
| 126 158 | 
             
              end
         | 
| 127 159 | 
             
            end
         | 
| 128 160 |  | 
| 129 | 
            -
             | 
| 161 | 
            +
             | 
| 130 162 | 
             
            require "secure_headers/version"
         | 
| 131 163 | 
             
            require "secure_headers/header"
         | 
| 132 164 | 
             
            require "secure_headers/headers/content_security_policy"
         | 
| @@ -136,3 +168,5 @@ require "secure_headers/headers/x_xss_protection" | |
| 136 168 | 
             
            require "secure_headers/headers/x_content_type_options"
         | 
| 137 169 | 
             
            require "secure_headers/headers/x_download_options"
         | 
| 138 170 | 
             
            require "secure_headers/railtie"
         | 
| 171 | 
            +
            require "secure_headers/hash_helper"
         | 
| 172 | 
            +
            require "secure_headers/view_helper"
         | 
| @@ -0,0 +1,48 @@ | |
| 1 | 
            +
            INLINE_SCRIPT_REGEX = /(<script(\s*(?!src)([\w\-])+=([\"\'])[^\"\']+\4)*\s*>)(.*?)<\/script>/mx
         | 
| 2 | 
            +
            INLINE_HASH_HELPER_REGEX = /<%=\s?hashed_javascript_tag(.*?)\s+do\s?%>(.*?)<%\s*end\s*%>/mx
         | 
| 3 | 
            +
            SCRIPT_HASH_CONFIG_FILE = 'config/script_hashes.yml'
         | 
| 4 | 
            +
             | 
| 5 | 
            +
            namespace :secure_headers do
         | 
| 6 | 
            +
              include SecureHeaders::HashHelper
         | 
| 7 | 
            +
             | 
| 8 | 
            +
              def is_erb?(filename)
         | 
| 9 | 
            +
                filename =~ /\.erb\Z/
         | 
| 10 | 
            +
              end
         | 
| 11 | 
            +
             | 
| 12 | 
            +
              def generate_inline_script_hashes(filename)
         | 
| 13 | 
            +
                file = File.read(filename)
         | 
| 14 | 
            +
                hashes = []
         | 
| 15 | 
            +
             | 
| 16 | 
            +
                [INLINE_SCRIPT_REGEX, INLINE_HASH_HELPER_REGEX].each do |regex|
         | 
| 17 | 
            +
                  file.gsub(regex) do # TODO don't use gsub
         | 
| 18 | 
            +
                    inline_script = Regexp.last_match.captures.last
         | 
| 19 | 
            +
                    if (filename =~ /\.mustache\Z/ && inline_script =~ /\{\{.*\}\}/) || (is_erb?(filename) && inline_script =~ /<%.*%>/)
         | 
| 20 | 
            +
                      puts "Looks like there's some dynamic content inside of a script tag :-/"
         | 
| 21 | 
            +
                      puts "That pretty much means the hash value will never match."
         | 
| 22 | 
            +
                      puts "Code: " + inline_script
         | 
| 23 | 
            +
                      puts "=" * 20
         | 
| 24 | 
            +
                    end
         | 
| 25 | 
            +
             | 
| 26 | 
            +
                    hashes << hash_source(inline_script)
         | 
| 27 | 
            +
                  end
         | 
| 28 | 
            +
                end
         | 
| 29 | 
            +
             | 
| 30 | 
            +
                hashes
         | 
| 31 | 
            +
              end
         | 
| 32 | 
            +
             | 
| 33 | 
            +
              task :generate_hashes do |t, args|
         | 
| 34 | 
            +
                script_hashes = {}
         | 
| 35 | 
            +
                Dir.glob("app/{views,templates}/**/*.{erb,mustache}") do |filename|
         | 
| 36 | 
            +
                  hashes = generate_inline_script_hashes(filename)
         | 
| 37 | 
            +
                  if hashes.any?
         | 
| 38 | 
            +
                    script_hashes[filename] = hashes
         | 
| 39 | 
            +
                  end
         | 
| 40 | 
            +
                end
         | 
| 41 | 
            +
             | 
| 42 | 
            +
                File.open(SCRIPT_HASH_CONFIG_FILE, 'w') do |file|
         | 
| 43 | 
            +
                  file.write(script_hashes.to_yaml)
         | 
| 44 | 
            +
                end
         | 
| 45 | 
            +
             | 
| 46 | 
            +
                puts "Script hashes from " + script_hashes.keys.size.to_s + " files added to #{SCRIPT_HASH_CONFIG_FILE}"
         | 
| 47 | 
            +
              end
         | 
| 48 | 
            +
            end
         |