Bujidao ff8b1c4b37 feat(rules): add Vue architecture patterns and security rules
Add rules/vue/patterns.md:
- Presentational vs Container component design
- Provide/Inject, Scoped Slots, Teleport (with 3.5+ defer prop)
- State management decision tree and Pinia Setup Store patterns
- Vue Router navigation guards, lazy loading, reactive route params
- v-for/v-if patterns, v-model (Vue 3.4+ defineModel)
- Scoped CSS (:deep, :slotted), KeepAlive with max, Dynamic Components
- Vue 3.5+ new APIs: useId(), data-allow-mismatch, Suspense
- Nuxt-specific patterns and Vue 2 migration notes

Add rules/vue/security.md:
- v-html XSS audit (DOMPurify sanitization checklist)
- Unsafe URL binding validation (javascript:/data: scheme prevention)
- Custom directive innerHTML injection
- Secret exposure via VITE_ prefix and Nuxt runtimeConfig
- Nuxt Nitro server API input validation with zod
- localStorage/sessionStorage token risks, SSR browser API guards
- target=_blank rel=noopener, CSP minimum policy
- Prototype pollution, source maps in production
- Vue 3.5+ SSR hydration mismatch security notes
2026-06-12 17:53:49 +08:00

6.9 KiB

paths
paths
**/*.vue
**/components/**/*.ts
**/composables/**/*.ts
**/pages/**/*.vue
**/server/**/*.ts

Vue Security

This file extends typescript/security.md and common/security.md with Vue-specific security rules.

XSS via v-html

CRITICAL. v-html sets innerHTML directly — Vue deliberately named it to look dangerous.

<!-- CRITICAL: unsanitized user input -->
<div v-html="userBio" />

<!-- CORRECT: render as text -->
<div>{{ userBio }}</div>

<!-- CORRECT: sanitize first -->
<script setup>
import DOMPurify from "dompurify";
const sanitizedBio = computed(() => DOMPurify.sanitize(userBio.value));
</script>
<template>
  <div v-html="sanitizedBio" />
</template>

Audit checklist for every v-html usage:

  • Is the input always under our control? Document the source.
  • If user-derived: is it sanitized at the same call site?
  • Is the sanitizer allowlisting tags, not denylisting?
  • Consider eslint-plugin-vue rule vue/no-v-html to flag all usages.

Unsafe URL Bindings

<!-- CRITICAL: unsafe URL from user input -->
<a :href="user.website">Visit</a>
<iframe :src="user.providedUrl" />

<!-- CORRECT: validate scheme -->
<script setup lang="ts">
function safeUrl(url: string): string | undefined {
  try {
    const parsed = new URL(url);
    return ["http:", "https:", "mailto:"].includes(parsed.protocol) ? url : undefined;
  } catch {
    return undefined;
  }
}
</script>
<template>
  <a v-if="safeUrl(user.website)" :href="safeUrl(user.website)">Visit</a>
</template>

Template Injection via Interpolation

Vue template interpolation ({{ }}) automatically escapes HTML entities — this is safe. The risk is v-html (covered above) and any custom directive that manipulates innerHTML directly.

// Suspicious: custom directive manipulating innerHTML
app.directive("render-html", (el, binding) => {
  el.innerHTML = binding.value; // Same risk as v-html
});

Secret Exposure via Environment Variables

Framework Public prefix Private
Vite VITE_* Others
Nuxt public in runtimeConfig Server-side only
Vue CLI VUE_APP_* Others
Custom (import.meta.env) Any exposed via Vite define Not configured
// CRITICAL: secret leaked to client bundle (Vite)
const apiKey = import.meta.env.VITE_STRIPE_SECRET; // ❌ VITE_ prefix = public

// CORRECT: server-side only
// vite.config.ts — never pass VITE_ prefixed secrets

Nuxt Runtime Config

// nuxt.config.ts
export default defineNuxtConfig({
  runtimeConfig: {
    // Server-side only — never exposed to client
    stripeSecret: "",

    // Public — exposed to client, treat as public
    public: {
      apiBase: "https://api.example.com",
      // NEVER put secrets here
    },
  },
});

