HTML escape disabled in Vue component

ID

javascript.vue_html_escape_disabled

Severity

high

Remediation Complexity

trivial

Remediation Risk

low

Remediation Effort

medium

Resource

Injection

Language

JavaScript

Tags

ASVS50:v3.2.3, CWE:79, NIST.SP.800-53, OWASP:2025:A05, PCI-DSS:6.5.7, vue

Description

Vue allows direct rendering of HTML using the v-html attribute in templates, or innerHTML in render functions or JSX. This is dangerous unless the passed content is sanitized against cross-site scripting (XSS) injection, or you are 100% sure that the content is safe and does not contain user input that could be passed to enable execution of JavaScript code.

Additional constructs that are potentially vulnerable to XSS injection attacks are:

  • The v-bind:href directive, which may be exploited with javascript: URLs,

  • The v-bind:style directive.

  • Event directives such as v-on:onclick.

And, in general, rendering part of content in <script> or <style> elements in Vue templates and render functions.

A v-bind:href whose expression starts with a constant path, query, fragment or http(s):// URL ('#diff-' + hash, /repos/${name} ) is not reported: the constant fixes the scheme, so nothing appended to it can make a javascript: URL.

Neither is one that starts with a value the deployment fixes, alone or followed at once by a constant path, query or fragment: the page’s origin (location.origin), the build-time environment (import.meta.env.BASE_URL), or a property of a configuration object the server writes into the page ( ${appSubUrl}/${repo} where const {appSubUrl} = window.config), read directly or through a variable or a data() entry the component never reassigns. The href of an SVG <image> or <use> only loads a resource, so it is not reported either.

A v-bind:href whose URL starts with data the server provided is reported at info, marked undecided (the finding property precision_demotion is undecided): a prop, a property of fetched JSON or of the page’s data (run.link, ${repo.link}/issues ), an attribute of the server’s markup (el.getAttribute('data-repo-link')), or what one of the component’s own functions, or a method of such data (store.buildUrl(item.path)), builds from them. Nothing in the component tells an internal path the server builds (safe) from a URL a user stored, such as a profile’s website or a status' target URL, that the server echoes back (a javascript: URL): check how the server builds the value. A URL the visitor supplies keeps the rule’s severity wherever it appears in the binding: the route ($route, useRoute()), the page’s own URL (location.hash, location.search), the referrer, the window’s name, browser storage, an event’s payload or a v-model value (the property the v-model binds, not the other properties of its object). So does a URL whose origin the component does not show, such as the result of an imported function.

When the project’s Go backend is scanned too, what it sends under each key is read into the API inventory, and the key the value is read under (repo.link is what is written under link) is followed to every place the backend, or the frontend itself, writes that key: struct fields by their json tag or name, map entries, page data (ctx.PageData["k"]), and the argument a frontend function stores under the key (makeStore(pageData.FolderIcon) storing {folderIcon}). A URL every write of which the backend builds from its routes (a literal path, or the configured base followed by a path, as in setting.AppSubURL + "/" + url.PathEscape(name)) is not reported; one any write of which stores what a user sent (a field of a bound form, a request value) is reported at the rule’s severity. The base is configuration when every value the backend assigns to it comes from a configuration source the library metadata knows (an ini key, a viper or koanf getter, a command-line flag, the environment), directly or through parsing and trimming; a package named config or setting is no evidence by itself. A form is bound when a library call the metadata knows fills it from the request (a gin ShouldBind, a go-chi binder, json.NewDecoder(r.Body).Decode). v-html bound to server data is not reported when every write of its key is markup a catalogued library sanitiser or escaper produced, directly or through project functions at most four calls deep. When every write is markup the backend declares it produced (Go’s template.HTML) but its escaping is not proven, it is reported at info, marked undecided: check the renderer. Anything else keeps what is said above: a type with its own MarshalJSON, a json.RawMessage, more than 64 writes, or a write whose value the code does not show leave the finding as it is, and v-html on such server data keeps the rule’s severity.

Rationale

Vue framework use native browser APIs, like textContent, to render HTML content in the browser, so any JavaScript code is escaped and not executed in the browser’s JavaScript engine.

That means that in templates, the text between curly brackets like this: <h1>{{ userData }}</h2> is 100% safe, as userData HTML metacharacters are escaped. Attributes of HTML elements such as <h1 v-bind:title="userProvidedInput">…​</h1> are also escaped.

But developers may skip the automatic escaping by using the constructs mentioned before.

<!-- VULNERABLE -->
<div v-html="userProvided"></div>

<!-- VULNERABLE, JSX syntax in render functions -->
<div domPropsInnerHTML={this.userProvided}></div>

<!-- VULNERABLE, attacker may inject javascript: URLs
(and navigation to attacker-controlled sites is dangerous) -->
<a v-bind:href="userProvidedUrl"></a>

<script>
  // call in a render function
  h('div', {
    domProps: {
      innerHTML: this.userProvided // VULNERABLE
    }
  })
</script>

Remediation

If possible, do not render externally-controlled untrusted input using Vue non-escaping constructs. By default, HTML templates and render functions in Vue automatically escape content in HTML elements and attributes. Use {{ …​ }} interpolated expressions instead.

In particular, avoid untrusted content into v-bind:href and :onEVENT template directives. When rendering text using rendering functions, you should use the innerText DOM attribute, instead of innerHtml.

If using an external Vue component library, check how the data passed through input properties is rendered: libraries using v-html or even document.write() with input properties are unsafe.

<!-- SAFE -->
<div>{{ userProvided }}</div>

<!-- SAFE (JSX syntax inside JavaScript render functions) -->
<div>{this.userProvided}</div>

<!-- SAFE if user may chose between a set of allowed fixed URLs -->
<a href="whiteListedUrl" v-if="cond_1">...</a>
<a href="whiteListedUrl2" v-else-if="cond_2">...</a>
...
<a href="whiteListedUrlN" v-else>...</a>

<script>
  // call in a render function
  h('div', {
    domProps: {
      innerText: this.userProvided // SAFE
    }
    // Also SAFE when written as child node
    this.userProvided
  })
</script>

Configuration

Property Default Meaning

trust_settings

true

A value of the Go backend’s configuration (read from a settings file, a command-line flag or the environment) is safe in a URL and in markup. Set to false to treat it like any other value the backend writes.

References