Skip to content

Commit

Permalink
Merge pull request #240 from twitter/script-hashes-for-3.x
Browse files Browse the repository at this point in the history
re-add support for script hashes
  • Loading branch information
oreoshake committed Apr 18, 2016
2 parents 9756065 + 87b525e commit c618d70
Show file tree
Hide file tree
Showing 9 changed files with 364 additions and 6 deletions.
72 changes: 69 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -228,9 +228,14 @@ body {
</style>
```

script/style-nonce can be used to whitelist inline content. To do this, call the `content_security_policy_script_nonce` or `content_security_policy_style_nonce` then set the nonce attributes on the various tags.
```

Content-Security-Policy: ...
script-src 'nonce-/jRAxuLJsDXAxqhNBB7gg7h55KETtDQBXe4ZL+xIXwI=' ...;
style-src 'nonce-/jRAxuLJsDXAxqhNBB7gg7h55KETtDQBXe4ZL+xIXwI=' ...;
```
Setting a nonce will also set 'unsafe-inline' for browsers that don't support nonces for backwards compatibility. 'unsafe-inline' is ignored if a nonce is present in a directive in compliant browsers.
`script`/`style-nonce` can be used to whitelist inline content. To do this, call the `content_security_policy_script_nonce` or `content_security_policy_style_nonce` then set the nonce attributes on the various tags.
```erb
<script nonce="<%= content_security_policy_script_nonce %>">
Expand All @@ -248,7 +253,68 @@ Setting a nonce will also set 'unsafe-inline' for browsers that don't support no

#### Hash

The hash feature has been removed, for now.
`script`/`style-src` hashes can be used to whitelist inline content that is static. This has the benefit of allowing inline content without opening up the possibility of dynamic javascript like you would with a `nonce`.

You can add hash sources directly to your policy :

```ruby
::SecureHeaders::Configuration.default do |config|
config.csp = {
default_src: %w('self')

# this is a made up value but browsers will show the expected hash in the console.
script_src: %w(sha256-123456)
}
end
```

You can also use the automated inline script detection/collection/computation of hash source values in your app.

```bash
rake secure_headers:generate_hashes
```

This will generate a file (`config/config/secure_headers_generated_hashes.yml` by default, you can override by setting `ENV["secure_headers_generated_hashes_file"]`) containing a mapping of file names with the array of hash values found on that page. When ActionView renders a given file, we check if there are any known hashes for that given file. If so, they are added as values to the header.

```yaml
---
scripts:
app/views/asdfs/index.html.erb:
- "'sha256-yktKiAsZWmc8WpOyhnmhQoDf9G2dAZvuBBC+V0LGQhg='"
styles:
app/views/asdfs/index.html.erb:
- "'sha256-SLp6LO3rrKDJwsG9uJUxZapb4Wp2Zhj6Bu3l+d9rnAY='"
- "'sha256-HSGHqlRoKmHAGTAJ2Rq0piXX4CnEbOl1ArNd6ejp2TE='"
```

##### Helpers

**This will not compute dynamic hashes** by design. The output of both helpers will be a plain `script`/`style` tag without modification and the known hashes for a given file will be added to `script-src`/`style-src` when `hashed_javascript_tag` and `hashed_style_tag` are used. You can use `raise_error_on_unrecognized_hash = true` to be extra paranoid that you have precomputed hash values for all of your inline content. By default, this will raise an error in non-production environments.

```erb
<%= hashed_style_tag do %>
body {
background-color: black;
}
<% end %>
<%= hashed_style_tag do %>
body {
font-size: 30px;
font-color: green;
}
<% end %>
<%= hashed_javascript_tag do %>
console.log(1)
<% end %>
```

```
Content-Security-Policy: ...
script-src 'sha256-yktKiAsZWmc8WpOyhnmhQoDf9G2dAZvuBBC+V0LGQhg=' ... ;
style-src 'sha256-SLp6LO3rrKDJwsG9uJUxZapb4Wp2Zhj6Bu3l+d9rnAY=' 'sha256-HSGHqlRoKmHAGTAJ2Rq0piXX4CnEbOl1ArNd6ejp2TE=' ...;
```
### Public Key Pins
Expand Down
1 change: 1 addition & 0 deletions lib/secure_headers.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
require "secure_headers/configuration"
require "secure_headers/hash_helper"
require "secure_headers/headers/cookie"
require "secure_headers/headers/public_key_pins"
require "secure_headers/headers/content_security_policy"
Expand Down
9 changes: 9 additions & 0 deletions lib/secure_headers/configuration.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
require 'yaml'

module SecureHeaders
class Configuration
DEFAULT_CONFIG = :default
Expand Down Expand Up @@ -106,6 +108,13 @@ def deep_copy_if_hash(value)

attr_reader :cached_headers, :csp, :dynamic_csp, :cookies

HASH_CONFIG_FILE = ENV["secure_headers_generated_hashes_file"] || "config/secure_headers_generated_hashes.yml"
if File.exists?(HASH_CONFIG_FILE)
config = YAML.safe_load(File.open(HASH_CONFIG_FILE))
@script_hashes = config["scripts"]
@style_hashes = config["styles"]
end

def initialize(&block)
self.hpkp = OPT_OUT
self.csp = self.class.send(:deep_copy, CSP::DEFAULT_CONFIG)
Expand Down
10 changes: 10 additions & 0 deletions lib/secure_headers/hash_helper.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
require 'base64'

module SecureHeaders
module HashHelper
def hash_source(inline_script, digest = :SHA256)
base64_hashed_content = Base64.encode64(Digest.const_get(digest).digest(inline_script)).chomp
"'#{digest.to_s.downcase}-#{base64_hashed_content}'"
end
end
end
4 changes: 4 additions & 0 deletions lib/secure_headers/railtie.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ class Railtie < Rails::Railtie
Rails.application.config.middleware.insert_before 0, SecureHeaders::Middleware
end

rake_tasks do
load File.expand_path(File.join('..', '..', 'lib', 'tasks', 'tasks.rake'), File.dirname(__FILE__))
end

initializer "secure_headers.action_controller" do
ActiveSupport.on_load(:action_controller) do
include SecureHeaders
Expand Down
64 changes: 64 additions & 0 deletions lib/secure_headers/view_helper.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
module SecureHeaders
module ViewHelpers
include SecureHeaders::HashHelper
SECURE_HEADERS_RAKE_TASK = "rake secure_headers:generate_hashes"

class UnexpectedHashedScriptException < StandardError; end

# Public: create a style tag using the content security policy nonce.
# Instructs secure_headers to append a nonce to style/script-src directives.
#
Expand Down Expand Up @@ -29,8 +34,67 @@ def content_security_policy_nonce(type)
end
end

##
# Checks to see if the hashed code is expected and adds the hash source
# value to the current CSP.
#
# By default, in development/test/etc. an exception will be raised.
def hashed_javascript_tag(raise_error_on_unrecognized_hash = nil, &block)
hashed_tag(
:script,
:script_src,
Configuration.instance_variable_get(:@script_hashes),
raise_error_on_unrecognized_hash,
block
)
end

def hashed_style_tag(raise_error_on_unrecognized_hash = nil, &block)
hashed_tag(
:style,
:style_src,
Configuration.instance_variable_get(:@style_hashes),
raise_error_on_unrecognized_hash,
block
)
end

private

def hashed_tag(type, directive, hashes, raise_error_on_unrecognized_hash, block)
if raise_error_on_unrecognized_hash.nil?
raise_error_on_unrecognized_hash = ENV["RAILS_ENV"] != "production"
end

content = capture(&block)
file_path = File.join('app', 'views', self.instance_variable_get(:@virtual_path) + '.html.erb')

if raise_error_on_unrecognized_hash
hash_value = hash_source(content)
message = unexpected_hash_error_message(file_path, content, hash_value)

if hashes.nil? || hashes[file_path].nil? || !hashes[file_path].include?(hash_value)
raise UnexpectedHashedScriptException.new(message)
end
end

SecureHeaders.append_content_security_policy_directives(request, directive => hashes[file_path])

content_tag type, content
end

def unexpected_hash_error_message(file_path, content, hash_value)
<<-EOF
\n\n*** WARNING: Unrecognized hash in #{file_path}!!! Value: #{hash_value} ***
#{content}
*** Run #{SECURE_HEADERS_RAKE_TASK} or add the following to config/script_hashes.yml:***
#{file_path}:
- #{hash_value}\n\n
NOTE: dynamic javascript is not supported using script hash integration
on purpose. It defeats the point of using it in the first place.
EOF
end

def nonced_tag(type, content_or_options, block)
options = {}
content = if block
Expand Down
81 changes: 81 additions & 0 deletions lib/tasks/tasks.rake
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
INLINE_SCRIPT_REGEX = /(<script(\s*(?!src)([\w\-])+=([\"\'])[^\"\']+\4)*\s*>)(.*?)<\/script>/mx
INLINE_STYLE_REGEX = /(<style[^>]*>)(.*?)<\/style>/mx
INLINE_HASH_SCRIPT_HELPER_REGEX = /<%=\s?hashed_javascript_tag(.*?)\s+do\s?%>(.*?)<%\s*end\s*%>/mx
INLINE_HASH_STYLE_HELPER_REGEX = /<%=\s?hashed_style_tag(.*?)\s+do\s?%>(.*?)<%\s*end\s*%>/mx

namespace :secure_headers do
include SecureHeaders::HashHelper

def is_erb?(filename)
filename =~ /\.erb\Z/
end

def is_mustache?(filename)
filename =~ /\.mustache\Z/
end

def dynamic_content?(filename, inline_script)
(is_mustache?(filename) && inline_script =~ /\{\{.*\}\}/) ||
(is_erb?(filename) && inline_script =~ /<%.*%>/)
end

def find_inline_content(filename, regex, hashes)
file = File.read(filename)
file.scan(regex) do # TODO don't use gsub
inline_script = Regexp.last_match.captures.last
if dynamic_content?(filename, inline_script)
puts "Looks like there's some dynamic content inside of a tag :-/"
puts "That pretty much means the hash value will never match."
puts "Code: " + inline_script
puts "=" * 20
end

hashes << hash_source(inline_script)
end
end

def generate_inline_script_hashes(filename)
hashes = []

[INLINE_SCRIPT_REGEX, INLINE_HASH_SCRIPT_HELPER_REGEX].each do |regex|
find_inline_content(filename, regex, hashes)
end

hashes
end

def generate_inline_style_hashes(filename)
hashes = []

[INLINE_STYLE_REGEX, INLINE_HASH_STYLE_HELPER_REGEX].each do |regex|
find_inline_content(filename, regex, hashes)
end

hashes
end

task :generate_hashes do |t, args|
script_hashes = {
"scripts" => {},
"styles" => {}
}

Dir.glob("app/{views,templates}/**/*.{erb,mustache}") do |filename|
hashes = generate_inline_script_hashes(filename)
if hashes.any?
script_hashes["scripts"][filename] = hashes
end

hashes = generate_inline_style_hashes(filename)
if hashes.any?
script_hashes["styles"][filename] = hashes
end
end

File.open(SecureHeaders::Configuration::HASH_CONFIG_FILE, 'w') do |file|
file.write(script_hashes.to_yaml)
end

puts "Script hashes from " + script_hashes.keys.size.to_s + " files added to #{SecureHeaders::Configuration::HASH_CONFIG_FILE}"
end
end
4 changes: 1 addition & 3 deletions spec/lib/secure_headers/middleware_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,7 @@ module SecureHeaders

before(:each) do
reset_config
Configuration.default do |config|
# use all default provided by the library
end
Configuration.default
end

it "sets the headers" do
Expand Down
Loading

0 comments on commit c618d70

Please sign in to comment.