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

251 lines
6.9 KiB
Markdown

---
paths:
- "**/*.vue"
- "**/components/**/*.ts"
- "**/composables/**/*.ts"
- "**/pages/**/*.vue"
- "**/server/**/*.ts"
---
# Vue Security
> This file extends [typescript/security.md](../typescript/security.md) and [common/security.md](../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.
```vue
<!-- 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
```vue
<!-- 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.
```ts
// 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 |
```ts
// 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
```ts
// 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:
```vue
<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)
```ts
// 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`
```ts
// 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.
```ts
// CORRECT: guard browser-only APIs
if (import.meta.client) {
const theme = localStorage.getItem("theme");
}
```
## `target="_blank"`
```vue
<!-- 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
```ts
// 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.
```ts
// 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.