Hugo

Internal GA template triggers CSP

The internal GA template includes inline code which is not good from a security perspective as it means that you need to include ‘unsafe-inline’ in your CSP header which will get you badly marked down on security checkers and for good reason since any malware that can insert code will be able to run.

script-src 'self' 'unsafe-inline'

I believe that it would be much better to put this code into an external script file. I realise that I can do this myself, and I am doing. But I thought this might be useful to others who might puzzle over why things are going strange with their site in relation to Google Analytics.

for the record, this is my google analytics template, which makes checks happy:

{{- $pc := .Site.Config.Privacy.GoogleAnalytics -}}
{{- if not $pc.Disable -}}
{{ with .Site.GoogleAnalytics }}
<script type="application/javascript">
function loadGoogleAnalyticsAsync(){
    var script = document.createElement('script');
    script.type = 'text/javascript';
    script.src = 'https://www.google-analytics.com/analytics.js';
    document.body.appendChild(script);
}
{{ template "__ga_js_set_doNotTrack" $ }}
if (!doNotTrack) {	
	document.addEventListener('readystatechange', event => {
	    if (event.target.readyState === "complete") {
	        setTimeout("loadGoogleAnalyticsAsync()", 3000);
	    }
	});
	window.ga=window.ga||function(){(ga.q=ga.q||[]).push(arguments)};ga.l=+new Date;
	{{- if $pc.UseSessionStorage }}
	if (window.sessionStorage) {
		var GA_SESSION_STORAGE_KEY = 'ga:clientId';
		ga('create', '{{ . }}', {
			'storage': 'none',
			'clientId': sessionStorage.getItem(GA_SESSION_STORAGE_KEY)
		});
		ga(function(tracker) {
			sessionStorage.setItem(GA_SESSION_STORAGE_KEY, tracker.get('clientId'));
		});
	}
	{{ else }}
	ga('create', '{{ . }}', 'auto');
	{{ end -}}
	{{ if $pc.AnonymizeIP }}ga('set', 'anonymizeIp', true);{{ end }}
	ga('send', 'pageview');
}
</script>
{{ end }}
{{- end -}}

it’s loading 3 seconds after page load. you can change that at setTimeout("loadGoogleAnalyticsAsync()", 3000);. It’s compatible with all the privacy settings of hugo.

But it will still trigger a CSP unless ‘unsafe-inline’ is included won’t it? I can’t see anything different there that would stop it. In fact, that might trigger other malware checks since you are behaving just like some malware by dynamically adding a script after the page has loaded.

This would all be fixed by simply moving the in-line script to a file and loading that instead since then you only need script-src: 'self'; in your CSP which is pretty normal.

I just need to work out how to create a script resource with dynamic content - been a while since I looked at Hugo since my site generally just works! :slight_smile: - apart from the CSP which has been annoying me for a while.

Yes. But it would be interesting to solve this thing. The partial should create a string. That string should be able to be transformed into an hash code. That hash could be added to your CSP and then everything would be fine.

You could still without that add everything in the script into your own js file.

I am creating my CSP with a template for netlify. So “technically” it should be possible.

OK, now I’m interested :smile:

The alternative to a hash would be a dynamically created nonce. This should be a string of at least 128 bits.

Either way, how do we link the dynamically created hash/nonce to the Netlify header? I’m specifying the header in netlify.toml in the root folder of my sites source code.

In your config.toml:

[outputs]
home = [ "HTML", "RSS", "Algolia", "REDIR", "HEADERS" ]

[outputFormats.HEADERS]
  mediatype = "text/netlify"
  baseName = "_headers"
  isPlainText = true
  notAlternative = true

The HEADERS part is important, ignore all else you don’t already have

Then in _default/index.header add your template for the headers file in netlify. Mine looks very specific, but should explain some points. The with partial footer/js.html is the place where the hash is created for my preloaded script. the same use cold be done for adding a CSP hash for the script.

