Featured image of post Fortify Your Hugo Site: Mastering Security Headers

Fortify Your Hugo Site: Mastering Security Headers

Enhance your Hugo website's defenses with expert security header configuration. Elevate your security posture and protect your visitors with ease.


By diving into the forum concerning GoHugo (the static site generator built in GoLang that I am using on this blog) and reading the Hugo documentation, I regularly encountered the topic of security policy configuration. I then decided to verify and strengthen the security of my blog. I detail the journey that led me to raise this security to a satisfying level.

Before exploring the details further, I must clarify that I am not a security expert. I did my best to understand the basics of security, and I hope this article may be helpful for some of you.


❯  Measure the initial level of security

As a prerequisite, you must have a website deployed and available online. The tool I used to measure the security score of my website is Security Headers by Probely. From the interface, enter the website address you want to scan. After a few seconds, the website displays a note ( from F, which symbolizes a terrible security level, to A+, the highest level of security). You can also enter other website addresses than your blog. You may be surprised by the level of security some well-known websites have!

Scan applied to spotify.com
Scan applied to spotify.com

I then launched the same analysis on my blog, and I obtained the following result:

Initial analysis
Initial analysis

I obtained helpful information: first, the score is pretty low ( D, we are low in the range). However, the website notes one positive point: the header "Strict-Transport-Security" is already set and valid. Without going too deep into the details, cf this article, this header “allows a web server to enforce the use of TLS in a […] web browser.

Now that we know where we stand, we will see how to add all the other missing headers.


❯  Basic headers configuration

By having a look on the configure server documentation page), there is a basic configuration for the development environment, to add in the file config/development/server.toml:

1
2
3
4
5
6
7
8
[[headers]]
for = '/**'
[headers.values]
Content-Security-Policy = 'script-src localhost:1313'
Referrer-Policy = 'strict-origin-when-cross-origin'
X-Content-Type-Options = 'nosniff'
X-Frame-Options = 'DENY'
X-XSS-Protection = '1; mode=block'

Since this file is in a “development” package, it is made to be used locally. I initially planned to create one file per environment. Unfortunately, Netlify ignores the headers defined in such a configuration, whatever if you define it in an environment and you explicit the environment in adding –environment=<the_environment> to the hugo build command, and even if you add the server.toml file in configs/_default/.

I then choose to define headers that I will use in production directly in the netlify.toml configuration file located at the root of my project. I kept the server.toml file to do the successive iteration in my development environment. I recommend you this article if you want to learn more on the add of headers in this file.

Note that there is another manner to configure these headers. I successfully tested and used, in particular, the definition of headers on a file static/_headers for a site hosted on Netlify (cf this documentation).


❯  Add configuration on the netlify.toml file

If we take the configuration displayed in the section “Basic headers configuration”, the beginning of the configuration looks like this:

1
2
3
4
[[headers]]
for = '/**'
[headers.values]
...

[[headers]] indicates that we will describe the headers configuration, for = '/**' indicates that the following configuration will apply for all the paths of the website, and finally, [headers.values] indicates the beginning of the header configuration for this path.You can then define different headers depending on the path as follows:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
[[headers]]
for = '/**'
[headers.values]
...
                                               
