Cross-site scripting (XSS) prevention: HTML escaping is not enough

I wrote this article mainly for myself. But it may be helpful for others too.


HTML escaping is not enough to prevent XSS attacks. What you really need to do is contextual output escaping.


Because HTML escaping is really just safe when untrusted data is put between HTML tags like <span>{{ untrusted-data }}</span>. If untrusted data is rendered in different rendering contexts, for example as an HTML attribute value like href, different characters need to be escaped.

A lot of template engines only do automatic HTML escaping and it's still the developers job to escape properly in other rendering contexts.


I'd like to point out that output escaping or output encoding is not the only measure against XSS. There is HTML sanitization if users are able to enter HTML tags, there's Content Security Policy (CSP) etc. Read more in OWASP's Cross Site Scripting Prevention Cheat Sheet.

Example for HTML attribute escaping (vs. HTML escaping)

When I was about to turn the URL variants on the results page of into clickable links, I was concerned about attackers tricking people to click malicious links because the domain to check is passed as a query parameter.

The surprising thing is a lot of template engines only do HTML escaping. That means if you render untrusted data as an attribute value (which I do with the href attribute), the template engine (Selmer in my case) will HTML-escape the value automatically.

However, since attribute values do not need to be quoted in HTML5, this is very dangerous.

To illustrate the issue, check this example from <a href={{check-result.url}}>{{check-result.url}}</a> with a value of some onmouseover=window.alert(). Since the href's value is unquoted, the space character after some would break out of the href attribute and let the attacker define another attribute – in this case onmouseover=window.alert().

Since Selmer is "only" doing automatic HTML escaping (i.e. replacing &, <, >, ", ' with the respective HTML entities), the malicious payload is left untouched after the escaping step.

Review of templating systems

As I said above, quite some template systems only do HTML escaping. Here is a non-exhaustive list:

  • Selmer (looked into code, confirmed by maintainer via email)
  • Twig (Fabien Potencier's comment on Github, Twig does not "understand" HTML, will not be worked on in the foreseeable future. To his knowledge no other Twig-like templating engine does it. However, Twig provides filters to do contextual encoding manually)
  • Jinja (just read the documentation, doesn't seem to support it. It says "a variable that may include any of the following chars (>, <, &, or ") you SHOULD escape it ... by piping the variable through the |e filter". No word about contextual encoding. Beware that automatic HTML escaping is off by default. Well, this issue about HTML attributes confirms my impression.)
  • Laminas (is aware of the need of contextual escaping, offers escaping functions, see Article describes issue quite well, by the way.)
  • Django (other than Jinja, it does automatic escaping. But just like Jinja only HTML escaping. XSS Exploitation in Django Applications shows the problems quite well. I like this statement a lot: "To say that this escaping of these 5 characters “protects” your application is a gross-underestimation of XSS.")

According to OWASP's XSS prevention cheat sheet, client-side frameworks like React and Angular do automatic contextual encoding. I haven't checked that.

Anyway, for my taste the cheat sheet is a bit too optimistic about how frameworks (and template systems) deal with that problem.

The only template engine I saw with automatic contextual encoding is Go's html/template.

Insufficient auto-escaping? Use encoding libraries

In this case, OWASP recommends using escaping libraries. OWASP is maintaining ESAPI and the newer OWASP Java Encoder.

The maintainer of ESAPI recommends the Java Encoder in Should I use ESAPI? in most cases. In a nutshell, ESAPI offers more than output encoding (e.g. sanitization) and is heavier (also in terms of dependencies) than Java Encoder.

I use Java Encoder for successfully. I had to register a custom filter in Selmer.

Other resources

Published by Robert Möstl

« Back to Blog