Content Security Policy - Part 2:
Inline Scripts

In this post I'm going to take a look at Content Security Policy and see if I can remove unsafe-inline from script-src.

Recap

In part one of this series, Content Security Policy, I decided that it was too much effort to remove unsafe-inline from script-src and style-src.

At present it is impossible for me to remove unsafe-inline from style-src because of my use of jQuery animations (among other things).

Removing unsafe-inline from script-src, however, might be feasible.

script-src 'unsafe-inline'

'unsafe-inline' allows the following JavaScript on a page:

  • In-line <script>s.
  • In-line events, such as OnLoad().
  • Scripts with a javascript: uri.

'unsafe-eval' allows the following JavaSCript to run on a page:

  • Operator and function eval().
  • The Function constructor.
  • When the first argument is not a function, setTimeout.
  • When the first argument is not a function, setInterval.

Replacing In-Line Events

The only inline events on my site are an onLoad and onerror for loading the combined JS file.

In order to replace the inline events, I need to do two things:

  1. Add an id attribute to the <script> element.
  2. Use event listeners with the above id.

I couldn't get that to work, however. Instead I am going to move things around a bit.

DOMContentLoaded fires when everything has loaded including external scripts. Scripts loaded by scripts, however, are loaded afterwards.

I need to move my onload() and onclick() attributes to functions in an external JavaScript file, and I need to change the onload() of the <script> for my combined JS file to an event listener.

footer.php

With the needed changes, the relevant section of footer.php looks like this:

<script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.4/jquery.min.js" defer></script>
<script src="https://web.johncook.uk/js/combined.2015-10-22R002.min.js" defer></script>
<script type="text/javascript">
function ShowMenuBar() {
	if (window.jQuery) {
		$(".top-bar").css({"opacity":"1", "bottom":"0px !important", "positition":"relative", "overflow":"visible"});
		$(".lead .panel").css("margin-top","3px;");
		$(".top-bar li.toggle-topbar").css("display","none");
	} else {
		setTimeout(ShowMenuBar,1000);
	}
}
document.addEventListener('DOMContentLoaded', function() {
	InitiateFoundation();
});
</script>

The ShowMenuBar() function was a fallback that fired onerror() loading the combined JS file. I have kept it for the time being as I might use it later.

johncook.uk.js

After moving code into johncook.uk.js (which gets minified and added to combined.min.js) and a bit of moving code around so it works the same way, the top of johncook.uk.js looks like the following:

function InitiateFoundation() {
  if (window.jQuery) {
    $(document).foundation();
    if ($.cookie("fonts") !== "1") {
      MenuBarText();
      AddAlert("Load","secondary","Fonts cookie missing. If you are not on a low bandwidth connection, please <a href='#' id='fontscookiemissing' class='tiny button round info'>load fonts</a>.");
      document.getElementById('fontscookiemissing').addEventListener('click',LoadFontClick);
    } else {
      FontLoader();
    }
    LoadSocial();
  } else {
    setTimeout(InitiateFoundation,1000);
  }
};

function LoadFontClick() {
        FontLoader();
        $("#panelAlertBoxLoad .button").addClass("success").removeClass("info");
}

Testing

After testing in Chrome and Chromium, I removed script-src 'unsafe-inline' and then added the sha256- hashes to the headers in nginx—one for the inline JavaScript shown above, and another for something loaded by Twitter's widget.js which will likely stop working at a later time when Twitter change their code.

When I tested in Iceweasel, however, it didn't work. From the look of things "hash-source" (as they call it in Firefox) is either broken in Firefox/Iceweasel, or broken in Chrome/Chromium.

After further testing it looks like Iceweasel is incorrectly reporting errors (or something else is going wrong). I thought the problem was with the share button but I was mistaken—if the container is set to display:none the button's images are set to a width of 0 and isn't fixed when the container is made visible.

To solve that issue I switched from display:none to opacity:0.001 (pretty much completely transparent). I also moved some code around, and switched from a jQuery fadeIn to a fadeTo.

The share button for Twitter is working again in Firefox, and everything else seems to be working despite the

For now I'm going to add back script-src 'unsafe-inline' and wait for whatever is broken to get fixed.

