mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-06-16 08:26:52 +08:00
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
This commit is contained in:
parent
57386e156d
commit
ff8b1c4b37
412
rules/vue/patterns.md
Normal file
412
rules/vue/patterns.md
Normal file
@ -0,0 +1,412 @@
|
|||||||
|
---
|
||||||
|
paths:
|
||||||
|
- "**/*.vue"
|
||||||
|
- "**/components/**/*.ts"
|
||||||
|
- "**/composables/**/*.ts"
|
||||||
|
- "**/stores/**/*.ts"
|
||||||
|
- "**/pages/**/*.vue"
|
||||||
|
---
|
||||||
|
# Vue Patterns
|
||||||
|
|
||||||
|
> This file extends [typescript/patterns.md](../typescript/patterns.md) and [common/patterns.md](../common/patterns.md) with Vue-specific architecture patterns. For composable rules see [hooks.md](./hooks.md).
|
||||||
|
|
||||||
|
## Component Design Principles
|
||||||
|
|
||||||
|
### Presentational vs Container
|
||||||
|
|
||||||
|
Split large views into container (data-fetching, state, orchestration) and presentational (props-in, events-out) components.
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<!-- Container: src/pages/UserList.vue -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
const { users, isLoading } = useUsers();
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<UserListSkeleton v-if="isLoading" />
|
||||||
|
<UserTable v-else :users="users" @select="handleSelect" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Presentational: src/components/UserTable.vue -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{ users: User[] }>();
|
||||||
|
const emit = defineEmits<{ select: [id: string] }>();
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div v-for="user in users" :key="user.id" @click="emit('select', user.id)">
|
||||||
|
{{ user.name }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Provide / Inject
|
||||||
|
|
||||||
|
Use for dependency injection (not state management). Ideal for: theme, locale, configuration, plugin API surfaces.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Provider — in a parent or plugin
|
||||||
|
const theme = ref<Theme>("light");
|
||||||
|
provide("theme", readonly(theme));
|
||||||
|
|
||||||
|
// Consumer — in any descendant
|
||||||
|
const theme = inject<Ref<Theme>>("theme");
|
||||||
|
```
|
||||||
|
|
||||||
|
- Always use `readonly()` when providing to prevent child mutations.
|
||||||
|
- Use `Symbol` keys for injection to avoid name collisions.
|
||||||
|
- Document the injection key type with a shared constant.
|
||||||
|
|
||||||
|
### Scoped Slots
|
||||||
|
|
||||||
|
Use scoped slots when a child component owns data but the parent controls rendering.
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<!-- Child -->
|
||||||
|
<template>
|
||||||
|
<ul>
|
||||||
|
<li v-for="item in items" :key="item.id">
|
||||||
|
<slot name="item" :item="item" :index="index" />
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Parent -->
|
||||||
|
<template>
|
||||||
|
<DataList :items="users">
|
||||||
|
<template #item="{ item, index }">
|
||||||
|
<UserCard :user="item" :rank="index + 1" />
|
||||||
|
</template>
|
||||||
|
</DataList>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
## State Management
|
||||||
|
|
||||||
|
### Decision Tree
|
||||||
|
|
||||||
|
1. **Component-local**: `ref()` / `reactive()` inside the component
|
||||||
|
2. **Shared between parent + few children**: Lift to parent, pass via props + emits
|
||||||
|
3. **Shared across distant branches, infrequent updates**: `provide` / `inject`
|
||||||
|
4. **Global, shared, complex**: Pinia store
|
||||||
|
5. **Server-derived data**: Composables wrapping `fetch` / `useFetch` (Nuxt) / TanStack Query (Vue Query)
|
||||||
|
|
||||||
|
### Pinia Patterns
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// stores/useUserStore.ts
|
||||||
|
import { defineStore } from "pinia";
|
||||||
|
import { ref, computed } from "vue";
|
||||||
|
import { getUser, updateUser } from "@/api/user";
|
||||||
|
|
||||||
|
export const useUserStore = defineStore("user", () => {
|
||||||
|
// State
|
||||||
|
const currentUser = ref<User | null>(null);
|
||||||
|
const isLoading = ref(false);
|
||||||
|
const error = ref<Error | null>(null);
|
||||||
|
|
||||||
|
// Getters (computed)
|
||||||
|
const isLoggedIn = computed(() => currentUser.value !== null);
|
||||||
|
const displayName = computed(() =>
|
||||||
|
currentUser.value ? currentUser.value.name : "Guest"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
async function fetchUser(id: string) {
|
||||||
|
isLoading.value = true;
|
||||||
|
error.value = null;
|
||||||
|
try {
|
||||||
|
currentUser.value = await getUser(id);
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e as Error;
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { currentUser, isLoading, error, isLoggedIn, displayName, fetchUser };
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- Prefer **Setup Store** syntax (Composition API) over Options Store.
|
||||||
|
- Store actions are the ONLY place to mutate state — no direct `store.$patch` in components for complex logic.
|
||||||
|
- Every async action must handle loading, success, and error states.
|
||||||
|
- Keep stores focused on one domain — split auth, user, cart, etc. into separate stores.
|
||||||
|
|
||||||
|
## Vue Router Patterns
|
||||||
|
|
||||||
|
### Navigation Guards
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Global guard
|
||||||
|
router.beforeEach((to, from) => {
|
||||||
|
const store = useUserStore();
|
||||||
|
if (to.meta.requiresAuth && !store.isLoggedIn) {
|
||||||
|
return { name: "login", query: { redirect: to.fullPath } };
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- Always provide a redirect path so the user returns to their intended destination after login.
|
||||||
|
- Route guards should not have side effects beyond navigation decisions.
|
||||||
|
- Use `beforeEnter` on routes for route-specific checks; `beforeEach` for global ones.
|
||||||
|
|
||||||
|
### Lazy Loading
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const routes = [
|
||||||
|
{
|
||||||
|
path: "/dashboard",
|
||||||
|
component: () => import("@/pages/Dashboard.vue"), // lazy
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/settings",
|
||||||
|
component: () => import("@/pages/Settings.vue"),
|
||||||
|
// Provide loading/error components
|
||||||
|
meta: {
|
||||||
|
__loadingComponent: LoadingSpinner,
|
||||||
|
__errorComponent: ErrorView,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
### Route Params inside Same Component
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
// WRONG: snapshot
|
||||||
|
const { id } = useRoute().params;
|
||||||
|
watch(id, fetchItem); // id is a plain string — doesn't change
|
||||||
|
|
||||||
|
// CORRECT: ref-wrapped
|
||||||
|
const route = useRoute();
|
||||||
|
const id = computed(() => route.params.id as string);
|
||||||
|
watch(id, fetchItem);
|
||||||
|
|
||||||
|
// ALSO CORRECT: watch the route
|
||||||
|
watch(() => route.params.id, fetchItem);
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
## List Rendering
|
||||||
|
|
||||||
|
### `v-for` with Stable Keys
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<!-- CORRECT: stable unique ID -->
|
||||||
|
<div v-for="item in items" :key="item.id">
|
||||||
|
{{ item.name }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- WRONG: index as key (breaks on reorder/insert/delete) -->
|
||||||
|
<div v-for="(item, index) in items" :key="index">
|
||||||
|
{{ item.name }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- WRONG: v-if + v-for on same element -->
|
||||||
|
<div v-for="item in items" v-if="item.active" :key="item.id">
|
||||||
|
<!-- v-if runs on item, but intention is to filter the list -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CORRECT: computed filtered list -->
|
||||||
|
<script setup>
|
||||||
|
const activeItems = computed(() => items.value.filter(i => i.active));
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<div v-for="item in activeItems" :key="item.id">{{ item.name }}</div>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Forms
|
||||||
|
|
||||||
|
### v-model Patterns
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
// Basic binding
|
||||||
|
const name = ref("");
|
||||||
|
|
||||||
|
// Multiple v-model (Vue 3.4+ defineModel)
|
||||||
|
const model = defineModel<string>();
|
||||||
|
const title2 = defineModel<string>("title");
|
||||||
|
|
||||||
|
// With validator/transformer
|
||||||
|
const price = defineModel<number>({ required: true });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<input v-model="name" />
|
||||||
|
<CustomInput v-model="model" v-model:title="title2" />
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Form Validation
|
||||||
|
|
||||||
|
For non-trivial forms, use a vetted library:
|
||||||
|
|
||||||
|
- **VeeValidate** — declarative validation rules, form-level context.
|
||||||
|
- **FormKit** — schema-based forms with built-in validation.
|
||||||
|
- **Custom with composable** — for simple cases only.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Anti-pattern: manual validation in component
|
||||||
|
const errors = ref<string[]>([]);
|
||||||
|
function submit() {
|
||||||
|
errors.value = [];
|
||||||
|
if (!email.value.includes("@")) errors.value.push("Invalid email");
|
||||||
|
// ... fragile, not reusable, no i18n
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Event Handling
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<!-- Prefer @submit.prevent on form element -->
|
||||||
|
<form @submit.prevent="handleSubmit">
|
||||||
|
<button type="submit">Save</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Key modifiers -->
|
||||||
|
<input @keyup.enter="submit" />
|
||||||
|
<input @keyup.esc="cancel" />
|
||||||
|
|
||||||
|
<!-- Event modifiers chaining -->
|
||||||
|
<a @click.prevent.stop="handleClick">Link</a>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Scoped CSS
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<style scoped>
|
||||||
|
/* Styles are scoped to this component via data-v-* attributes */
|
||||||
|
.card {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
```
|
||||||
|
|
||||||
|
- Always use `<style scoped>` for component styles — prevents leakage.
|
||||||
|
- For child component root element styling, use `:deep()` combinator.
|
||||||
|
- For slot content styling, use `:slotted()`.
|
||||||
|
- For global overrides, use a separate `<style>` block (no scoped) sparingly.
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<style scoped>
|
||||||
|
/* Target child root element */
|
||||||
|
.card :deep(.title) { font-size: 20px; }
|
||||||
|
|
||||||
|
/* Target slot content */
|
||||||
|
:slotted(p) { margin: 0; }
|
||||||
|
</style>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Teleport
|
||||||
|
|
||||||
|
Use `<Teleport>` for modals, tooltips, notifications — content that must escape parent overflow/z-index constraints.
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<Modal :show="isOpen" @close="isOpen = false">
|
||||||
|
<slot />
|
||||||
|
</Modal>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Vue 3.5+**: `<Teleport>` supports `defer` prop for deferred mounting. This allows teleporting to a target element that is rendered later in the same render cycle:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<!-- defer: target can appear after the Teleport in the DOM -->
|
||||||
|
<Teleport defer target="#container">
|
||||||
|
<p>Teleported content</p>
|
||||||
|
</Teleport>
|
||||||
|
<div id="container"></div>
|
||||||
|
```
|
||||||
|
|
||||||
|
## KeepAlive
|
||||||
|
|
||||||
|
Cache component state when toggling between views. Always set `:max` to control memory.
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<KeepAlive :max="10">
|
||||||
|
<component :is="currentTab" />
|
||||||
|
</KeepAlive>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
## `useId()` (Vue 3.5+)
|
||||||
|
|
||||||
|
Generate unique, SSR-stable IDs for form elements and accessibility attributes:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<script setup>
|
||||||
|
import { useId } from "vue";
|
||||||
|
const id = useId();
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<label :for="id">Name:</label>
|
||||||
|
<input :id="id" type="text" />
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
- IDs are unique per application instance and stable across server/client rendering.
|
||||||
|
- Prefer `useId()` over manual ID generation to avoid SSR hydration mismatches.
|
||||||
|
|
||||||
|
## `data-allow-mismatch` (Vue 3.5+)
|
||||||
|
|
||||||
|
Suppress unavoidable server/client value mismatch warnings:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<span data-allow-mismatch>{{ date.toLocaleString() }}</span>
|
||||||
|
<!-- Optional: restrict to specific mismatch types -->
|
||||||
|
<span data-allow-mismatch="text">{{ clientOnlyValue }}</span>
|
||||||
|
```
|
||||||
|
|
||||||
|
Allowed types: `text`, `children`, `class`, `style`, `attribute`.
|
||||||
|
|
||||||
|
## Suspense (Experimental / Vue 3.3+)
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<Suspense>
|
||||||
|
<AsyncDashboard />
|
||||||
|
<template #fallback>
|
||||||
|
<LoadingSkeleton />
|
||||||
|
</template>
|
||||||
|
</Suspense>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dynamic Components
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<component :is="stepComponent" v-bind="stepProps" @done="nextStep" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import Step1 from "./Step1.vue";
|
||||||
|
import Step2 from "./Step2.vue";
|
||||||
|
import Step3 from "./Step3.vue";
|
||||||
|
|
||||||
|
const stepComponent = computed(() => {
|
||||||
|
const steps = [Step1, Step2, Step3];
|
||||||
|
return steps[currentStep.value];
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Out of Scope (Pointer Sections)
|
||||||
|
|
||||||
|
### Nuxt-specific Patterns
|
||||||
|
|
||||||
|
Nuxt auto-imports, server routes, Nitro, modules, and build configuration are treated as a separate framework concern. When adding deep Nuxt-specific patterns, see `skills/nuxt4-patterns/` if present, or propose a dedicated `rules/nuxt/` track.
|
||||||
|
|
||||||
|
### Vue 2 / Migration
|
||||||
|
|
||||||
|
Options API, `Vue.extend`, `Vue.directive`, filters, and event bus patterns belong to migration documentation. New code should target Vue 3 Composition API.
|
||||||
|
|
||||||
|
## Skill Reference
|
||||||
|
|
||||||
|
For Vue deep dives see `skills/vue-patterns/SKILL.md`. For cross-framework frontend concerns see `skills/frontend-patterns/SKILL.md`. For accessibility see `skills/accessibility/SKILL.md`.
|
||||||
250
rules/vue/security.md
Normal file
250
rules/vue/security.md
Normal file
@ -0,0 +1,250 @@
|
|||||||
|
---
|
||||||
|
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.
|
||||||
Loading…
x
Reference in New Issue
Block a user