Uncovering and Exploiting a Client-Side Template Injection in Vue.js

Vue.js is a popular open-source JavaScript framework used for building user interfaces and single-page applications. It was created by Evan You and released in 2014. Vue.js is known for its simplicity, flexibility, and ease of integration with other libraries and projects.

Vue.js has gained significant popularity in the web development community due to its simplicity, performance, and extensive documentation. It’s often compared to other front-end frameworks like React.js and Angular.js, offering it’s own unique features and advantages.

As with any software framework or library Vue.js may have vulnerabilities that could potentially be exploited by attackers. Some common types of vulnerabilities associated with Vue.js applications include:

  • Cross-Site Scripting (XSS): XSS vulnerabilities can occur if developers do not properly sanitize user input or output. Attackers may inject malicious scripts into the application, which can then be executed in the context of other users’ browsers, potentially compromising sensitive data or performing unauthorized actions.
  • Component Security: Vue.js applications often rely on third-party components and libraries, which may introduce vulnerabilities if they are not regularly updated or if they contain security flaws. Developers should ensure that they use reputable and up-to-date components to minimize the risk of vulnerabilities.
  • Sensitive Data Exposure: Vue.js applications may expose sensitive data if developers do not properly secure data transmission or storage. Attackers may intercept network traffic or exploit vulnerabilities to gain access to sensitive information such as user credentials or personal data.

In this post we will analyze and understand common mistakes and misconfigurations that lead to applications being vulnerable to Cross-Site Scripting (XSS) in Vue.js.

Let’s start with a simple example of a Vue.js application that displays a form to search for data that is reflected in the URL and to the page.

 

Let’s take a look at the code and get an understanding of what is going on.

  • This HTML structure sets up a basic web page layout.
  • The Vue.js and Vue Router libraries are imported using script tags from CDN (Content Delivery Network) sources.
  • Bootstrap CSS is included to style the components again from a CDN.
  • The main content is wrapped in a div with the id app, which is the root Vue instance.

The important part about this HTML is on line 25. <p v-html="searchQuery"></p>. We will come back to this. Let’s keep moving.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Search App</title>
  <script src="https://unpkg.com/vue@2/dist/vue.js"></script>
  <script src="https://unpkg.com/vue-router@3/dist/vue-router.js"></script>
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.4.1/css/bootstrap.min.css">
  <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.7.1/jquery.min.js"></script>
  <script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.4.1/js/bootstrap.min.js"></script>
</head>

<body>
  <div id="app" class="container">
    <h2>Search Form</h2>
    <div class="form-group">
      <form action="#">
        <label for="search">Search: </label>
        <input id="search" name="search" type="text" v-model="searchQuery" @input="updateUrl" placeholder="Type something...">
      </form>
    </div>
    <strong>Results:</strong>
    <p v-html="searchQuery"></p>
  </div>

The next and final snippet of code we will analyze is the guts of the Vue.js code. Let’s also break this down.

  • A Vue component called SearchResults is defined. It receives a prop called searchQuery and renders a simple template displaying the search query.
  • Vue Router is configured with a single route /search, which renders the SearchResults component. The searchQuery prop is passed to the component based on the query parameter q from the URL. If the URL does not match any route, it redirects to /search.
  • A new Vue instance is created, bound to the #app element.
  • The instance is provided with the Vue Router instance router.
  • The data object initializes the searchQuery property to an empty string.
  • The updateUrl method updates the URL with the current search query whenever the input field changes.
  • In the created lifecycle hook, the code checks if there’s a q query parameter in the URL. If present, it initializes the searchQuery with its value.
<script>
    const SearchResults = {
      props: ['searchQuery'],
      template: `
        <div>
          <h2>Search Results</h2>
          <div>Results for:<p v-html="searchQuery"></p></div>
        </div>
      `
    };

    const router = new VueRouter({
      routes: [
        { path: '/search', component: SearchResults, props: (route) => ({ searchQuery: route.query.q }) },
        { path: '*', redirect: '/search' }
      ]
    });

    new Vue({
      el: '#app',
      router,
      data: {
        searchQuery: ''
      },
      methods: {
        updateUrl() {
          this.$router.replace({ query: { q: this.searchQuery }});
        }
      },
      created() {
        const queryParam = this.$route.query.q;
        if (queryParam !== undefined) {
          this.searchQuery = queryParam;
        }
      }
    });
  </script>
