Overview

Many people do not have a clear understanding of the differences between XSS and CSRF, hence get confused when choosing security strategies to prevent XSS and CSRF, it turns out many people just use all security weapons which is sometimes unnecessary (For example, CSRF token is unnecessary in most cases). In this article, I will try to help you understand the differences between them, give you the best option and explain the trade-off between security and implementation effort.

XSS

We used to have two types of XSS, type 1 and type 2. In 2005, Amit Klein defined a third type of XSS, and often referred to as type 0.

XSS Types

Type 1: stored XSS

Stored XSS means a malicious script can be injected into the server storage such as the backend database or the browser storage, then the script can send requests to the server to steal user sensitive information or even make payment. This kind of attack is often permanent.

Type 2: reflected XSS

Reflected XSS means the error message or search result contains malicious script from user input. The script is not stored anywhere. Unlike type 1, this kind of attack is not permanent.

Type 0: DOM based XSS

The malicious input in Type 1 and 2 are submitted to the server, and the vulnerability most likely happens with server-rendered pages and forms. But in type 0, the malicious user input is directly injected into the DOM (e.g, document.write) without any server interactions. This type happens more frequently in modern HTML5 single-page applications.

XSS Prevention

Now let’s talk about preventions from various aspects like “client side rendering”, “server side rendering”, “API design” etc.

Client Side Rendering

Most client-side frameworks have built-in XSS prevention. e.g, React automatically escapes variables. e.g.

const test = '< > & <script>alert(1);</script>'
return (<div>test</div>)

The output would be

<div>&lt; &gt; &amp; &lt;script&gt;alert(1);&lt;/script&gt;</div>

In most cases React is immune to XSS unless you use some special risky features as below:

Exception 1 - inline javascript:code
const val = "javascript:alert(0)"
return (<a href={val}>mylink</a>)
Exception 2 - base64 encoded script
const val = "data:text/html;base64," + base64encode("<script>alert(0)</script>")
return (<a href={val}>mylink</a>)
Exception 3 - dangerouslySetInnerHTML

<div dangerouslySetInnerHTML={{__html:test}} />

Vue cannot protect your if you are trying the following exceptions.

Exception 1 - user input HTML template
new Vue({
  el: '#app',
  template: `<div>` + userProvidedString + `</div>`
})

<div v-html="userProvidedHtml"></div>

h('div', {
  domProps: {
    innerHTML: this.userProvidedHtml
  }
})

<div domPropsInnerHTML={this.userProvidedHtml}></div>
<a v-bind:href="userProvidedUrl">
  click me, the userProvidedUrl could be "javascript:alert(1)"
</a>
Exception 3 - user input scripts

Never render a script.

Server Side Rendering

I don’t want to talk too much about SSR (server side rendering) since this pattern is dying, no matter in JSP, PHP, FreeMarker, or Thymeleaf. The only scenario we still use SSR is SEO.

Although Google crawler can simulate a headless browser and craw the javascript loaded content, this could run out of the crawler budget since it’s relatively slow, so it’s recommended to pre-render the static content or use SSR.

The most popular rendering solutions are all nodejs based, most of the popular frameworks like React can be rendered at the server side, since React already has some mechanisms (mentioned in the last section) to prevent XSS, there are no extra security concerns and the page generated by SSR or client browser is the same.

Server API Response

Unlike traditional JSP/PHP/Thymeleaf applications with very few APIs, modern applications are based on many APIs. If the API response is in JSON format, and you are using modern frameworks like React, you do not need to escape HTML entities like “<” and “>”. If you use ancient frameworks like jQuery you need to use “text(response)” instead of “HTML(response)”.

Input Sanitization

Some people believe we should always do input sanitization, escape all potentially risky characters when submitting data to the server. For example, if the user inputs “it is true that 2>1” into the textarea, we should always convert and save the data in the database as “it is true that 2>1”. But this is NOT the data we want to store, if you return the response in JSON with React, the response would be “it is true that 2>1” and it will be rendered as “it is true that 2&gt;1”, it is only rendered correctly with ancient SSR frameworks like JSP/PHP. Even JSTL will generate it wrongly as below

<c:out value="${val}"/>
it is true that 2&amp;gt;1

if you are a smart ass you can work around it this way

<c:out value="${val}" escapeXml="false"/>
it is true that 2&gt;1

But this solution does not work consistently and you must ensure there are no special characters (not malicious scripts) stored in your database. This way, you will never be able to create a website like github.com or mathway.com. You should just store “2>1” as it is in your database and decide whether or not to escape it, and how to escape it, later when you use it, render it.

Here we only talk about input sanitization for XSS, for other scenarios input sanitization is useful, e,g, input a value to make the application overflow to return an error message with sensitive information like stack traces.

