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!
I then launched the same analysis on my blog, and I obtained the following result:
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
:
|
|
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:
|
|
[[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:
|
|
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:
|
|
I applied the update, redeployed my side with these changes, and launched a new evaluation. I then obtained the following result:
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.
β― 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
:
|
|
After applying this update and redeploying my website, the header X-Frame-Options has turned green, and I have reached a new C ranking.
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:
|
|
I now have the following evaluation:
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.
|
|
A new check gave me the following result:
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:
|
|
β― 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.
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”.
You can notice plenty of error logs on Google Chrome when opening the DevTools console.
We will now resolve each error by taking these logs one by one.
|
|
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
|
|
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
|
|
β 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
|
|
β 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
|
|
β 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)
|
|
β 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
- A Balanced Approach: New Security Headers Grading Criteria β Probely
- Configuring Netlify HTTP Security Headers | Portfolio
- Content Security Policy on Netlify (guide) - DEV Community
- Create a Content Security Policy (CSP) in Hugo | Developer for Life
- CSP Evaluator
- Custom headers | Netlify Docs
- HSTS - The missing link in Transport Layer Security
- HTTP Response Analysis
- Hugo Documentation | Hugo
- MDN Web Docs
- Why It’s Bad to Use ‘unsafe-inline’ in script-src