Cross-Site Scripting in Frontend
Vulnerable Examples
JavaScript
Use of innerHTML
, outerHTML
, etc.
There are several ways in which the DOM can be altered by providing literal HTML code. innerHTML
is just the most common way of doing that, and when used recklessly without any check or sanitization, it can be the perfect vector for an XSS vulnerability.
As an example, consider a scenario in which some content needs to be updated:
const parent = document.querySelector('parent');
parent.innerHTML = `<p>${userControlledValue}</p>`;
Instead of the above, a better solution is to use innerText
and possibly build the required DOM objects using the proper API, for example:
const parent = document.querySelector('parent');
const p = document.createElement('p');
p.innerText = userControlledValue;
parent.replaceChildren(p);
In this way, the value of userControlledValue
is not treated as HTML code.
Use of sanitizers
There are cases in which there is no working around that raw HTML, which depends on user-controlled data, must be placed into the web page. The most common example is probably a Markdown editor/viewer where the user provides the original Markdown code. Libraries in charge of performing the conversion to HTML do not also sufficiently sanitize the output. It is the developer’s duty to introduce an additional step to make sure that no dangerous HTML ends up on the page.
This step is often missing, or in-house solutions are used instead of mature and well-established solutions like DOMPurify. Of course, keeping such solutions up to date is also essential.
Use of eval
When possible, eval
should never be used; extra care must be taken in those rare cases where there is no alternative.
A vestige of the past is using eval
to parse JSON, for example:
const jsonString = '{"foo": 123}';
const object = eval(`(${jsonString})`);
This is wrong and very dangerous, especially if the value contains literal user-controlled data. The proper solution is to use JSON.parse
:
const jsonString = '{"foo": 123}';
const object = JSON.parse(jsonString);
Content Security Policy (CSP)
While theoretically, a well-implemented CSP can, of its own accord, stop XSS in most cases, it is better to consider this tool as an additional layer of protection, i.e., a part of a defense-in-depth implementation, to make sure that older user agents that still don’t support CSP, or don’t support it properly, are covered.
At the very least, developers should carefully specify the set of valid origins from the legitimate resources that can be fetched.
Additionally, they should not depend on inline JavaScript and instead use the unsafe-inline
expression. This helps mitigate the impact of reflected XSS instances that affect the backend.
CSP is an enormous and complex topic; it is quite hard to implement correctly, so close attention must be paid to how its rules are written. One possible approach is to start from a very restrictive setup, then increasingly relax as needed according to the legitimate behavior of the web application.
React
React tries very hard to induce the developer not to insert literal HTML into the DOM. However, as previously mentioned, there are cases in which this is needed (after the proper considerations). For this reason, it provides the dangerouslySetInnerHTML
attribute:
function SomeComponent() {
const markup = {__html: 'properly sanitized HTML here'};
return <div dangerouslySetInnerHTML={markup} />;
}
Angular
Angular adopts a similar approach; in fact, it provides a number of bypasses that allow for the relaxation of the default DOM sanitizer depending on the context in which the trusted value is to be placed:
-
bypassSecurityTrustHtml
; -
bypassSecurityTrustStyle
; -
bypassSecurityTrustScript
; -
bypassSecurityTrustUrl
; -
bypassSecurityTrustResourceUrl
.
Vue.js
Vue.js, by default, provides means for safely including user data into the DOM using the {{ ... }}
syntax, for example:
<p>{{ userProvidedData }}</p>
This ensures that dangerous characters, like <
, are properly converted to HTML entities. There are scenarios, though, in which bypassing this kind of sanitization is useful, if not mandatory, the aforementioned Markdown scenario being a chief example. Vue.js provides the v-html
special attributes to do that:
<p v-html="sanitizedUserProvidedData"></p>
Remember that recklessly using this approach might expose your application to an XSS exposure; thus, make sure to use a sanitizer accordingly.
There is another subtle opportunity to gain code execution using the Vue.js facilities. A quite central concept in Vue.js is the one of binding; this is the way of keeping components data synchronized with the relative DOM properties. The v-bind
directive is what is used to do that, and its versatility is what causes the inattentive developer to introduce the aforementioned XSS exposure.
Imagine a scenario in which a component must change some of its attributes according to some parameter in the query. A lazy way could be the following:
<MyComponent v-bind="$route.query" />
The upshot of this is that any parameter from the query will be set as an attribute to the root element of the MyComponent
component. According to the kind of element, it will be trivial to achieve an XSS; refer to this great XSS cheat sheet by PortSwigger. In most cases, though, a way to exploit this scenario is most likely to be found. In one of the simplest cases, assume that MyComponent
has an img
as a root tag; in this case, navigating to #the-page?src=x&onerror=alert(1)
is enough to trigger the exploit.
One possible solution in this case is to limit the bound parameters to the ones really needed, assuming that MyComponent
only needs the myAttribute
parameter; the following code can be used:
<MyComponent :myAttribute="$route.query.myAttribute" />
References
OWASP - Cross-Site Scripting (XSS) OWASP - Code Review Guide OWASP - Cross-Site Scripting Prevention Cheat Sheet