
In this post I describe how to use the Trusted Types directive as part of the Content-Security-Policy (CSP) feature to prevent against cross-site-scripting (XSS) attacks. I'll start by demonstrating how a cross-site scripting attack works, focusing on client-side, DOM-based attacks using dangerous APIs.
After demonstrating the issue, I'll show how you can use Trusted Types to close the vulnerabilities in these APIs, and how Trusted Types work. After closing these vulnerabilities, I'll show several ways to work Trusted Types: by using safe APIs, by using sanitisation libraries, and by creating Trusted Types using policies.
It's worth noting that the Trusted Types API is currently only available in Chromium based browsers, but it has been available in Chromium since version 83, released in 2020.
DOM-based client-side cross-site-scripting
Cross-site-scripting generally occurs where you allow input from a user to be directly written, unsanitised, into an HTML page in some way. This can occur in server side apps when generating HTML, for example if you use the @Html.Raw()
method in Razor.
A similar vulnerability exists on the client-side if you allow user data to be written unsanitised into the DOM. This input could come from a URL parameter or postMessage
channel for example, and it becomes problematic if you use it directly in some APIs like Element.innerHTML
, eval
, or setTimeout
, for example.
To demonstrate the issue, The following shows part of a very simple HTML page returned from an ASP.NET Core app. It contains a single "target" <div>
element which initially contains some text to be replaced. There is also a simple <script>
block which reads a username
parameter from the URL's querystring, and writes the name as an <h1>
tag using the innerHTML
property.
Remember, this code contains an XSS vulnerability, it's just for demo purposes!
<div id="target">To be replaced</div>
<script type="text/javascript">
const urlParams = new URLSearchParams(window.location.search);
const username = urlParams.get('username') || 'no user';
// set the username
const target = document.getElementById('target');
const inner = '<h1>' + username + '</h1>';
target.innerHTML = inner;
</script>
The innocuous case where the user enters a simple name doesn't show any obvious issues:
However, this code contains a severe XSS vulnerability. An attacker could force a user to travel to a URL something like the following:
http://localhost:5000/?username=<img src=x onerror="alert('XSS Attack')">
This querystring demonstrates that the code contains a clear XSS. When you hit this page:
- The querystring is read from the URL as
<img src=x onerror="alert('XSS Attack')">
. - It's combined with the
<h1>
to give<h1><img src=x onerror="alert('XSS Attack')"></h1>
. - It's inserted into the target
<div>
. - The
<img>
tag attempts to load the srcx
, which obviously fails. - The
onerror
event fires, executing the XSS attack!
The attack may seem a bit convoluted, and it is, but that's because there are basic protections to prevent the simplest XSS attacks;
<script>
tags added viainnerHTML
are not executed automatically.
The following is the result: the alert
executes demonstrating the attack.
Using innerHTML
as shown above opens yourself up to XSS vulnerabilities, but auditing everywhere that uses the API can be difficult, especially if it's used inside a library somewhere. Luckily, there's a way to protect yourself using CSP.
Blocking cross-site-scripting with Trusted Types and a Content-Security-Policy
One of the most important security feature for modern applications is the Content-Security-Policy
(CSP). CSP is typically added as a header, but it can also be added as a <meta>
element. CSP provides a whole swathe of features, such as forcing requests to use https
instead of http
, only allowing connections to specific hosts and resources, only allowing scripts that match a specific hash, and a variety of other options.
The feature I'm using in this post is based on a draft W3C specification called Trusted Types. In particular, you can add the require-trusted-types-for
directive to your CSP.
My security headers library, NetEscapades.AspNetCore.SecurityHeaders can help you build a CSP and add it as a header to your ASP.NET Core responses. The following very basic configuration adds a simply Trusted Types directive to the CSP for the app:
var builder = new WebApplication.CreateBuilder();
var app = builder.Build();
// Configure the policy
app.UseSecurityHeaders(new HeaderPolicyCollection()
.AddContentSecurityPolicy(builder =>
{
// Add require-trusted-types-for 'script' header
builder.AddRequireTrustedTypesFor().Script();
}));
// other configuration
app.Run();
This adds a header like the following to the response:
Content-Security-Policy: require-trusted-types-for 'script'
And now if we try to exploit the innerHTML
API, we are magically protected:
The screenshot above shows that the document requires TrustedHTML
assignment, and that assigning innerHTML
has failed completely. The attack has been thwarted!
So how does the CSP header protect you?
As you can see from the log messages, when you return a CSP with the require-trusted-types-for 'Script'
directive, you put the browser into a different mode, in which the standard APIs no longer accept simple string
values. Instead, APIs like innerHTML
must be passed a TrustedHTML
object; APIs like eval
must be used with a TrustedScript
object; and APIs like setting a <script src>
must be used with TrustedScriptURL
.
Fixing trusted type violations
By changing the type accepted by these APIs (also called "injection sinks"), you're safe from simple injection attacks, but presumably you do want to call those APIs, or at least achieve the same functionality. In this section I'll show a couple of options.
Avoiding problematic APIs
In this section, I'll assume that you want to display the username passed in the querystring, but in a safe way. The following snippet shows how you can use createElement
, textContent
, and appendChild
instead of the dangerous innerHTML
API:
const urlParams = new URLSearchParams(window.location.search);
const username = urlParams.get('username') || 'no user';
const target = document.getElementById('target');
// Use createElement to create a DOM element, and set the text
const h1 = document.createElement('h1');
h1.textContent = username;
target.textContent = '';
target.appendChild(h1);
With this change, the page is functioning again, this time without exposing any CSS vectors, and without any errors:
Unfortunately, it's not always possible to refactor your code in this way.
Using a sanitisation library
The approach in the previous section is safest, as it uses inherently safe APIs, but it's unfortunately not always possible. For example, perhaps you want people to be able to provide safe HTML to include in the DOM.
In this section, I show how you can use a sanitisation library to create a TrustedHTML
type that you can pass directly to the innerHTML
setter.
Using a sanitisation library is obviously only as "safe" as the library itself. If there's a bug or workaround in the sanitisation library, then you may still be vulnerable despite using Trusted Types.
In this example I'm using DOMPurify, an XSS sanitiser library. There are many ways to use this library, but in this example I add a link to the script directly:
<script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.2.3/purify.min.js"
integrity="sha512-Ll+TuDvrWDNNRnFFIM8dOiw7Go7dsHyxRp4RutiIFW/wm3DgDmCnRZow6AqbXnCbpWu93yM1O34q+4ggzGeXVA=="
crossorigin="anonymous" referrerpolicy="no-referrer"></script>
And then call DOMPurify.sanitize()
on the potentially vulnerable HTML, enabling the Trusted Type support:
const urlParams = new URLSearchParams(window.location.search);
const username = urlParams.get('username') || 'no user';
const target = document.getElementById('target');
const inner = '<h1>' + username + '</h1>';
// Create a "cleaned" version, which returns a TrustedHTML object
const cleaned = DOMPurify.sanitize(inner, {RETURN_TRUSTED_TYPE: true});
target.innerHTML = cleaned;
If we test this out, we get a different result to the previous example, as the HTML is still written to the <div>
target, but the dangerous XSS vector has been removed:
<div id="target"><h1><img src="x"></h1></div>
The result is simply a broken image, instead of the direct CSS:
It's always safest to just not accept user input where possible, or to use the safe APIs, but DOMPurify provides a feasible alternative if you really need the functionality.
Controlling how trustedHTML may be generated
We've protected our application, but there still feels like a gap here, right? Should it really be possible for just any JavaScript to be able to generate a TrustedHTML
type and bypass the trusted Type protections? 🤔
The solution to that problem is with yet another CSP directive—trusted-types
. You can add this directive using NetEscapades.AspNetCore.SecurityHeaders as follows:
var policyCollection = new HeaderPolicyCollection()
.AddContentSecurityPolicy(builder =>
{
builder.AddRequireTrustedTypesFor().Script();
// Add trusted-types mypolicy to CSP
builder.AddTrustedTypes().AllowPolicy("my-policy");
});
This results in the following CSP header being added to the response:
Content-Security-Policy: require-trusted-types-for 'script'; trusted-types my-policy
After that change, the code that was previously working to sanitize the querystring now fails:
The error message says:
Refused to create a TrustedTypePolicy named 'dompurify'
because it violates the following Content Security
Policy directive: "trusted-types my policy".
As the error message hints at, the DOMPurify library creates a TrustedHTML
object using a TrustedTypePolicy
called dompurify
. We'll look in more detail at how to create policies soon, but for now we'll unblock DOMPurify by explicitly allowing the dompurify
policy it uses in our CSP:
var policyCollection = new HeaderPolicyCollection()
.AddContentSecurityPolicy(builder =>
{
builder.AddRequireTrustedTypesFor().Script();
builder.AddTrustedTypes()
.AllowPolicy("my-policy");
.AllowPolicy("dompurify"); // 👈 Add additional policy
});
So now the response header looks like this:
Content-Security-Policy: require-trusted-types-for 'script'; trusted-types my-policy dompurify
And with that we have a functioning DOMPurify sanitizer with Trusted Type protections again:
Unfortunately, DOMPurify doesn't work for all scenarios.
Creating a Trusted Type policy
In general, creating your own Trusted Type policy should probably be a last resort. Ideally you'll simply remove usages of any problematic APIs. If not, then using a library like DOMPurify is probably the next-best safest approach. Unfortunately, that's not always possible. For those cases, you can create your own policies to create Trusted Types like TrustedHTML
.
When you're initially adding support for Trusted Types, you may want to create your own policies as part of the migration. However, your goal should generally be to remove as many of these as possible.
At the core of the Trusted Types policies you must provide a function that takes a string
and returns a "safe", sanitized, string
. You pass this to the window.trustedTypes
API, to create a TrustedTypePolicy
that can be used to create instances of TrustedHTML
from a string
:
// fallback incase trusted-types feature is not available
let sanitizeHtmlPolicy = {createHTML: x => x};
// Feature testing
if (window.trustedTypes && trustedTypes.createPolicy) {
// Create a policy called my-policy (so it's allowed by the CSP)
sanitizeHtmlPolicy = trustedTypes.createPolicy('my-policy', {
// Create a basic sanitisation function (for demonstration only!)
createHTML: toEscape => toEscape
.replace(/</g, '<')
.replace(/>/g, '>'),
});
}
// retrieve the parameters
const urlParams = new URLSearchParams(window.location.search);
const username = urlParams.get('username') || 'no user';
const target = document.getElementById('target');
const inner = '<h1>' + username + '</h1>';
// Create a trusted-type by invoking the policy and using it
const cleaned = sanitizeHtmlPolicy.createHTML(inner)
target.innerHTML = cleaned;
It's important to understand that there's no verification that the "sanitization" function you use to create the policy actually sanitizes. It's entirely up to you to make it safe. Which is also why this is the riskiest approach 😉
Nevertheless, you can see that with the new policy we don't receive any errors, and our sanitised string can be written to the innerHTML
property successfully:
In the example above I showed how to create a TrustedHTML
object for setting on innerHTML
using the createHTML
method on the policy. If you need it TrustedTypePolicy
also contains a createScript()
method for creating a TrustedScript
object and a createScriptURL()
method for creating a TrustedScriptURL
object.
Creating a default policy
When you're initially migrating to using Trusted Types you have a bit of a problem. You need to update every usage of a problematic API. That could be a lot of work.
To work around this, you can create a "default" policy. This policy is used whenever a string
is passed to an API that requires Trusted Types.
In the simplest case you could create a "pass through" version that just logs a warning to the console when it's used, though ideally you'll do some sort of sanitisation, whether it's using DOMPurify, or the super simple case:
// fallback incase trusted-types feature is not available
let sanitizeHtmlPolicy = {createHTML: x => x, createScript: x => x, createScriptURL: x => x};
// Feature testing
if (window.trustedTypes && trustedTypes.createPolicy) {
// create a default policy
sanitizeHtmlPolicy = trustedTypes.createPolicy('default', {
createHTML: toEscape => {
console.log('Warning: use of default createHTML policy.');
return toEscape // TODO: use a sanitization library
.replace(/</g, '<')
.replace(/>/g, '>');
},
createScript: toEscape => {
console.log('Warning: use of default createScript policy.');
return toEscape; // TODO: actually try to sanitize this
},
createScriptURL: toEscape => {
console.log('Warning: use of default createScriptURL policy.');
return toEscape; // TODO: actually try to sanitize this
},
});
}
The default
policy is always available, but you still need to explicitly allow it in your CSP configuration:
var policyCollection = new HeaderPolicyCollection()
.AddContentSecurityPolicy(builder =>
{
builder.AddRequireTrustedTypesFor().Script();
builder.AddTrustedTypes().Default();
});
With this setup, you now get warnings whenever the fallback policy is used, but your APIs aren't broken any more:
This at least gives a migration path forward for Trusted Types!
Summary
In this post I demonstrated how some APIs are vulnerable to cross-site-scripting (CSS) attacks in the browser. I then showed how the Trusted Types APIs in the browser, coupled with the require-trusted-types-for
and trusted-types
Content-Security-Policy directives can help protect your site. Depending on which APIs you're using, how prevalently they're used, and whether they're called by code outside of your control, migrating to Trusted Types may not be an easy fix, but there are approaches that make it possible.