Web security

Server-side risks in Nitro routes

Injection and parameterised queries, SSRF through user-supplied URLs and image proxies, path traversal, prototype pollution, ReDoS, open redirects, file uploads, secrets and configuration, error handling that does not leak, logging without PII, timing-safe comparisons, webhooks, and denial-of-service by unbounded input.

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.

Know

  • Injection: never build SQL by concatenation; use parameterised queries or an ORM's query builder (Drizzle, Prisma, Kysely) and keep raw SQL fragments free of user input (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).
  • SSRF: any server-side fetch of a user-supplied URL (link previews, image proxies, webhooks, import-from-URL) can reach internal services and cloud metadata endpoints. Defences: allow-list hosts, block private and link-local ranges after DNS resolution, disable redirects or re-validate the redirect target, short timeouts, and for image proxying use @nuxt/image with an explicit domains allow-list rather than fetching arbitrary URLs.
  • Path traversal: 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.
  • Prototype pollution: deep-merging user JSON (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.
  • ReDoS: a regex with nested quantifiers on user input can take seconds per request; avoid ambiguous patterns, use RegExp.escape (ES2025) for user text in patterns, prefer linear-time libraries (re2), and bound input length before matching.
  • Open redirects: 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 uploads: limit size at the proxy and in Nitro (body size limits), validate type by magic bytes (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.
  • Secrets and configuration: secrets live in 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.
  • Errors and information leaks: production Nitro strips stack traces, but 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.
  • Logging and privacy: structured logs with request ids, no tokens, passwords, cookies or full PII; audit events for authentication and permission changes; retention limits; GDPR applies to logs too.
  • Timing and comparison: compare tokens and signatures with crypto.timingSafeEqual (Node) or a constant-time helper, not ===; verify webhook signatures (HMAC over the raw body) and reject old timestamps to prevent replay.
  • Denial of service by input: bound everything: body size, array lengths (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).
  • Framework and platform: keep Nuxt, Nitro and h3 current (security releases shipped on 4.4 and 4.5), pin Node to a supported LTS, purge CDN caches after security deploys, and read event.context set by middleware rather than re-trusting headers.

How it works

Three routes that show the fixes rather than the bugs:

server/api/preview.get.ts
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
})
server/api/files/[...path].get.ts
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))
})
server/api/uploads.post.ts
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:

server/utils/safe.ts
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)
}
Gotcha· Validating the hostname but following redirects

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.

Gotcha· Trusting Content-Type on uploads

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.

Verify before the interview:

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.

docs ↗

Exercise

Exercise
  • Write a naive /api/preview?url= proxy and point it at http://localhost:3111/api/_hidden; then apply the allow-list, DNS check and redirect: 'error'.
  • Deep-merge a user JSON body into a settings object with a hand-written recursive merge; send {"__proto__":{"isAdmin":true}} and inspect ({}).isAdmin; fix with schema validation.
  • Upload an HTML file renamed to .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.

Be able to say

Be able to say· What do you check when reviewing a Nitro server route for security?

"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."

Your notes

Open in Notes

Nothing yet. Notes filed here show up on this page and the section overview.