The Blessing of the Strings
Trusted Types have been a proposal by Google for quite some time at this point, but it's currently getting a lot of attention and work in all browsers (Igalia is working on implementations in WebKit and Gecko, sponsored by Salesforce and Google, respectively). I've been looking at it a lot and thought it's probably something worth writing about.
The Trusted Types proposal is about preventing Cross-site scripting (XSS), and rides atop Content Security Policy (CSP) and allows website maintainers to say "require trusted-types". Once required, lots of the Web Platform's dangerous API surfaces ("sinks") which currently require a string will now require... well, a different type.
myElement.innerHTML
(and a whole lot of other APIs) for example, would now require a TrustedHTML
object instead of just a string.
You can think of TrustedHTML
as an interface indicating that a string has been somehow specially "blessed" as safe... Sanitized.
Granting Blessings
The interesting thing about this is how one goes about blessing strings, and how this changes the dynamics of development and safety to protect from XSS.
To start with, there is a new global trustedTypes
object (available in both window and workers) with a method called .createPolicy
which can be used to create "policies" for blessing various kinds of input (createHTML
, createScript
, and createScriptURL
). Trusted Types comes with the concept of a default policy, and the ability for you to register a specially named "default"...
//returns a policy, but you
// don't really need to do anything
// with the default one
trustedTypes.createPolicy(
"default",
{
createHTML: s => {
return DOMPurify.sanitize(s)
}
}
);
And now, the practical upshot is that all attempts to set HTML will be sanitized... So if there's some code that tries to do:
// if str contains
// `<img src="no" onerror="<em>dangerous code</em>" >`;
target.innerHTML = str;
Then the onerror
attribute will be automatically stripped (sanitized) before .innerHTML
gets it.
Hey that's pretty cool!
But wait... can't someone come along then and just create a more lenient policy called default?
No! That will throw an exception!
Also, you don't have to create a default. If you don't, and someone tries to use one of those methods to assign a string, it will throw.
The only thing this enforcement cares about is that it is one of these "blessed" types. Website administrators can also provide (in the header) the name of 1 or more policies which should be created.
Any attempts to define a policy not in that list will throw (it's a bit more complicated than that, see Name your Policy below). Let's imagine that in the header we specified that a policy named "sanitize" is allowed to be created.
Maybe you can see some of why that starts to get really interesting. In order to use any of those APIs (at all), you'd need access to a policy in order to bless the string. But because the policy which can do that blessing is a handle, it's up to you what code you give it to...
{
const sanitizerPolicy =
trustedTypes.createPolicy(
"sanitize",
{
createHTML: s => {
return DOMPurify.sanitize(s)
}
);
// give someOtherModule access to a sanitization policy
someOtherModule.init(sanitizerPolicy)
// yetAnotherModule can't even sanitize, any use of those
// APIs will throw
yetAnotherModule.foo()
}
// Anything out here also doesn't have
// access to a sanitization policy
What's interesting about this is that the thing doing the trusting on the client, is actually on the client as well - but the pattern ensures that this becomes a considerably more finite problem. It is much easier to audit whether the "trust" is warranted. That is, we can look at the above to see that there is only one policy and it only supports creating HTML. We can see that the trust there is placed in DOMPurify, and even that amount of trust is only provided to select modules.
Finally, most importantly: It is a pattern that is machine enforceable. Anything that tries to use any of those APIs without a blessed string (a Trusted Type) will fail... Unless you ask it not to.
Don't Throw, Just Help?
Shutting down all of those APIs after the fact is hard because all of those dangerous APIs are also really useful and therefore widely used. As I said earlier, auditing to find and understand all uses of them all is pretty difficult. Chances are pretty good that there might just be a lot more unsafe stuff floating around in your site than you expected.
Instead of Content-Security-Policy
CSP headers, you can send Content-Security-Policy-Report-Only
and include a directive that includes report-to /csp-violation-report-endpoint/
where /csp-violation-report-endpoint/
is an endpoint path (on the same origin). If set, whenever violations occur, browsers should send a request to report a violation to that endpoint (JSON formatted with lots of data).
The general idea is that it is then pretty easy to turn this on and monitor your site to discover where you might have some problems, and begin to work through them. This should be especially good for your QA environment. Just keep in mind that the report doesn't actually prevent the potentially bad things from happening, it just lets you know they exist.
Shouldn't there just be a standard santizer too?
Name Your Policy
I'm not going to lie, I found CSP/headers to be both a little confusing to read and to figure out their relationships. You might see a header set up to report only....
Content-Security-Policy-Report-Only: report-uri /csp-violation-report-endpoint; default-src 'self'; require-trusted-types-for 'script'; trusted-types one two;
Believe it or not that's a fairly simple one. Basically though, you split it up on semi-colons and each of those is a directive. The directive has a name like "report-uri" followed by whitespace and then a list of values (potentially containing only 1) which are whitespace separated. There are also keyword values which are quoted.
So, the last two parts of this are about Trusted Types. The first, require-trusted-types-for
is about what gets some kind of enforcement and really the only thing you can put there currently is the keyword 'script'
. The second, trusted-types
is about what policies can be created.
Note that I said "some kind of enforcement" because the above is "report only" which means those things will report, but not actually throw, while if we just change the name of the header from Content-Security-Policy-Report-Only
to Content-Security-Policy
lots of things might start throwing - which didn't greatly help my exploration. So, here's a little table that might help..
If the directives are... | then... |
---|---|
(missing) | You can create whatever policies you want (except duplicates), but they aren't enforced in any way. |
require-trusted-types-for 'script'; |
You can create whatever policies you want (except duplicates), and they are enforced. All attempts to assign strings to those sinks will throw. This means if you create a policy named default, it will 'bless' strings through that automatically, but it also means anyone can create any policy to 'bless' strings too. |
trusted-types |
You cannot create any policies whatsoever. Attempts to will throw. |
trusted-types 'none' |
Same as with no value. |
trusted-types a b |
You can call createPolicy with names 'a' and 'b' exactly once. Attempts to call with other names (including 'default'), or repeatedly will throw. |
trusted-types default |
You can call createPolicy with names 'default' exactly once. Attempts to call with other names, or repeatedly will throw. |
require-trusted-types-for 'script'; trusted-types a |
You can call createPolicy with names 'a' exactly once. Attempts to call with other names (including default), or repeatedly will throw. All attempts to assign strings to those sinks will throw unless they are 'blessed' from a function in a policy named 'a' |