</body>
</html>

After breaking this down, what is the vulnerability and how can we exploit it?

As I stated earlier we need to review <p v-html="searchQuery"></p> and understand what makes this dangerous. Reviewing the documentation on the directive v-html reveals it will output data in HTML. The search form is taking user input and outputting it as HTML. 

Let’s abuse this and supply HTML to the search and see how it displays.

As we can see we have successfully exploited the search function and have HTML rendered on the page. Now we can try JavaScript with the payload <img src=x onerror=alert('xss')>.

Again we are successful at exploiting this flaw in the code. 

You might be wondering, “how prevalent is this issue?” Unfortunately, I must admit that it occurs far too frequently, despite the explicit warning message provided in the documentation on vuejs.org regarding its potential dangers.

How can we remediate this vulnerability? The proper method is simply wrapping the data in double mustaches or {{ }} to interpret the data as plain text and not HTML.

Let’s modified the code to use {{searchQuery}}.

<div id="app" class="container">
  <h2>Search Form</h2>
  <div class="form-group">
    <form action="#">
      <label for="search">Search: </label>
      <input id="search" name="search" type="text" v-model="searchQuery" @input="updateUrl" placeholder="Type something...">
    </form>
  </div>
  <strong>Results:</strong>
  <p>{{searchQuery}}</p>
</div>

<script>
  const SearchResults = {
    props: ['searchQuery'],
    template: `
      <div>
        <h2>Search Results</h2>
        <div>Results for:<p>{{searchQuery}}</p></div>
      </div>
    `
  };

As we can see in the results no HTML is rendered. Only the plain text value. 

This method of exploiting a common Vue.js mistake is both simple and impactful. Frequently, it stems from developers copying and pasting code snippets from sources like Stack Overflow or ChatGPT without fully understanding the risks. It underscores the importance of taking heed of warnings presented in documentation, as negligence in this regard can lead to significant vulnerabilities.
 
Here is the entire vulnerable code snippet if you would like to tinker with it. It’s all client-side.

If you found this helpful, please send me a tweet and tell me what you thought! Feedback is always appreciated!

Jarrod

 
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Search App</title>
  <script src="https://unpkg.com/vue@2/dist/vue.js"></script>
  <script src="https://unpkg.com/vue-router@3/dist/vue-router.js"></script>
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.4.1/css/bootstrap.min.css">
  <script src="https://ajax.googleapis.com/ajax/libs/jquery/3.7.1/jquery.min.js"></script>
  <script src="https://maxcdn.bootstrapcdn.com/bootstrap/3.4.1/js/bootstrap.min.js"></script>
</head>

<body>
  <div id="app" class="container">
    <h2>Search Form</h2>
    <div class="form-group">
      <form action="#">
        <label for="search">Search: </label>
        <input id="search" name="search" type="text" v-model="searchQuery" @input="updateUrl" placeholder="Type something...">
      </form>
    </div>
    <strong>Results:</strong>
    <p v-html="searchQuery"></p>
  </div>

  <script>
    const SearchResults = {
      props: ['searchQuery'],
      template: `
        <div>
          <h2>Search Results</h2>
          <div>Results for:<p v-html="searchQuery"></p></div>
        </div>
      `
    };

    const router = new VueRouter({
      routes: [
        { path: '/search', component: SearchResults, props: (route) => ({ searchQuery: route.query.q }) },
        { path: '*', redirect: '/search' }
      ]
    });

    new Vue({
      el: '#app',
      router,
      data: {
        searchQuery: ''
      },
      methods: {
        updateUrl() {
          this.$router.replace({ query: { q: this.searchQuery }});
        }
      },
      created() {
        const queryParam = this.$route.query.q;
        if (queryParam !== undefined) {
          this.searchQuery = queryParam;
        }
      }
    });
  </script>
</body>
</html>