/
  Accept-Encoding: gzip, deflate, br
  Link: </assets/fonts/panton/panton-regular-webfont.woff2>; rel=preload; as=font; crossorigin; type="font/woff2"
  Link: </assets/fonts/panton/panton-regularitalic-webfont.woff2>; rel=preload; as=font; crossorigin; type="font/woff2"
  Link: </assets/fonts/panton/panton-black-webfont.woff2>; rel=preload; as=font; crossorigin; type="font/woff2"
  Link: </assets/fonts/panton/panton-blackitalic-webfont.woff2>; rel=preload; as=font; crossorigin; type="font/woff2"
  Link: </assets/fonts/panton/panton-heavy-webfont.woff2>; rel=preload; as=font; crossorigin; type="font/woff2"
  {{ with partial "footer/js.html" . }}
  Link: <{{ .RelPermalink }}>; rel=preload; as=script; integrity='{{ .Data.Integrity }}'
  {{ end }}
  {{ with partialCached "head/css.html" . }}
  Link: <{{ .RelPermalink }}>; rel=preload; as=style; integrity='{{ .Data.Integrity }}'
  {{ end }}
  Feature-Policy: geolocation 'none'; midi 'none'; sync-xhr 'none'; microphone 'none'; camera 'none'; magnetometer 'none'; gyroscope 'none'; speaker 'none'; fullscreen 'none'; payment 'none'

/*
  X-Frame-Options: DENY
  X-XSS-Protection: 1; mode=block
  Referrer-Policy: no-referrer
  X-Content-Type-Options: nosniff
  Content-Security-Policy: default-src 'self' googleads.g.doubleclick.net; script-src 'self' 'unsafe-inline' 'unsafe-eval' pagead2.googlesyndication.com storage.googleapis.com googleads.g.doubleclick.net ajax.googleapis.com www.google-analytics.com www.google.com www.gstatic.com; style-src 'self' 'unsafe-inline'; img-src 'self' data: www.google-analytics.com; connect-src *.algolia.net *.algolianet.com; frame-src www.google.com www.youtube.com googleads.g.doubleclick.net; upgrade-insecure-requests; manifest-src 'self'; font-src 'self' fonts.googleapis.com; frame-ancestors 'self'; object-src 'self'

/*.html
  Accept-Encoding: gzip, deflate, br

/*.manifest
  Content-Type: application/manifest+json; charset=utf-8  
  Cache-Control: public, max-age=31536000, immutable
  Accept-Encoding: gzip, deflate, br

/script*.js
  Expires: Tue, 31-Dec-2020 23:59:59 GMT+0700
  Content-Type: text/javascript; charset=utf-8
  Cache-Control: public, max-age=31536000, immutable
  Accept-Encoding: gzip, deflate, br

/style*.css
  Expires: Tue, 31-Dec-2020 23:59:59 GMT+0700
  Cache-Control: public, max-age=31536000, immutable
  Accept-Encoding: gzip, deflate, br

/*.jpg
  Expires: Tue, 31-Dec-2020 23:59:59 GMT+0700
  Cache-Control: public, max-age=31536000, immutable
  Accept-Encoding: gzip, deflate, br

/*.png
  Expires: Tue, 31-Dec-2020 23:59:59 GMT+0700
  Cache-Control: public, max-age=31536000, immutable
  Accept-Encoding: gzip, deflate, br

/assets/*
  Expires: Tue, 31-Dec-2020 23:59:59 GMT+0700
  Cache-Control: public, max-age=31536000, immutable

/images/*
  Expires: Tue, 31-Dec-2020 23:59:59 GMT+0700
  Cache-Control: public, max-age=31536000, immutable

I think this topic is interesting for my website too, but it might take some days to implement it properly. If you can wait, then wait. Else: be an explorer and tell us how to do it :slight_smile:

2 Likes

just in case it’s not obvious: you can put your js code into a file and then create the hash from it, this is the footer/js.html file:

{{/*

JS creation for samui-samui.de

-
- SRI is applied via Fingerprint
- that's all for now. but it's enough, isn't it?

*/}}