Further Testing

I have now moved all in-line JavaScript (excluding some shadow-root in-line JavaScript inserted by scripts, such as by Twitter's widget.js) out to the combined JS file.

Chrome now shows no CSP errors with unsafe-inline removed from script-src, and Iceweasel only shows one error which looks like it might be related to Twitter's widget.js. iOS Safari also seems to be loading pages on the site fine.

Since everything seems to be working fine, for now I'm going to keep my Content-Security-Policy as it now is:

add_header Content-Security-Policy "default-src 'none'; child-src https://www.youtube.com:443 https://platform.twitter.com:443; frame-src https://www.youtube.com:443 https://platform.twitter.com:443;object-src 'none'; script-src https://web.johncook.uk:443 https://ajax.googleapis.com:443 https://platform.twitter.com:443 https://cdn.syndication.twimg.com:443 https://syndication.twitter.com:443 'sha256-XnNQECY9o-nIv2Qgcd1A39YarwxTm10rhdzegH_JBxY='; style-src https://web.johncook.uk:443 https://fonts.googleapis.com:443 https://platform.twitter.com:443 'unsafe-inline'; font-src https://web.johncook.uk:443 https://fonts.gstatic.com:443; img-src https://web.johncook.uk:443 https://syndication.twitter.com:443 https://platform.twitter.com:443 https://pbs.twimg.com:443 data:; connect-src https://web.johncook.uk:443 'self'; frame-ancestors 'none'; form-action 'none'; base-uri 'self';";

I have specified port 443 for everything in the above just to make the policies as tight as possible.

Twitter Widgets

Due to the removal of no-inline from script-src, Twitter Widgets such as Embedded Tweets may not function properly, and two warnings appear in the console:

"TWITTER: Content Security Policy restrictions may be applied to your site. Add <meta name="twitter:widgets:csp" content="on"> to supress this warning."

"TWITTER: Please note: Not all embedded timeline and embedded Tweet functionality is supported when CSP is applied."

Although the only twitter widgets I currently use are the share button and a timeline (on the Network Status page), I do use a modified embedded tweet (i.e. not technically and Embedded Tweet) on the EFVI blog posting.

There is possibly an alternative way of doing things.

twttr.widgets

For my current two/three use cases, there are functions in widget.js that make it possible to programatically add the widgets to the page:

  • twttr.widgets.createShareButton()
  • twttr.widgets.createTimeline()
  • twttr.widgets.createTweetEmbed()

At present, the share button is added to the page using the following function:

function LoadSocial() {
  if (window.location.pathname != "[...]") {
    if (window.jQuery && window.Foundation) {
      $(".social-links").load("/api/social-links?twt_name=" + encodeURIComponent($("meta[property='og:title']").attr('content')), function() {
      window.twttr=(function(d,s,id){var js,fjs=d.getElementsByTagName(s)[0],t=window.twttr||{};if(d.getElementById(id))return;js=d.createElement(s);js.id=id;js.src="https://platform.twitter.com/widgets.js";fjs.parentNode.insertBefore(js,fjs);t._e=[];t.ready=function(f){t._e.push(f);};return t;}(document,"script","twitter-wjs"));
      twttr.ready(function(){
        twttr.events.bind('rendered',function(){
          $(".social-links").fadeTo(500,1);
        });
      });
//      ShowSocial();
      });
    } else {
      setTimeout(LoadSocial,1000);
    }
  }
};

Using twttr.widgets.createShareButton(), I can greatly simplify it and remove a GET request in the process:

function LoadSocial() {
  if (window.location.pathname != "/articles/personal/biography") {
    if (window.jQuery && window.Foundation && window.twttr) {
      twttr.ready(function(){
        twttr.events.bind('rendered',function(event){
          if ($("#"+event.target.id).hasClass("twitter-share-button-rendered")) {
            $("#"+event.target.id).after("&emsp;");
            $("#social-links").fadeTo(500,1);
          } else if ($("#"+event.target.id).hasClass("twitter-timeline-rendered") || $("#"+event.target.id).hasClass("twitter-tweet-rendered")) {
            $("#"+event.target.id).css({'max-width':'100%','width':'100%'});
            $("#"+event.target.id).each(function () {
              var head = $(this).contents().find('head');
                if (head.length) {
                  head.append('<style type="text/css">.EmbeddedTweet, .timeline, .timeline .stream { max-width: none !important; }</style>');
                }
              });
          }
        });
        twttr.widgets.createShareButton($("link[rel='canonical']").attr("href"),document.getElementById("social-links"),{text:$("meta[property='og:title']").attr('content'),"dnt":false});
        twttr.widgets.createMentionButton("WatfordJC",document.getElementById("social-links"));
      });
    } else {
      setTimeout(LoadSocial,1000);
    }
  }
};

I have modified twttr.events.bind('rendered') as well so I only run the fadeTo jQuery function on the share button once, rather than every time a widget is rendered.

Twitter doesn't seem to like Embedded Tweets being used on fluid layout sites. The else if statement for the twttr.events.bind('rendered') event ensures that, for the time being, all embedded timelines and tweets expand to the full width of the element that contains them.

Browsers throw a CSP error if JavaScript tries to access/modify a Twitter button due to the buttons having a CSP/CORS policy. Twitter may, at a later date, decide to apply the same policy to embedded tweets and timelines although I hope if they do they'll decide to actually support fluid sites by changing the width in the <head> to 100% and support percentages for the iframe width.

I have removed the widget.js loading code and replaced it with a <script> tag in footer.php:

<script src="https://platform.twitter.com/widgets.js" defer></script>

After checking everything was working fine, I added a couple of meta tags to header.php to remove CSP-incompatible widget.js functions (and the widget.js warnings and CSP errors) and to tell Twitter that visitors should be treated as if they have Do Not Track enabled in their browsers:

<meta name="twitter:dnt" content="on" />
<meta name="twitter:widgets:csp" content="on" />

Finally, I made another modification to my nginx configuration for the Content-Security-Policy header:

	add_header Content-Security-Policy "default-src 'none'; child-src https://www.youtube-nocookie.com:443 https://platform.twitter.com:443 https://player.vimeo.com:443; frame-src https://www.youtube-nocookie.com:443 https://platform.twitter.com:443 https://player.vimeo.com:443; object-src 'none'; script-src https://web.johncook.uk:443 https://ajax.googleapis.com:443 https://platform.twitter.com:443 https://cdn.syndication.twimg.com:443 https://syndication.twitter.com:443; style-src https://web.johncook.uk:443 https://fonts.googleapis.com:443 https://platform.twitter.com:443 'unsafe-inline'; font-src https://web.johncook.uk:443 https://fonts.gstatic.com:443; img-src https://web.johncook.uk:443 https://syndication.twitter.com:443 https://platform.twitter.com:443 https://pbs.twimg.com:443 data:; connect-src https://web.johncook.uk:443 'self'; frame-ancestors 'none'; form-action 'none'; base-uri 'self'; media-src https://web.johncook.uk:443";

I have removed the hashes from script-src, and added Vimeo because a restored blog post embedded a Vimeo video.

Eventually when child-src is widely supported and frame-src is no longer used by any of the main browsers I will remove the frame-src directive which will reduce the header size a bit.

Conclusion

Content-Security-Policy can help with securing a site.

While a visitor's compatible browser will load images from https://pbs.twimg.com, it will prevent the loading of scripts from that domain.

If using iframes, sandboxing when appropriate also helps.

CSP may not secure a Web site, but it can help ensure that content from non-whitelisted domains are not executed by a visitor's browser.

When Let's Encrypt launches I will have a play around with it. On my non-Cloudflare domains a multi-domain certificate will mean SNI is no longer a requirement for visiting my sites over IPv4.

When it is fast and easy to create a new TLS certificate I can not only turn on HSTS on all my sites, but I can also request inclusion in the HSTS preload list and turn on HTTP Public Key Pinning (HPKP).

DNSSEC, however, is still a bit of an issue in terms of support. Although Esgob support it, neither Hurricane Electric nor Cloudflare do. It is for that reason I haven't looked into how I'd deploy it yet.