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.
{{ }}) 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.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)).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.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.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.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).<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.<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.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.Rendering user Markdown safely, on both sides:
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'],
})
}
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 })
})
<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:
<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:
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>`)
})
})
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)
})
})
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.
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.
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.
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.:style object to a link and use position: fixed; inset: 0; opacity: 0 to cover the page; then restrict to an allow-listed property."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."
Nothing yet. Notes filed here show up on this page and the section overview.
Web security
The threat model of a Nuxt application — where untrusted input enters, what the browser protects and what it does not, the OWASP Top 10 (2025) as vocabulary — and how the pages on XSS, CSP and headers, authentication and CSRF, server-side risks and the software supply chain fit together.
CSP and security headers
Content Security Policy in practice — directives, nonces versus hashes versus strict-dynamic, report-only rollout, how Nuxt's inline payload and scripts interact with it and what nuxt-security automates — plus HSTS, nosniff, Referrer-Policy, Permissions-Policy, frame-ancestors, the cross-origin isolation headers and subresource integrity.