{{ $js0 := resources.Get "js/libs/jquery.js" }}
{{ $js1 := resources.Get "js/libs/popper.js" }}
{{ $js2 := resources.Get "js/libs/bootstrap.js" }}
{{ $js32 := resources.Get "js/libs/de.js" }}
{{ $js31 := resources.Get "js/libs/moment.js" }}
{{ $js4 := resources.Get "js/libs/instantsearch.js" }}
{{ $js5 := resources.Get "js/youtube-embedder.js" }}
{{ $js6 := resources.Get "js/soundcloud.js" }}
{{ $js7 := resources.Get "js/progress-indicator.js" }}
{{ $js8 := resources.Get "js/search.js" }}
{{ $js9 := resources.Get "js/script.js" }}
{{ $js10 := resources.Get "js/cookieconsent.min.js" }}
{{ $js := slice $js0 $js31 $js4 $js5 $js6 $js7 $js8 $js9 $js10 | resources.Concat "script.js" | resources.Minify | resources.Fingerprint "sha384" }}

{{ partial "structured-data/organisation" . }}
{{ partial "structured-data/person" . }}
{{ partial "structured-data/sitesearch" . }}

{{ return $js }}
1 Like

I have limited time unfortunately and far too many things to try and get through however, I am looking at this. I will see if I can get this working for myself but also look forward to seeing your results.

I’m excited as this may well finish off the outstanding annoyances of my site which I moved from Wordpress to Hugo over a year ago now.

I also have 2 more sites to set up but I also have some open source software to update - this isn’t even my day-job :frowning:

Ah, I think I did this for some other script file - hard to remember - I will check that out.

I moved my Wordpress site a year ago to Hugo and got 100/100 on GTMetrix now. It’s quite easy. Most of the time “annoyances” are just personal perks.

As you can see my CSP allows all the insecure stuff… Let’s fix that…

1 Like

Here is my CSP so far:

upgrade-insecure-requests;
frame-ancestors 'none';
child-src 'self' https://disqus.com/ https://www.slideshare.net/;
report-uri https://totallyinfo.report-uri.com/r/d/csp/wizard;

default-src 'self';
style-src 'self' 'unsafe-inline' https://c.disquscdn.com https://www.google.com/cse/static/;
img-src 'self' data: https://*.cloudfront.net https://c.disquscdn.com https://referrer.disqus.com https://stackexchange.com/users/flair/1375993.png https://www.google-analytics.com https://www.googletagmanager.com/a;
media-src 'self' https://youtube.com;
script-src 'self' 'unsafe-inline' 'unsafe-eval' https://www.google-analytics.com/ https://*.disqus.com https://disqus.com https://c.disquscdn.com https://ajax.cloudflare.com https://www.googletagmanager.com/gtag/js https://cse.google.com/cse.js https://www.google.com/cse/static/element/ https://local.adguard.org;
base-uri 'none'; object-src 'none';
connect-src 'self' wss://local.adguard.org/adguard-ws-api/api;
font-src 'self' data:;

Obviously we want to remove the ‘unsafe-inline’.

Some oddities in there that bear explainging. References to adguard deal with AdGuard for Windows that I’m trialling at the moment (that is also the reason for data: on the font-src). Disqus should be obvious from our other thread :wink:. Google references cover GA apart from the inline script. Cloudfront is because I use Netlify’s smart image handling. StackExchange is to allow me to dynamically load my SE badge.

I may be able to trim that down a little more as I think some of those entries are only needed for my search page which uses a Google site search form. Yes, I know I need to fix that up and use Algolia or some such but I failed miserably last time I tried and I just haven’t had the motivation to fix that.

I also need some special care on a few other pages.

Post page with embedded Slideshare

Seems to need Access-Control-Allow-Origin = "*" instead of my default Access-Control-Allow-Origin = "https://it.knightnet.org.uk"

Contact form

I use Netlify forms for this and have to use reCapture to stop the spam. So that needs a different CSP:

upgrade-insecure-requests; 
frame-ancestors 'none'; child-src https://www.google.com/; 
report-uri https://totallyinfo.report-uri.com/r/d/csp/wizard;

Search form

Uses Google site search as mentioned.

Redirects

As my site has been around a long time and was merged from several older sites to WordPress then migrated from that to Hugo, I have a lot of old URL’s that I forward to better locations. Every time I try to remove those, I get lots of issues.

I also have some “shortcut” redirects that take me to other places, easier than trying to remember all of the URL’s. I actually have another domain registered that I’m going to use for short URL’s.

OK, as I was looking into it all, I realised that GA REALLY isn’t worth it - it is GONE! The crap that it dynamically loads makes a complete mockery of any security.

2 Likes