Web security

XSS and output encoding

The three kinds of cross-site scripting, exactly what Vue escapes and what it does not, the sinks to watch — v-html, href and src schemes, style bindings, user templates, JSON in script tags, SVG uploads, postMessage — sanitisation with DOMPurify on both sides, Trusted Types, and how CSP limits the blast radius.

Cross-site scripting is the vulnerability a frontend engineer is personally responsible for, and the interview question is precise: "where in a Vue application can user input become code?" The answer is a short list of sinks, a rule for each, and a sanitiser for the one case where you must render HTML.

Know

  • Three kinds: stored (malicious content saved to the database and rendered for other users), reflected (input echoed straight back, typically from the URL), DOM-based (client code writes untrusted data into a dangerous sink). In an SPA most XSS is stored or DOM-based; in SSR, reflected XSS returns through the server render.
  • What Vue escapes: text interpolation ({{ }}) and attribute bindings (:title="userInput") are encoded for their context, so injected markup renders as text. Nuxt's payload is serialised with devalue and escapes < so a </script> in data cannot break out.
  • The sinks Vue cannot protect: v-html (raw HTML), URL attributes (:href, :src, :action, :formaction with javascript: or data: schemes), :style objects from user input (clickjacking via position: fixed, exfiltration via url() in some contexts), event handlers or :is from user strings, user-provided templates (compiling one is arbitrary code execution: the Vue guide's rule number one), innerHTML/insertAdjacentHTML/document.write in your own code, srcdoc on iframes, and eval-likes (new Function, setTimeout(string)).
  • Server-side sinks in SSR: anything interpolated into the HTML outside Vue (a Nitro plugin editing render:html, a custom useHead script with innerHTML built from data, JSON embedded in <script> without escaping </script> and <!--), useHeadSafe exists for head data from untrusted sources.
  • Sanitise HTML you must render: DOMPurify with an explicit allow-list of tags and attributes, run on the server (isomorphic-dompurify) and on the client for defence in depth, or at write time into the database; strip on* attributes and javascript:/data: URLs; forbid style, svg, math unless required (SVG carries scripts). Markdown from users is rendered to HTML and then sanitised; this app renders Markdown from its own trusted YAML, which is why it can skip that step.
  • URLs: allow-list schemes (https:, http:, mailto:, relative paths) before binding to href; new URL(input, base) to parse and check protocol; rel="noopener" is implicit for target="_blank" in modern browsers, add noreferrer when the referrer matters.
  • Trusted Types: a CSP (require-trusted-types-for 'script') that makes dangerous DOM sinks accept only typed values created by an approved policy; Vue 3.5 supports it, so v-html and third-party scripts must go through a policy that sanitises. The Sanitizer API (setHTML()) is the browser-native sanitiser but is still maturing (verify).
  • Uploads that execute: SVG files can contain <script>; serve user uploads from a separate origin, with Content-Disposition: attachment or X-Content-Type-Options: nosniff, or re-encode images with a library so metadata and scripts are gone.
  • postMessage listeners must check event.origin and validate the payload; BroadcastChannel data is same-origin but still untrusted content.
  • CSP is the second line: with a nonce-based script policy, an injected <script> does not run even when XSS slips through; object-src 'none' and base-uri 'self' close the remaining tricks. The next page covers it.
  • Review heuristics: grep for v-html, innerHTML, dangerously, javascript:, :href=" with variables, new Function, eval, and any place that compiles templates; each hit needs a justification comment and a sanitiser.

How it works

Rendering user Markdown safely, on both sides:

shared/utils/sanitize.ts
import DOMPurify from 'isomorphic-dompurify'

const ALLOWED_TAGS = ['p', 'br', 'b', 'strong', 'i', 'em', 'code', 'pre', 'a', 'ul', 'ol', 'li', 'blockquote', 'h3', 'h4']
const ALLOWED_ATTR = ['href', 'title']

export function sanitizeHtml(dirty: string) {
  return DOMPurify.sanitize(dirty, {
    ALLOWED_TAGS,
    ALLOWED_ATTR,
    ALLOWED_URI_REGEXP: /^(?:https?:|mailto:|\/(?!\/))/i, // no javascript:, data:, or protocol-relative URLs
    FORBID_TAGS: ['style', 'svg', 'math'],
  })
}
server/api/comments.post.ts
export default defineEventHandler(async (event) => {
  const { markdown } = await readValidatedBody(event, z.object({ markdown: z.string().max(5_000) }).parse)
  const html = sanitizeHtml(await renderMarkdown(markdown)) // sanitise at write time, after rendering
  return useDb().comments.create({ html })
})
app/components/Comment/Body.vue
<script setup lang="ts">
const props = defineProps<{ html: string }>()
// Defence in depth: sanitise again on the client in case the stored HTML predates the current allow-list.
const safe = computed(() => sanitizeHtml(props.html))
</script>

<template>
  <div class="prose" v-html="safe" />
</template>

Safe URL binding and a safe style binding:

app/components/Profile/Link.vue
<script setup lang="ts">
const props = defineProps<{ url: string, accent?: string }>()

const safeHref = computed(() => {
  try {
    const u = new URL(props.url, 'https://placeholder.invalid')
    return ['https:', 'http:', 'mailto:'].includes(u.protocol) ? props.url : undefined
  }
  catch { return undefined }
})

// Only the properties we allow, never a user-controlled style string or object.
const safeStyle = computed(() => (props.accent && /^#[0-9a-f]{6}$/i.test(props.accent) ? { borderColor: props.accent } : undefined))
</script>

<template>
  <a v-if="safeHref" :href="safeHref" :style="safeStyle" rel="noopener noreferrer" target="_blank">{{ url }}</a>
  <span v-else>{{ url }}</span>
</template>

Embedding JSON for a third-party script without breaking out of the tag, and a postMessage listener done right:

server/plugins/analytics-config.ts
export default defineNitroPlugin((nitroApp) => {
  nitroApp.hooks.hook('render:html', (html, { event }) => {
    const config = { tenant: event.context.tenant?.id ?? null }
    // Escape sequences that could terminate the script block or start a comment.
    const json = JSON.stringify(config).replace(/</g, '\\u003c').replace(/-->/g, '--\\>')
    html.head.push(`<script type="application/json" id="analytics-config">${json}</script>`)
  })
})
app/plugins/embed-bridge.client.ts
export default defineNuxtPlugin(() => {
  const ALLOWED = new Set(['https://widgets.example.com'])
  window.addEventListener('message', (e) => {
    if (!ALLOWED.has(e.origin)) return                     // origin check first
    const result = messageSchema.safeParse(e.data)          // then validate the payload
    if (result.success) handle(result.data)
  })
})
Gotcha· Sanitising with a regex or a deny-list

Stripping <script> tags misses <img onerror>, <svg><script>, javascript: in href, and encoding tricks. Use an allow-list sanitiser maintained by people who track browser parsing quirks, and keep it updated; DOMPurify is the reference.

Gotcha· Escaping into the wrong context

HTML-escaping a value that ends up inside a <script> block, a URL, or a CSS url() does not protect it: each context has its own encoding. Vue handles the HTML and attribute contexts; JSON-in-script needs \u003c escaping, URLs need scheme allow-lists, CSS needs no user input at all.

Verify before the interview:

The status of the Sanitizer API (Element.setHTML()) and Trusted Types support across browsers, and Vue's Trusted Types integration notes, as of the interview week.

docs ↗

Exercise

Exercise
  • Create a comment form that stores raw Markdown and renders it with v-html without sanitising; post <img src=x onerror=alert(1)> and a [link](javascript:alert(1)); then add sanitizeHtml on both sides and confirm both are neutralised.
  • Bind a user-provided :style object to a link and use position: fixed; inset: 0; opacity: 0 to cover the page; then restrict to an allow-listed property.
  • Add a nonce-based CSP in report-only mode and see which of your own inline scripts would have been blocked.

Be able to say

Be able to say· Where can XSS happen in a Vue application, and what do you do about it?

"Not in interpolation or attribute bindings, which Vue encodes for their context. It happens at the sinks Vue cannot guard: v-html, URL attributes that accept javascript: or data: schemes, user-controlled style bindings, anything that compiles a user-provided template, our own innerHTML or head scripts built from data, uploaded SVGs, and postMessage handlers without origin checks. For each I have a rule: HTML that must be rendered goes through DOMPurify with an allow-list on the server and again on the client, URLs are parsed and scheme-checked before binding, styles are limited to specific properties, templates are never user-provided, JSON embedded in script tags escapes <, uploads are served from another origin with nosniff, and postMessage checks event.origin first. Then a nonce-based CSP with object-src 'none' and base-uri 'self' makes any XSS that still slips through fail to execute."

Your notes

Open in Notes

Nothing yet. Notes filed here show up on this page and the section overview.