Someone argues that without input sanitization, it is too risky to render web pages. I think using input sanitization to heuristicly guess XSS is simply making your application behavior incorrect. You can simply use modern frameworks to avoid this.

The principal: Input sanitization is NOT to prevent XSS (or SQL injection), keep it as, use modern web frameworks and decide how to escape it only when using it.

CSP and other headers

Content Security Policy (CSP)

CSP can prevent browsers from loading untrusted scripts and style sheets to avoid XSS, and it’s very powerful to specify very complicated policies. e.g, you can disable any cross-origin resources to be loaded by

Content-Security-Policy: default-src 'self'

Or maybe you want to allow images loaded from some origins

Content-Security-Policy: default-src 'self'; img-src img.abc.com

If a hacker injects a script into your web page, it won’t be loaded by the browser.

There are many other useful features in CSP, not just to prevent XSS but also to prevent man-in-the-middle attack with “Strict-Transport-Security” and HSTS. For more details about CSP please read this

X-XSS-Protection

This header was designed to prevent XSS but ironically it can be used for XSS. See this. Old browsers have a default value of 1 which causes a vulnerability. Modern browsers now ignore this header. If you want to protect your customers running a very old browser, you probably want to set X-XSS-Protection: 0 and rely on other mechanisms like the solutions I give in this article.

CSRF

Browsers have the same-origin policy to prevent a site to manipulate another site maliciously. The script running on website A cannot send a POST XMLHTTPRequest to website B. But some exceptions could be used to attack:

Problem 1: GET request is allowed to be cross-origin with 4 tags

Unlike XSS, in CSRF a hacker cannot run an arbitrary script in the victim’s browser, instead, the hacker has to take the victim to a malicious web page with the 4 tags (img, link, script, and iframe), then make implicit requests to the honest website to attack.

Suppose an API on the honest website as below:

GET https://honest-website.com/pay?toAccount=<account>

There is an img tag on the page of the malicious website like this

<img src="https://honest-website.com/pay?toAccount=hacker_account">

If the hacker can have the user login at the honest website, then go to the malicious website, a payment request is sent from the victim’s browser from the malicious website to the honest website. And in most cases, the honest website cannot identify if this is the user’s intention and will process it normally. Although the same-origin policy protects users from being attacked by ajax requests initiated by javascript, the GET requests with the 4 tags are not protected.

Problem 2: Form submission is allowed to be cross-origin

In the old days, developers assumed the world is wonderful, there are no hackers, cross-origin POST is fine, so, it was allowed and many systems were built on top of this feature. If we had a chance to go back to the old days and disable cross-origin POST and define CORS standards to unlock it, many of the CSRF vulnerabilities would magically disappear. Unfortunately, we cannot change it to break the compatibility of massive existing web applications.

So, if you have a form in the malicious website, and send POST/PUT/DELETE request to the honest website, it’s allowed! And can be done silently!

<form name="myform" action="https://fintech.com/pay" method="post">...</form>
<script>document.form.myform.submit();</script>

However, this is probably not a problem for modern applications, because they do not use forms to submit requests. Modern web APIs accept “application/json” instead of “application/x-www-form-urlencoded”. If you upload a video, modern APIs accept “application/octet-stream” or “video/mp4” instead of “multipart/form-data”, so any form submission is simply not accepted by modern web APIs at all. Considering we still have some legacy applications, I will still give suggestions about solving it in this article, but I will remind you it’s only applicable to legacy applications whenever I talk about form submissions.

Note, if you use javascript to create a REST payload from a form, that’s protected and disallowed by the browser, because it’s actually scripting, not a simple form anymore. e.g, the following code will not work if it’s cross-origin:

  $.ajax({type: "POST", url: url,  data: form.serialize() });

Prevention

Correct HTTP Verb

In the example of problem 1, if we change the HTTP verb to POST, then the hacker won’t be able to attack because img tag cannot send POST requests. So the easiest way to solve this problem is to use POST/PUT/DELETE/PATCH to protect data-changing APIs.

But for those non-data-changing APIs with GET requests, sometimes it is still vulnerable. e.g, suppose we have a fintech company providing paid service for historical stock prices query, and each query costs $1. The hacker could create a page with thousands of tags like this:

<img src="https://fintech.com/query-stock?ticker=SAP"/>
<img src="https://fintech.com/query-stock?ticker=GME"/>
...

Once the victim opens this page, the fintech company could charge him thousands of dollars. In another word, using correct HTTP verbs only protects APIs making changes, for those APIs don’t make changes (like GET requests), this solution does not work. Many security experts and papers suggest protect requests for POST/PUT/PATCH/DELETE, but that’s incorrect.

Anyway, although this solution cannot protect GET requests, and this method cannot prevent cross-origin form submission(legacy applications), but it’s still highly recommended to do so because there are many other benefits when you adhere to the HTTP standard (client knows what APIs are idempotent and can be retried, however, this is out of the scope of this article).