/posts/**
[headers.values]
...

/articles/**
[headers.values]
...

I now detail the different headers, their possible values, what I have applied to my case, and the consequences of the security ranking.


❯  Permissions-Policy

This header specifies functionalities (like the camera, the microphone, or geolocation) that can be used on the website. More details on the MDN Web Docs here. My blog doesn’t need any of these functionalities. As a result, I assign an empty allowlist () to each functionality. At this step, my netlify.toml file is as follows:

1
2
3
4
[[headers]]
for = '/**'
[headers.values]
Permissions-Policy = 'accelerometer=(self), camera=(), microphone=(), geolocation=()'

I applied the update, redeployed my side with these changes, and launched a new evaluation. I then obtained the following result:

Analysis after adding the Permission-Policy header
Analysis after adding the Permission-Policy header

Although I still have the same awful D ranking, we can see that the header Permissions-Policy turns green, which is excellent news! The proof that the configuration applied has been taken into account.

Another way to validate that the configuration is well used is from the Google Chrome DevTools. To verify what are the headers of a website, do a right click > Inspect. Then, click on the tab “Network” in the panel that opened. Eventually, refresh your page, and you should see a line matching your website’s url, of type “Document”. By clicking on it, a new view opens, on which you can see the Headers that are currently set.

Google Chrome DevTools with the Permissions-Policy header highlighted
Google Chrome DevTools with the Permissions-Policy header highlighted

❯  X-Frame-Options

These headers prevent rendering a page from an in a <frame>, <iframe>, <embed> or <object>. Adding protection to the visitor from click-jacking attacks, consisting of trying to trick them via invisible objects located above elements of a website, for example to steal sensitive information.

There are two valid options for this header: SAME-ORIGIN, allowing to render elements only if they are coming from the same origin as the website, or DENY: whatever the origin, it is not possible to load such elements. More details concerning this header are available on the MDN Web Docs here. Again, since I don’t need such functionality, I add this header and set it to DENY:

1
2
3
4
5
[[headers]]
for = '/**'
[headers.values]
Permissions-Policy = 'accelerometer=(self), camera=(), microphone=(), geolocation=()'
X-Frame-Options = 'DENY'

After applying this update and redeploying my website, the header X-Frame-Options has turned green, and I have reached a new C ranking.

Analysis after adding the X-Frame-Options header
Analysis after adding the X-Frame-Options header

We are on the right way!


❯  X-XSS-Protection

Cf the MDN web docs, the X-XSS-Protection headers that prevent cross-script scripting attacks by stopping the loading of pages. But this is a deprecated header. So, unless there is a need to support the legacy version of browsers, this header can be omitted to deactivate the inline javascript in the Content-Security Policy. You will see in the dedicated section that this is a tricky mission…


❯  X-Content-Type-Options

This header informs the browser of the possibility of guessing the content-type of a page that has been requested. There are two possible values: sniff, telling that this operation is permitted, or nosniff. Using nosniff to avoid sniffing MIME attacks is highly recommended: attackers are sending an HTTP response with an incorrect type of content. If the browser misinterprets the page as a Javascript, it may authorize the attackers to execute unauthorized operations on the victim’s browser. More details on this header on the MDN web doc here.

Without surprise, I add this header with the value nosniff to my list of headers:

1
2
3
4
5
6
[[headers]]
for = '/**'
[headers.values]
Permissions-Policy = 'accelerometer=(self), camera=(), microphone=(), geolocation=()'
X-Frame-Options = 'DENY'
X-Content-Type-Options = 'nosniff'

I now have the following evaluation:

Analysis after adding the X-Content-Type_Options
Analysis after adding the X-Content-Type_Options

With this B ranking, We are closer and closer of the best security score! The next header we will focus on is the Referrer-Policy.


❯  Referrer-Policy

This one is a bit more complex to understand. It controls “how much information concerning the referrer should be included with requests.” In other words, given a page containing this header, if this page contains one or several links, this header controls which information will be transmitted to the destination of the links clicked by a visitor of this page (nothing, the referrer base URL, the path, some eventual parameters) under certain conditions (the destination belong to the same domain or not, both source and destination are using HTTPS, the source is in HTTPS and the destination is in HTTP, …). I won’t detail here the possible options, which are described with examples again on the MDN web doc.

For my blog, I will use the default value for this header: strict-origin-when-cross-origin. It transfers the origin ( the base URL of my blog), parameters and path for destination that are on the same domain if the protocol security level of the origin and the destination are identical. Otherwise, nothing is transferred.

1
2
3
4
5
6
7
[[headers]]
for = '/**'
[headers.values]
Permissions-Policy = 'accelerometer=(self), camera=(), microphone=(), geolocation=()'
X-Frame-Options = 'DENY'
X-Content-Type-Options = 'nosniff'
Referrer-Policy = 'strict-origin-when-cross-origin'

A new check gave me the following result:

Analysis after adding the Referrer-Policy
Analysis after adding the Referrer-Policy

Hourra, it’s green! I now reach rank A! We’re not going to stop there, and we’re now going to try to reach the A+ rank, thanks to one last header, the Content-Security-Policy!


❯  Content-Security-Policy

As a first return of experience, setting up this header finely is a nightmare. Understanding how it works and configuring it properly has demanded hours of investment, during which I had to abandon 3rd parties or replace some of them with others. There is no generic configuration to apply: for people doing a serious and rigorous configuration of this header, it will differ from one website to another… and even for the same website, it varies from one environment to another!

Since configuring this header induces several iterations, I recommend using the hugo server locally to check the effects of the successive modifications you will apply to its values. I also suggest that you be guided by Google Chrome DevTools, which highlights the errors in the header configuration and offers suggestions for resolving them.

Let’s start with the most restrictive possible values for this header:

1
2
3
4
5
6
7
8
9
[[headers]]
for = '/**'
[headers.values]
Permissions-Policy = 'accelerometer=(self), camera=(), microphone=(), geolocation=()'
X-Frame-Options = 'DENY'
X-Content-Type-Options = 'nosniff'
Referrer-Policy = 'strict-origin-when-cross-origin'
Content-Security-Policy = "default-src 'none' ; script-src 'none' ; script-src-elem 'none' ; connect-src 'none' ; 
img-src 'none' ; style-src 'none' ; style-src-elem 'none' ; base-uri 'none'; form-action 'none' ; font-src 'none' ; object-src 'none'"

❯  A little tip concerning indentation in the configuration file

The Content-Security policy header contains lots of directives (default-src, script-src, …), that contains themselves several attributes ('self', 'none', a domain, a hash, …). To make it more readable, and then easier to maintain, you can use multiline strings.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
...
Content-Security-Policy = """
 default-src
   'none' ;
 script-src
   'none' ;
 script-src-elem
   'none' ;
 connect-src
   'none' ;
 img-src
   'none' ;
 style-src
   'none' ;
 style-src-elem
   'none' ;
 base-uri
   'none';
 form-action
   'none' ;
 font-src
   'none' ;
 object-src
   'none'
"""

You can go even further by handcrafting the generation of these headers. I recommend you this article if you are interested in this topic.

After adding this configuration to my netlify.toml file and redeploying my blog, I launched a new evaluation.

Analysis after adding the Content-Security-Policy
Analysis after adding the Content-Security-Policy

Yes! I did it! A+. The perfect ranking!

I mentioned earlier that configuring this header is a nightmare. Now you’ll understand why. Looking at my deployed website, I remarked that its appearance has changed “slightly”.

My blog, without the CSP header
My blog, without the CSP header
The same blog, after adding the CSP header
The same blog, after adding the CSP header

You can notice plenty of error logs on Google Chrome when opening the DevTools console.

Google Chrome console displays error for a Content Security Policy with all fields set to 'none'
Google Chrome console displays error for a Content Security Policy with all fields set to 'none'

We will now resolve each error by taking these logs one by one.

1
2
3
In http://localhost:1313/p/hugo-netlify-setup-security-headers/
Refused to load the script 'http://localhost:1313/livereload.js?mindelay=10&v=2&port=1313&path=livereload'
because it violates the following Content Security Policy directive: "script-src-elem 'none'".

This error says that this script cannot be loaded because script-src-elem forbid any script to be executed. This concerns the live reload, used in development, which automatically reloads the website when a change is detected. Since this is something used only locally, I add localhost:1313/livereload.js only in development/config.toml

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
[[headers]]
for = '/**'
[headers.values]
Permissions-Policy = 'accelerometer=(self), camera=(), microphone=(), geolocation=()'
X-Frame-Options = 'DENY'
X-Content-Type-Options = 'nosniff'
Referrer-Policy = 'strict-origin-when-cross-origin'
Content-Security-Policy = """
  default-src
    'none' ;
  script-src
    'none' ;
  script-src-elem
    localhost:1313/livereload.js ;
  connect-src
    'none' ; 
  img-src
    'none' ;
  style-src
    'none' ;
  style-src-elem
    'none' ;
  base-uri
    'none';
  form-action
    'none' ;
  font-src
    'none' ; 
  object-src
    'none'
"""

Once done, rebuild your app, check that the corresponding log error successfully disappeared, and then take the following log in the list.

I give below different kind of cases to tackle.


❯  Case 1. On the style-src-elem directive

1
2
3
localhost/:10
Refused to load the stylesheet 'http://localhost:1313/scss/path_to_file.css'
because it violates the following Content Security Policy directive: "style-src-elem 'none'".

β†’ Either replace 'none' in script-src-elem by http://localhost:1313/scss/path_to_file.css, or by 'self'. I recommend the second solution: the first one will be specific to the local environment, and you will have to adapt the path for the different environments. Instead, add 'self' to the development/server.toml and to the netlify.toml configuration files will automatically accept the execution of styles running from the same origin.


❯  Case 2. On the img-src directive

1
2
Refused to load the image 'http://localhost:1313/favicon.png'
because it violates the following Content Security Policy directive: "img-src 'none'".

β†’ The problem is quite similar to the previous case, but it applies to the img-src section. In all environments, replace 'none' with 'self'.


❯  Case 3. On the script-src-elem directive

1
2
Refused to load the script 'https://domain/path_to_file.js'
because it violates the following Content Security Policy directive: "script-src-elem http://localhost:1313".

β†’ Append this to the list of domain of the script-src-elem section (which become in this case: script-src-elem localhost:1313 https://domain/path_to_file.js).


❯  Case 4. On the script-src-elem directive again (a more complex one)

1
2
3
Refused to execute inline script
because it violates the following Content Security Policy directive: "script-src-elem http://localhost:1313 https://domain/path_to_file.js".
Either the 'unsafe-inline' keyword, a hash ('sha256-an-hash-value'), or a nonce ('nonce-...') is required to enable inline execution.

β†’ After having resolved a few error logs, I met this log (and you will probably meet a similar case if you decide to follow the same journey for your website).

As the log says, three possibilities exist to resolve the situation: add 'unsafe-inline', a hash (that the log provides), or something named 'nonce'.

Unsafe-inline. Solution that call affectionately “solution of the facility” or “easy solution”. This is hard, but the right solution is rarely the easiest one. Adding unsafe-inline will globally allow the execution of any script. I discard this solution (and also the other similar cases that are unsafe hashes, …). I recommend you read this article concerning the use of unsafe-inline for the script-src directive, and also this article for the use of unsafe-inline for the style-src directive.

Add the hash in the script-src-elem (do not forget the single quotes ' surrounding the hash!). To keep it short, this is a hash of the script. Adding this will allow the script corresponding to this hash to be executed. Since this is an appropriate solution for static elements, it becomes more tricky for dynamic objects, for which the hash will generally evolve depending on the context. This is, for example, the case on my blog for the comment section, which is different depending on the article. In this case, I use a nonce instead.

Generate and add a nonce. The solution to apply when the component to authorize is quite dynamic. This is an identifier that you will add on the <script> tag. To generate this, I have created a script in go available here.

Note.

GitHub proposes to embed lists in HTML documents directly, and there is also a shortcode in Hugo to do that. But this is not possible without adding unsafe hashes to the CSP. The other solution is to host JS code, HTML, and stylesheets locally, so it is not a perfect solution.

The same problem of avoiding unsafe-hashes also forced me to choose an alternative solution to replace ‘Discus’, the comment solution I used before. I finally made the choice of choosing a self-hosted solution named ‘Remark42’. But this is another story πŸ™‚.

By applying these strategies to all cases, you should be able to remove all console errors and have a fully functional site that maintains a strong level of security! To go further, if you are interested in checking more precisely whether your content Security Policies are well-defined and thoroughly fine-tuned, I recommend you have a look at cps.evaluator.withgoogle.com. You specify your website address, and it will analyze your CSP and make some explicit points to fix.

Also note the existence of the chrome extension Content Security Policy (CSP) Generator, which automatically generates the Content-Security-Policy header that matches your website simply by visiting different pages. A fantastic tool that was unsuccessful during my tests. After a scan and applying the (straightforward) CSP gave me output, I still had plenty of unresolved errors.


❯  Final word

Not that easy, right?

Here, I pushed the exercise relatively high, even if there are still some possible improvements. For example, adding require-trusted-types-for 'script' to the Content-Security-Policy. Adding this would require some consequent modification that I am not ready to do yet.

This illustrates quite well my final word: it is essential to consider the topic of security, at least to guarantee a certain level of trust and safety for your website’s visitors. Furthermore, depending on the criticality of the project, you can adjust the cursor and potentially release some constraints (particularly concerning the Content Security Policy 😊 ).

I will personally release some constraints to guarantee a good balance between the security level, its functionalities, and maintainability, with a measured cost and energy investment.


❯  Sources