SSR Hydration Mismatch (Vue 3.5+)

If server and client render different values for the same DOM node (e.g., locale-dependent date formatting), use data-allow-mismatch to suppress the warning rather than suppressing legitimate differences:

<span data-allow-mismatch="text">{{ date.toLocaleString() }}</span>

Do NOT use data-allow-mismatch to hide real security issues like missing auth checks or mismatched auth state.

Server API Input Validation (Nuxt Nitro)

// server/api/users/[id].ts
import { z } from "zod";

const paramsSchema = z.object({
  id: z.string().uuid(),
});

export default defineEventHandler(async (event) => {
  // Validate route params
  const { id } = await getValidatedRouterParams(event, paramsSchema.parse);

  // Validate query
  const query = await getValidatedQuery(event, z.object({
    include: z.string().optional(),
  }).parse);

  // Validate body (for POST/PUT)
  const body = await readValidatedBody(event, z.object({
    name: z.string().min(1).max(100),
    email: z.string().email(),
  }).safeParse);

  if (!body.success) {
    throw createError({ statusCode: 400, message: body.error.message });
  }

  // ... proceed with validated data
});
  • Never trust event.node.req raw properties — use Nitro's getValidatedRouterParams, readValidatedBody, getValidatedQuery.
  • Server routes with write operations must validate authentication and authorization.
  • Rate-limit sensitive endpoints.

localStorage / sessionStorage

// CRITICAL: session tokens in localStorage
localStorage.setItem("token", jwt); // ❌ any XSS can read this

// CORRECT: httpOnly cookie set by server
// Client never touches the token directly.

In SSR (Nuxt), localStorage does not exist on the server — accessing it unconditionally crashes.

// CORRECT: guard browser-only APIs
if (import.meta.client) {
  const theme = localStorage.getItem("theme");
}

target="_blank"

<!-- WRONG -->
<a :href="externalUrl" target="_blank">External</a>

<!-- CORRECT -->
<a :href="externalUrl" target="_blank" rel="noopener noreferrer">External</a>

Modern browsers default to noopener, but explicit is safer.

Third-Party Vue Libraries

  • Audit npm audit before adding any UI library.
  • Check that component libraries do not internally use v-html or innerHTML on user input.
  • Pin versions, review changelogs before major upgrades.
  • Be wary of rich-text/WYSIWYG editor components — they must sanitize HTML input.

Content Security Policy (CSP)

Minimum acceptable CSP for a Vue SPA:

default-src 'self';
script-src 'self' 'nonce-{REQUEST_NONCE}';
style-src 'self' 'unsafe-inline';
img-src 'self' data: https:;
connect-src 'self' https://api.example.com;
frame-ancestors 'none';
  • For SSR (Nuxt), use per-request nonces via useHead / useServerHead.
  • Avoid 'unsafe-eval' — Vue does not need it (unlike older Angular).
  • style-src 'unsafe-inline' is often required for <style> in SFCs and CSS-in-JS.

Authentication & Authorization

  • Never store session tokens in localStorage / sessionStorage.
  • Route guards (beforeEach) are UI gating only — every API endpoint must independently authorize.
  • Pinia stores that cache user roles/permissions must invalidate on logout.

Prototype Pollution

// WRONG: spreading untrusted data
const update = await req.json();
Object.assign(state, update); // attacker controls keys

// CORRECT: whitelist keys
const allowed = ["name", "email"];
const safe: Record<string, unknown> = {};
for (const key of allowed) {
  if (key in update) safe[key] = update[key];
}
Object.assign(state, safe);

Source Maps in Production

Production Vite builds should not ship source maps, or upload them to an error tracker and strip from public bundles.

// vite.config.ts
export default defineConfig({
  build: {
    sourcemap: process.env.NODE_ENV === "development",
  },
});

Agent Support

  • Use security-reviewer agent for comprehensive security audits across the codebase.
  • Use vue-reviewer agent for Vue-specific patterns and the above rules in active code review.