Skip to content

Commit 14c5ae4

Browse files
committed
Merge pull request #163 from intercom/marco/csp_sha
Add manual CSP nonce and sha-256 support
2 parents 2cdc2ab + 5e6b944 commit 14c5ae4

File tree

7 files changed

+122
-26
lines changed

7 files changed

+122
-26
lines changed

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,5 @@ pkg/*
99
spike.rb
1010
html
1111
.idea
12-
.ruby-version
12+
.ruby-version
13+
vendor/*

lib/intercom-rails/auto_include_filter.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ def initialize(kontroller)
2727
end
2828

2929
def include_javascript!
30-
response.body = response.body.gsub(CLOSING_BODY_TAG, intercom_script_tag.output + '\\0')
30+
response.body = response.body.gsub(CLOSING_BODY_TAG, intercom_script_tag.to_s + '\\0')
3131
end
3232

3333
def include_javascript?

lib/intercom-rails/script_tag.rb

Lines changed: 41 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,8 @@ class ScriptTag
99

1010
include ::ActionView::Helpers::JavaScriptHelper
1111

12-
def self.generate(*args)
13-
new(*args).output
14-
end
15-
1612
attr_reader :user_details, :company_details, :show_everywhere
17-
attr_accessor :secret, :widget_options, :controller
13+
attr_accessor :secret, :widget_options, :controller, :nonce
1814

1915
def initialize(options = {})
2016
self.secret = options[:secret] || Config.api_secret
@@ -27,13 +23,34 @@ def initialize(options = {})
2723
elsif options[:user_details]
2824
options[:user_details].delete(:company) if options[:user_details]
2925
end
26+
self.nonce = options[:nonce]
3027
end
3128

3229
def valid?
3330
valid = user_details[:app_id].present?
3431
unless @show_everywhere
3532
valid = valid && (user_details[:user_id] || user_details[:email]).present?
3633
end
34+
if nonce
35+
valid = valid && valid_nonce?
36+
end
37+
valid
38+
end
39+
40+
def valid_nonce?
41+
valid = false
42+
if nonce
43+
# Base64 regexp:
44+
# - blocks of 4 [A-Za-z0-9+/]
45+
# followed either by:
46+
# - blocks of 2 [A-Za-z0-9+/] + '=='
47+
# - blocks of 3 [A-Za-z0-9+/] + '='
48+
base64_regexp = Regexp.new('^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$')
49+
m = base64_regexp.match(nonce)
50+
if nonce == m.to_s
51+
valid = true
52+
end
53+
end
3754
valid
3855
end
3956

@@ -44,17 +61,28 @@ def intercom_settings
4461
hsh
4562
end
4663

47-
def output
64+
def to_s
65+
js_options = 'id="IntercomSettingsScriptTag"'
66+
if nonce && valid_nonce?
67+
js_options = js_options + " nonce=\"#{nonce}\""
68+
end
69+
str = "<script #{js_options}>#{intercom_javascript}</script>\n"
70+
str.respond_to?(:html_safe) ? str.html_safe : str
71+
end
72+
73+
def csp_sha256
74+
base64_sha256 = Base64.encode64(Digest::SHA256.digest(intercom_javascript))
75+
csp_hash = "sha256-#{base64_sha256}".delete("\n")
76+
csp_hash
77+
end
78+
79+
private
80+
def intercom_javascript
4881
intercom_settings_json = ActiveSupport::JSON.encode(intercom_settings).gsub('<', '\u003C')
4982

50-
str = <<-INTERCOM_SCRIPT
51-
<script id="IntercomSettingsScriptTag">
52-
window.intercomSettings = #{intercom_settings_json};
53-
</script>
54-
<script>(function(){var w=window;var ic=w.Intercom;if(typeof ic==="function"){ic('reattach_activator');ic('update',intercomSettings);}else{var d=document;var i=function(){i.c(arguments)};i.q=[];i.c=function(args){i.q.push(args)};w.Intercom=i;function l(){var s=d.createElement('script');s.type='text/javascript';s.async=true;s.src='#{Config.library_url || "https://widget.intercom.io/widget/#{j app_id}"}';var x=d.getElementsByTagName('script')[0];x.parentNode.insertBefore(s,x);}if(w.attachEvent){w.attachEvent('onload',l);}else{w.addEventListener('load',l,false);}};})()</script>
55-
INTERCOM_SCRIPT
83+
str = "window.intercomSettings = #{intercom_settings_json};(function(){var w=window;var ic=w.Intercom;if(typeof ic===\"function\"){ic('reattach_activator');ic('update',intercomSettings);}else{var d=document;var i=function(){i.c(arguments)};i.q=[];i.c=function(args){i.q.push(args)};w.Intercom=i;function l(){var s=d.createElement('script');s.type='text/javascript';s.async=true;s.src='#{Config.library_url || "https://widget.intercom.io/widget/#{j app_id}"}';var x=d.getElementsByTagName('script')[0];x.parentNode.insertBefore(s,x);}if(w.attachEvent){w.attachEvent('onload',l);}else{w.addEventListener('load',l,false);}};})()"
5684

57-
str.respond_to?(:html_safe) ? str.html_safe : str
85+
str
5886
end
5987

6088
private

lib/intercom-rails/script_tag_helper.rb

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ module ScriptTagHelper
1313
# @option user_details [Hash] :custom_data custom attributes you'd like saved for this user on Intercom.
1414
# @option options [String] :widget a hash containing a css selector for an element which when clicked should show the Intercom widget
1515
# @option options [String] :secret Your app secret for secure mode
16+
# @option options [String] :nonce a nonce generated by your CSP framework to be included inside the javascript tag
1617
# @return [String] Intercom script tag
1718
# @example basic example
1819
# <%= intercom_script_tag({ :app_id => "your-app-id",
@@ -33,7 +34,7 @@ def intercom_script_tag(user_details = nil, options={})
3334
options[:find_current_user_details] = !options[:user_details]
3435
options[:find_current_company_details] = !(options[:user_details] && options[:user_details][:company])
3536
options[:controller] = controller if defined?(controller)
36-
ScriptTag.generate(options)
37+
ScriptTag.new(options)
3738
end
3839
end
3940
end

spec/auto_include_filter_spec.rb

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -94,37 +94,37 @@ def current_user
9494

9595
it 'finds user from current_user method' do
9696
get :with_current_user_method, :body => "<body>Hello world</body>"
97-
expect(response.body).to include("<script>")
97+
expect(response.body).to include('<script id="IntercomSettingsScriptTag">')
9898
expect(response.body).to include("ciaran@intercom.io")
9999
expect(response.body).to include("Ciaran Lee")
100100
end
101101

102102
it 'finds user using config.user.current proc' do
103103
IntercomRails.config.user.current = Proc.new { @admin }
104104
get :with_admin_instance_variable, :body => "<body>Hello world</body>"
105-
expect(response.body).to include("<script>")
105+
expect(response.body).to include('<script id="IntercomSettingsScriptTag">')
106106
expect(response.body).to include("eoghan@intercom.io")
107107
expect(response.body).to include("Eoghan McCabe")
108108
end
109109

110110
it 'excludes users if necessary' do
111111
IntercomRails.config.user.exclude_if = Proc.new {|user| user.email.start_with?('ciaran')}
112112
get :with_current_user_method, :body => "<body>Hello world</body>"
113-
expect(response.body).not_to include("<script>")
113+
expect(response.body).not_to include('<script id="IntercomSettingsScriptTag">')
114114
expect(response.body).not_to include("ciaran@intercom.io")
115115
expect(response.body).not_to include("Ciaran Lee")
116116
end
117117

118118
it 'uses default library_url' do
119119
get :with_current_user_method, :body => "<body>Hello world</body>"
120-
expect(response.body).to include("<script>")
120+
expect(response.body).to include('<script id="IntercomSettingsScriptTag">')
121121
expect(response.body).to include("s.src='https://widget.intercom.io/widget/abc123'")
122122
end
123123

124124
it 'allows library_url override' do
125125
IntercomRails.config.library_url = 'http://a.b.c.d/library.js'
126126
get :with_current_user_method, :body => "<body>Hello world</body>"
127-
expect(response.body).to include("<script>")
127+
expect(response.body).to include('<script id="IntercomSettingsScriptTag">')
128128
expect(response.body).to include("s.src='http://a.b.c.d/library.js")
129129
end
130130

spec/script_tag_helper_spec.rb

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,13 @@
55
include IntercomRails::ScriptTagHelper
66

77
it 'delegates to script tag ' do
8-
expect(IntercomRails::ScriptTag).to receive(:generate)
8+
expect(IntercomRails::ScriptTag).to receive(:new)
99
intercom_script_tag({})
1010
end
1111

1212
it 'does not use dummy data if app_id is set in development' do
1313
allow(Rails).to receive(:development?).and_return true
14-
output = intercom_script_tag({app_id: 'thisismyappid', email:'foo'})
14+
output = intercom_script_tag({app_id: 'thisismyappid', email:'foo'}).to_s
1515
expect(output).to include("/widget/thisismyappid")
1616
end
1717

@@ -24,4 +24,29 @@
2424
fake_action_view.intercom_script_tag({})
2525
expect(obj.instance_variable_get(IntercomRails::SCRIPT_TAG_HELPER_CALLED_INSTANCE_VARIABLE)).to eq(true)
2626
end
27+
28+
context 'content security policy support' do
29+
it 'returns a valid sha256 hash for the CSP header' do
30+
#
31+
# See also spec/script_tag_spec.rb
32+
#
33+
script_tag = intercom_script_tag({
34+
:app_id => 'csp_sha_test',
35+
:email => 'marco@intercom.io',
36+
:user_id => 'marco',
37+
})
38+
expect(script_tag.csp_sha256).to eq('sha256-qLRbekKD6dEDMyLKPNFYpokzwYCz+WeNPqJE603mT24=')
39+
end
40+
41+
it 'inserts a valid nonce if present' do
42+
script_tag = intercom_script_tag({
43+
:app_id => 'csp_sha_test',
44+
:email => 'marco@intercom.io',
45+
:user_id => 'marco',
46+
}, {
47+
:nonce => 'pJwtLVnwiMaPCxpb41KZguOcC5mGUYD+8RNGcJSlR94=',
48+
})
49+
expect(script_tag.to_s).to include('nonce="pJwtLVnwiMaPCxpb41KZguOcC5mGUYD+8RNGcJSlR94="')
50+
end
51+
end
2752
end

spec/script_tag_spec.rb

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
end
99

1010
it 'should output html_safe?' do
11-
expect(ScriptTag.generate({}).html_safe?).to be(true)
11+
expect(ScriptTag.new({}).to_s.html_safe?).to be(true)
1212
end
1313

1414
it 'should convert times to unix timestamps' do
@@ -44,7 +44,7 @@
4444
it 'should escape html attributes' do
4545
nasty_email = "</script><script>alert('sup?');</script>"
4646
script_tag = ScriptTag.new(:user_details => {:email => nasty_email})
47-
expect(script_tag.output).not_to include(nasty_email)
47+
expect(script_tag.to_s).not_to include(nasty_email)
4848
end
4949

5050
it 'should escape html attributes in app_id' do
@@ -53,7 +53,7 @@
5353
nasty_app_id = "</script><script>alert('sup?');</script>"
5454
IntercomRails.config.app_id = nasty_app_id
5555
script_tag = ScriptTag.new(:user_details => {:email => email})
56-
expect(script_tag.output).not_to include(nasty_app_id)
56+
expect(script_tag.to_s).not_to include(nasty_app_id)
5757
IntercomRails.config.app_id = before
5858
end
5959

@@ -142,4 +142,45 @@ def sha256_hmac(secret, input)
142142
expect(script_tag.valid?).to eq(false)
143143
end
144144
end
145+
146+
context 'content security policy support' do
147+
it 'returns a valid sha256 hash for the CSP header' do
148+
#
149+
# If default values change, re-generate the string below using this one
150+
# liner:
151+
# echo "sha256-$(echo -n "js code" | openssl dgst -sha256 -binary | openssl base64)"
152+
# or an online service like https://report-uri.io/home/hash/
153+
#
154+
# For instance:
155+
# echo "sha256-$(echo -n "alert('hello');" | openssl dgst -sha256 -binary | openssl base64)"
156+
# sha256-gj4FLpwFgWrJxA7NLcFCWSwEF/PMnmWidszB6OONAAo=
157+
#
158+
script_tag = ScriptTag.new(:user_details => {
159+
:app_id => 'csp_sha_test',
160+
:email => 'marco@intercom.io',
161+
:user_id => 'marco',
162+
})
163+
expect(script_tag.csp_sha256).to eq('sha256-qLRbekKD6dEDMyLKPNFYpokzwYCz+WeNPqJE603mT24=')
164+
end
165+
166+
it 'inserts a valid nonce if present' do
167+
script_tag = ScriptTag.new(:user_details => {
168+
:app_id => 'csp_sha_test',
169+
:email => 'marco@intercom.io',
170+
:user_id => 'marco',
171+
},
172+
:nonce => 'pJwtLVnwiMaPCxpb41KZguOcC5mGUYD+8RNGcJSlR94=')
173+
expect(script_tag.to_s).to include('nonce="pJwtLVnwiMaPCxpb41KZguOcC5mGUYD+8RNGcJSlR94="')
174+
end
175+
176+
it 'does not insert a nasty nonce if present' do
177+
script_tag = ScriptTag.new(:user_details => {
178+
:app_id => 'csp_sha_test',
179+
:email => 'marco@intercom.io',
180+
:user_id => 'marco',
181+
},
182+
:nonce => '>alert(1)</script><script>')
183+
expect(script_tag.to_s).not_to include('>alert(1)</script><script>')
184+
end
185+
end
145186
end

0 commit comments

Comments
 (0)