Skip to content

Commit c618d70

Browse files
committed
Merge pull request #240 from twitter/script-hashes-for-3.x
re-add support for script hashes
2 parents 9756065 + 87b525e commit c618d70

File tree

9 files changed

+364
-6
lines changed

9 files changed

+364
-6
lines changed

README.md

Lines changed: 69 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -228,9 +228,14 @@ body {
228228
</style>
229229
```
230230

231-
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.
231+
```
232+
233+
Content-Security-Policy: ...
234+
script-src 'nonce-/jRAxuLJsDXAxqhNBB7gg7h55KETtDQBXe4ZL+xIXwI=' ...;
235+
style-src 'nonce-/jRAxuLJsDXAxqhNBB7gg7h55KETtDQBXe4ZL+xIXwI=' ...;
236+
```
232237

233-
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.
238+
`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.
234239

235240
```erb
236241
<script nonce="<%= content_security_policy_script_nonce %>">
@@ -248,7 +253,68 @@ Setting a nonce will also set 'unsafe-inline' for browsers that don't support no
248253

249254
#### Hash
250255

251-
The hash feature has been removed, for now.
256+
`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`.
257+
258+
You can add hash sources directly to your policy :
259+
260+
```ruby
261+
::SecureHeaders::Configuration.default do |config|
262+
config.csp = {
263+
default_src: %w('self')
264+
265+
# this is a made up value but browsers will show the expected hash in the console.
266+
script_src: %w(sha256-123456)
267+
}
268+
end
269+
```
270+
271+
You can also use the automated inline script detection/collection/computation of hash source values in your app.
272+
273+
```bash
274+
rake secure_headers:generate_hashes
275+
```
276+
277+
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.
278+
279+
```yaml
280+
---
281+
scripts:
282+
app/views/asdfs/index.html.erb:
283+
- "'sha256-yktKiAsZWmc8WpOyhnmhQoDf9G2dAZvuBBC+V0LGQhg='"
284+
styles:
285+
app/views/asdfs/index.html.erb:
286+
- "'sha256-SLp6LO3rrKDJwsG9uJUxZapb4Wp2Zhj6Bu3l+d9rnAY='"
287+
- "'sha256-HSGHqlRoKmHAGTAJ2Rq0piXX4CnEbOl1ArNd6ejp2TE='"
288+
```
289+
290+
##### Helpers
291+
292+
**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.
293+
294+
```erb
295+
<%= hashed_style_tag do %>
296+
body {
297+
background-color: black;
298+
}
299+
<% end %>
300+
301+
<%= hashed_style_tag do %>
302+
body {
303+
font-size: 30px;
304+
font-color: green;
305+
}
306+
<% end %>
307+
308+
<%= hashed_javascript_tag do %>
309+
console.log(1)
310+
<% end %>
311+
```
312+
313+
```
314+
Content-Security-Policy: ...
315+
script-src 'sha256-yktKiAsZWmc8WpOyhnmhQoDf9G2dAZvuBBC+V0LGQhg=' ... ;
316+
style-src 'sha256-SLp6LO3rrKDJwsG9uJUxZapb4Wp2Zhj6Bu3l+d9rnAY=' 'sha256-HSGHqlRoKmHAGTAJ2Rq0piXX4CnEbOl1ArNd6ejp2TE=' ...;
317+
```
252318
253319
### Public Key Pins
254320

lib/secure_headers.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
require "secure_headers/configuration"
2+
require "secure_headers/hash_helper"
23
require "secure_headers/headers/cookie"
34
require "secure_headers/headers/public_key_pins"
45
require "secure_headers/headers/content_security_policy"

lib/secure_headers/configuration.rb

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
require 'yaml'
2+
13
module SecureHeaders
24
class Configuration
35
DEFAULT_CONFIG = :default
@@ -106,6 +108,13 @@ def deep_copy_if_hash(value)
106108

107109
attr_reader :cached_headers, :csp, :dynamic_csp, :cookies
108110

111+
HASH_CONFIG_FILE = ENV["secure_headers_generated_hashes_file"] || "config/secure_headers_generated_hashes.yml"
112+
if File.exists?(HASH_CONFIG_FILE)
113+
config = YAML.safe_load(File.open(HASH_CONFIG_FILE))
114+
@script_hashes = config["scripts"]
115+
@style_hashes = config["styles"]
116+
end
117+
109118
def initialize(&block)
110119
self.hpkp = OPT_OUT
111120
self.csp = self.class.send(:deep_copy, CSP::DEFAULT_CONFIG)

lib/secure_headers/hash_helper.rb

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
require 'base64'
2+
3+
module SecureHeaders
4+
module HashHelper
5+
def hash_source(inline_script, digest = :SHA256)
6+
base64_hashed_content = Base64.encode64(Digest.const_get(digest).digest(inline_script)).chomp
7+
"'#{digest.to_s.downcase}-#{base64_hashed_content}'"
8+
end
9+
end
10+
end

lib/secure_headers/railtie.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,10 @@ class Railtie < Rails::Railtie
1313
Rails.application.config.middleware.insert_before 0, SecureHeaders::Middleware
1414
end
1515

16+
rake_tasks do
17+
load File.expand_path(File.join('..', '..', 'lib', 'tasks', 'tasks.rake'), File.dirname(__FILE__))
18+
end
19+
1620
initializer "secure_headers.action_controller" do
1721
ActiveSupport.on_load(:action_controller) do
1822
include SecureHeaders

lib/secure_headers/view_helper.rb

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
module SecureHeaders
22
module ViewHelpers
3+
include SecureHeaders::HashHelper
4+
SECURE_HEADERS_RAKE_TASK = "rake secure_headers:generate_hashes"
5+
6+
class UnexpectedHashedScriptException < StandardError; end
7+
38
# Public: create a style tag using the content security policy nonce.
49
# Instructs secure_headers to append a nonce to style/script-src directives.
510
#
@@ -29,8 +34,67 @@ def content_security_policy_nonce(type)
2934
end
3035
end
3136

37+
##
38+
# Checks to see if the hashed code is expected and adds the hash source
39+
# value to the current CSP.
40+
#
41+
# By default, in development/test/etc. an exception will be raised.
42+
def hashed_javascript_tag(raise_error_on_unrecognized_hash = nil, &block)
43+
hashed_tag(
44+
:script,
45+
:script_src,
46+
Configuration.instance_variable_get(:@script_hashes),
47+
raise_error_on_unrecognized_hash,
48+
block
49+
)
50+
end
51+
52+
def hashed_style_tag(raise_error_on_unrecognized_hash = nil, &block)
53+
hashed_tag(
54+
:style,
55+
:style_src,
56+
Configuration.instance_variable_get(:@style_hashes),
57+
raise_error_on_unrecognized_hash,
58+
block
59+
)
60+
end
61+
3262
private
3363

64+
def hashed_tag(type, directive, hashes, raise_error_on_unrecognized_hash, block)
65+
if raise_error_on_unrecognized_hash.nil?
66+
raise_error_on_unrecognized_hash = ENV["RAILS_ENV"] != "production"
67+
end
68+
69+
content = capture(&block)
70+
file_path = File.join('app', 'views', self.instance_variable_get(:@virtual_path) + '.html.erb')
71+
72+
if raise_error_on_unrecognized_hash
73+
hash_value = hash_source(content)
74+
message = unexpected_hash_error_message(file_path, content, hash_value)
75+
76+
if hashes.nil? || hashes[file_path].nil? || !hashes[file_path].include?(hash_value)
77+
raise UnexpectedHashedScriptException.new(message)
78+
end
79+
end
80+
81+
SecureHeaders.append_content_security_policy_directives(request, directive => hashes[file_path])
82+
83+
content_tag type, content
84+
end
85+
86+
def unexpected_hash_error_message(file_path, content, hash_value)
87+
<<-EOF
88+
\n\n*** WARNING: Unrecognized hash in #{file_path}!!! Value: #{hash_value} ***
89+
#{content}
90+
*** Run #{SECURE_HEADERS_RAKE_TASK} or add the following to config/script_hashes.yml:***
91+
#{file_path}:
92+
- #{hash_value}\n\n
93+
NOTE: dynamic javascript is not supported using script hash integration
94+
on purpose. It defeats the point of using it in the first place.
95+
EOF
96+
end
97+
3498
def nonced_tag(type, content_or_options, block)
3599
options = {}
36100
content = if block

lib/tasks/tasks.rake

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
INLINE_SCRIPT_REGEX = /(<script(\s*(?!src)([\w\-])+=([\"\'])[^\"\']+\4)*\s*>)(.*?)<\/script>/mx
2+
INLINE_STYLE_REGEX = /(<style[^>]*>)(.*?)<\/style>/mx
3+
INLINE_HASH_SCRIPT_HELPER_REGEX = /<%=\s?hashed_javascript_tag(.*?)\s+do\s?%>(.*?)<%\s*end\s*%>/mx
4+
INLINE_HASH_STYLE_HELPER_REGEX = /<%=\s?hashed_style_tag(.*?)\s+do\s?%>(.*?)<%\s*end\s*%>/mx
5+
6+
namespace :secure_headers do
7+
include SecureHeaders::HashHelper
8+
9+
def is_erb?(filename)
10+
filename =~ /\.erb\Z/
11+
end
12+
13+
def is_mustache?(filename)
14+
filename =~ /\.mustache\Z/
15+
end
16+
17+
def dynamic_content?(filename, inline_script)
18+
(is_mustache?(filename) && inline_script =~ /\{\{.*\}\}/) ||
19+
(is_erb?(filename) && inline_script =~ /<%.*%>/)
20+
end
21+
22+
def find_inline_content(filename, regex, hashes)
23+
file = File.read(filename)
24+
file.scan(regex) do # TODO don't use gsub
25+
inline_script = Regexp.last_match.captures.last
26+
if dynamic_content?(filename, inline_script)
27+
puts "Looks like there's some dynamic content inside of a tag :-/"
28+
puts "That pretty much means the hash value will never match."
29+
puts "Code: " + inline_script
30+
puts "=" * 20
31+
end
32+
33+
hashes << hash_source(inline_script)
34+
end
35+
end
36+
37+
def generate_inline_script_hashes(filename)
38+
hashes = []
39+
40+
[INLINE_SCRIPT_REGEX, INLINE_HASH_SCRIPT_HELPER_REGEX].each do |regex|
41+
find_inline_content(filename, regex, hashes)
42+
end
43+
44+
hashes
45+
end
46+
47+
def generate_inline_style_hashes(filename)
48+
hashes = []
49+
50+
[INLINE_STYLE_REGEX, INLINE_HASH_STYLE_HELPER_REGEX].each do |regex|
51+
find_inline_content(filename, regex, hashes)
52+
end
53+
54+
hashes
55+
end
56+
57+
task :generate_hashes do |t, args|
58+
script_hashes = {
59+
"scripts" => {},
60+
"styles" => {}
61+
}
62+
63+
Dir.glob("app/{views,templates}/**/*.{erb,mustache}") do |filename|
64+
hashes = generate_inline_script_hashes(filename)
65+
if hashes.any?
66+
script_hashes["scripts"][filename] = hashes
67+
end
68+
69+
hashes = generate_inline_style_hashes(filename)
70+
if hashes.any?
71+
script_hashes["styles"][filename] = hashes
72+
end
73+
end
74+
75+
File.open(SecureHeaders::Configuration::HASH_CONFIG_FILE, 'w') do |file|
76+
file.write(script_hashes.to_yaml)
77+
end
78+
79+
puts "Script hashes from " + script_hashes.keys.size.to_s + " files added to #{SecureHeaders::Configuration::HASH_CONFIG_FILE}"
80+
end
81+
end

spec/lib/secure_headers/middleware_spec.rb

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,7 @@ module SecureHeaders
1010

1111
before(:each) do
1212
reset_config
13-
Configuration.default do |config|
14-
# use all default provided by the library
15-
end
13+
Configuration.default
1614
end
1715

1816
it "sets the headers" do

0 commit comments

Comments
 (0)