Referer Header Check

You can check the referrer header is exactly from your website, and reject if not. This works because the “4 tags and forms” can only send requests with standard headers, a hacker cannot manipulate a victim’s browser to send extra headers with those 4 tags or forms. This is very simple to implement, but there are pitfalls. If the API is also used by native iOS applications there is probably no referrer. And some gateways or load balancers could remove this header before your server can receive it. Actually, the referrer header is never designed to be used in a very accurate way, not mentioning the “referer” is misspelled which should be “referrer”. The default referrer header could change with different referrer header policies, e.g, chrome changes the default policy last year, and some old browsers do not respect the policy. I would not recommend using this header for CSRF prevention since it’s inaccurate.

SameSite is a new standard for cookie attributes (similar to HTTPOnly, Secure etc.). It can be in 3 values.

  • The Strict value will prevent the cookie from being sent by the browser to the target site in all cross-site browsing contexts, even when following a regular link. This will sacrifice user experience sometimes but it’s definitely the safest.
  • The default Lax value means only when the user navigates to the origin site (the URL changes) with GET requests, cookies are allowed. This includes following a link, or submitting a form with GET. New browsers default to this.
  • If you don’t specify it, some old browser default to this, and cookies will be sent in all contexts including images, hyper-links, and POST requests.

Here we are saying cross-site, not cross-origin because cookies are not cross-origin, you cannot set cookie for each port number. For more details please read cross-origin and cross-site

In most cases Lax is good enough to prevent CSRF if your customers are using modern browsers and your application use correct HTTP verbs. The drawback of this solution is, some old browsers do not support this, think about the browser compatibility before using this solution.

Custom Header

Since those “4 tags and forms” cannot add extra headers, you can add a special header to all of your requests and validate the header at the server side. e.g,

"X-Requested-By: whatever value, supported by Jersey server"

If you are using client like axios you can make it default like this:

axios.defaults.headers.common['X-Requested-By'] = 'desktop-react-app';

This is very simple and effective.

Actually you can stop using cookies to hold the session id, you can just hold the session id in the browser local storage and send it via XMLHTTPRequest with this special header:

X-My-Auth: <your token here>

Then your session id will be immune to CSRF in any case because CSRF depends on cookies. If you don’t use cookies, there is no way to exploit the CSRF related attack vectors.

CSRF Token

CSRF token is probably the most widely suggested solution. CSRF tokens should be generated on the server-side, can be generated once per user session or for each request. Of course per-request token is safer but I would strongly suggest per-session token, or HMAC tokens, for several reasons.

A per-request token needs to generate, store and load a token for each request, this is a huge waste and sometimes impossible for large-scaled internet applications. A per-session token is better since it’s generated once per session, but it still needs an extra IO to a storage like Redis for each request. To reduce IOs, both per-request and per-session tokens can be implemented with HMAC tokens, then the token can be generated and verified by algorithms without any storages. You just ensure the HMAC key is secured and rotated monthly or so.

I don’t recommend using CSRF tokens because it’s best for legacy applications using form submission. Reading this, you must be shocked and think I am crazy. Not at all! Let me explain why. You can generate a per-request token in a hidden field in a form when you render the page with some ancient template engine like JSP or Thymeleaf, then submit the form with content-type “application/x-www-form-urlencoded”. However, in modern applications we don’t render the form from the server side, we have to make a request to get the token first then put it into the React state instead of the hidden form field, then submit the request with fetch or client like axios in JSON. So, each time you call an API you need to call another API to get the token first, sometimes your requests to the backend are doubled, that’s ugly extra work. For modern applications without form submissions, don’t use this!

CORS Vulnerabilities

So far, we know there are two problems that need your attention to prevent CSRF, all other scenarios are well protected by the same-origin policy of the browser. But sometimes you also want cross-origin access and that’s why CORS was introduced. CORS is very powerful to help you integrate web applications together and communicate with each other, but it also introduces severe CSRF problems if misconfigured. e.g, The policy below allow any website to send requests to your website with credentials (cookies) attached, this is a really bad idea of course.

Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true

Another problem is that if you trust and allow a website to access your website with cookies (set Access-Control-Allow-Credentials to true), you must be aware that if the website has XSS vulnerabilities, hackers could attack your website from the websites you trust.

Conclusion

XSS Recommendation

Using modern frameworks, avoid using special features like “dangerouslySetInnerHTML”, don’t escape user input, only escape it when rendering.

CSRF Recommendation

In most cases, using correct HTTP verbs and custom headers with a modern web framework should be good enough. CSRF token is the most widely used solution but I do not recommend it, because it’s much more complicated than other solutions, only use it if you have a legacy application that has form submissions. When enabling CORS, you could be attacked by the websites you trust.