The moment a Nuxt app has a server/ directory it inherits every classic backend vulnerability. Interviewers for fullstack roles walk through a route and ask "what is wrong here", and the list of things to look for is finite: untrusted input reaching a query, a URL, a path, a shell, a deep merge, a regex, or a redirect; secrets and errors leaking out; input without bounds.
sql tagged templates parameterise). NoSQL operator injection ({ password: { $gt: '' } }) is defeated by validating types with a schema. Shell commands use execFile with argument arrays, never exec with strings. Template engines and log lines are also injection targets (log injection: sanitise newlines, log structured JSON).@nuxt/image with an explicit domains allow-list rather than fetching arbitrary URLs.sendStream(createReadStream(join(root, userPath))) with ../ escapes the root; normalise with path.resolve, verify the result starts with the root plus separator, reject .. and null bytes, and prefer serving by id from a map rather than by path.defu, lodash merge, hand-written recursive merges) with __proto__/constructor/prototype keys poisons every object; validate with a schema that strips unknown keys, merge only known fields, or use Object.create(null) maps. JSON.parse alone is safe; the merge is the sink.RegExp.escape (ES2025) for user text in patterns, prefer linear-time libraries (re2), and bound input length before matching.navigateTo(query.redirect) or sendRedirect(event, query.next) with an absolute URL sends users to a phishing site after login; accept only relative paths (/ followed by a non-/), or map to an allow-list. Nuxt's navigateTo requires external: true for off-site URLs, which helps.file-type), not by extension or Content-Type; rename to random names; store outside the web root or in object storage (R2/S3) via presigned URLs; serve with Content-Disposition: attachment and nosniff from a separate origin; re-encode images with sharp to strip metadata and payloads; treat SVG as executable content; scan if the business requires it.runtimeConfig (private half) and arrive through NUXT_* environment variables at runtime, never under public, never in .env committed to git, never in client bundles; use a secret manager in production; rotate on suspicion; enable secret scanning in the repository; distinct keys per environment.statusText and data reach the client, so put nothing sensitive there; return 404 rather than 403 when existence is sensitive; do not echo raw database errors; OWASP 2025's Mishandling of Exceptional Conditions covers fail-open logic (a caught error that skips an auth check) and inconsistent error handling.crypto.timingSafeEqual (Node) or a constant-time helper, not ===; verify webhook signatures (HMAC over the raw body) and reject old timestamps to prevent replay.z.array().max(100)), pagination limits (limit <= 100), string lengths, query complexity (GraphQL depth), outbound fetch timeouts (AbortSignal.timeout), and rate limits per IP and per user (nuxt-security rate limiter, Cloudflare rules, rate-limiter-flexible).event.context set by middleware rather than re-trusting headers.Three routes that show the fixes rather than the bugs:
import { z } from 'zod'
import { isIP } from 'node:net'
import { lookup } from 'node:dns/promises'
const query = z.object({ url: z.string().url().max(2048) })
const ALLOWED_HOSTS = new Set(['cdn.example.com', 'images.example.com'])
function isPrivate(ip: string) {
return /^(10\.|127\.|169\.254\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|0\.|::1$|fc|fd|fe80)/i.test(ip)
}
export default defineEventHandler(async (event) => {
const { url } = await getValidatedQuery(event, query.parse)
const target = new URL(url)
if (target.protocol !== 'https:' || !ALLOWED_HOSTS.has(target.hostname)) throw createError({ status: 400, statusText: 'Host not allowed' })
const { address } = await lookup(target.hostname) // SSRF: resolve, then check the address, not just the name
if (!isIP(address) || isPrivate(address)) throw createError({ status: 400, statusText: 'Host not allowed' })
const res = await fetch(target, { redirect: 'error', signal: AbortSignal.timeout(3_000) }) // no redirects into private space, bounded time
if (!res.ok || !res.headers.get('content-type')?.startsWith('image/')) throw createError({ status: 502, statusText: 'Bad upstream' })
setResponseHeaders(event, { 'content-type': res.headers.get('content-type')!, 'x-content-type-options': 'nosniff', 'cache-control': 'public, max-age=3600' })
return res.body
})
import { resolve, sep } from 'node:path'
import { createReadStream } from 'node:fs'
import { stat } from 'node:fs/promises'
const ROOT = resolve(process.cwd(), 'storage/public')
export default defineEventHandler(async (event) => {
const requested = (getRouterParam(event, 'path') ?? '').replace(/\0/g, '')
const full = resolve(ROOT, requested)
if (!full.startsWith(ROOT + sep)) throw createError({ status: 404, statusText: 'Not found' }) // traversal blocked
const info = await stat(full).catch(() => null)
if (!info?.isFile()) throw createError({ status: 404, statusText: 'Not found' })
setResponseHeaders(event, { 'content-disposition': 'attachment', 'x-content-type-options': 'nosniff' })
return sendStream(event, createReadStream(full))
})
import { fileTypeFromBuffer } from 'file-type'
import sharp from 'sharp'
const MAX_BYTES = 5 * 1024 * 1024
export default defineEventHandler(async (event) => {
const { user } = await requireUserSession(event)
const form = await readMultipartFormData(event)
const file = form?.find(f => f.name === 'avatar')
if (!file || file.data.byteLength > MAX_BYTES) throw createError({ status: 413, statusText: 'File too large' })
const type = await fileTypeFromBuffer(file.data) // magic bytes, not the claimed content type
if (!type || !['image/png', 'image/jpeg', 'image/webp'].includes(type.mime)) throw createError({ status: 415, statusText: 'Unsupported type' })
const safe = await sharp(file.data).rotate().resize(512, 512, { fit: 'cover' }).webp({ quality: 85 }).toBuffer() // re-encode: strips metadata and payloads
const key = `avatars/${user.id}/${crypto.randomUUID()}.webp` // random name, user-scoped prefix
await useStorage('uploads').setItemRaw(key, safe) // object storage, outside the web root
return { url: `https://files.example.com/${key}` } // served from a separate origin
})
Safe redirects and merges, and a constant-time webhook check:
export function safeRedirectPath(input: unknown, fallback = '/') {
return typeof input === 'string' && /^\/(?!\/)/.test(input) ? input : fallback // relative, not protocol-relative
}
export function mergeSettings(current: Settings, patch: unknown): Settings {
const changes = settingsPatchSchema.parse(patch) // strips unknown keys such as __proto__; only listed fields survive
return { ...current, ...changes }
}
export function signaturesMatch(a: string, b: string) {
const ab = Buffer.from(a), bb = Buffer.from(b)
return ab.length === bb.length && timingSafeEqual(ab, bb)
}
An allowed host can answer with a 302 to http://169.254.169.254/ (cloud metadata) and fetch follows it by default. Use redirect: 'error' or 'manual' and re-validate the target; the same applies to the resolved IP, which can change between check and use (DNS rebinding) in the most careful implementations.
The client sets it; a .jpg named PHP or HTML file with image/jpeg passes a header check and, served from your origin with sniffing, becomes an XSS or worse. Magic-byte detection, re-encoding, random names, a separate origin and nosniff each close a part of that.
The current rate-limiting and request-size options in nuxt-security and Nitro's body size limits, and whether readMultipartFormData remains the recommended h3 API for uploads in the h3 v2 era.
/api/preview?url= proxy and point it at http://localhost:3111/api/_hidden; then apply the allow-list, DNS check and redirect: 'error'.{"__proto__":{"isAdmin":true}} and inspect ({}).isAdmin; fix with schema validation..png with a spoofed content type; confirm the magic-byte check rejects it and that a real PNG is re-encoded to WebP without metadata."Where untrusted input goes. If it reaches a query, it must be parameterised or go through the ORM's builder. If it reaches a URL the server fetches, hosts are allow-listed, the resolved address is checked against private ranges, redirects are disabled and the fetch has a timeout. If it reaches a file path, it is resolved and checked to stay under the root. If it is merged into an object, a schema strips unknown keys so __proto__ cannot pollute. If it feeds a regex, the pattern is linear and the input bounded. If it is a redirect target, only relative paths pass. Uploads are bounded, type-checked by magic bytes, re-encoded, randomly named and served from another origin. Then the route itself: authenticated, authorised per object, validated with bounds, rate-limited where abuse is possible, secrets read from private runtime config, errors short and generic, logs structured and free of PII, and signatures compared in constant time."
Nothing yet. Notes filed here show up on this page and the section overview.
Authentication, sessions and CSRF
Server sessions versus stateless sealed cookies versus JWTs and where each breaks, OAuth 2.1 and OIDC with PKCE, passkeys, password hashing and login hardening, CSRF defences from SameSite to origin checks and tokens, and authorisation done on the server for every object.
Supply chain and dependency hygiene
What the 2025 npm attacks changed, OWASP's new supply-chain category, lockfiles and frozen installs, pnpm's release-age delay and blocked install scripts, provenance and trusted publishing, update automation with cooldowns, CI with least privilege, SBOMs, and how to keep Nuxt and its ecosystem patched.