Articles

Thoughts on software engineering and technology, published on Medium

Hardening a Next.js Portfolio with OWASP ZAP

What I learned scanning a Next.js 16 portfolio with ZAP — fixing real vulnerabilities, accepting framework tradeoffs, and separating scanner noise from actual risk. — - Introduction Security scanners are blunt instruments. They excel at surfacing missing headers, injection surfaces, and misconfigured endpoints — but they also flag patterns inherent to modern frameworks like Next.js. When I ran OWASP ZAP 2.17.0 against this portfolio site, the first scan surfaced eight findings across Medium, Low, and Informational severity. After a focused hardening pass, a follow-up scan dropped that to zero alerts. A third scan, with active probing enabled, settled on three Medium findings that reflect framework constraints more than exploitable flaws. This post walks through that process: what broke, what we fixed, and how to decide when a scanner result deserves action. The scanning setup ZAP was pointed at a production build served locally: npm run build && npm run start Scanning `next dev` produces misleading results — extra scripts, looser caching, and development-only behavior inflate noise. Always scan what you ship. The jump from zero to three Medium alerts on the active scan is expected. Passive scans check headers and page content; active scans fuzz forms, image endpoints, and path parameters. What the first scan caught Traditional ZAP HTML report The initial report flagged issues worth fixing immediately: - Missing Content-Security-Policy — no declared content allowlist - Missing anti-clickjacking headers — no `X-Frame-Options` or `frame-ancestors` - Missing Subresource Integrity — tech stack badges loaded from `img.shields.io` - `X-Powered-By` header leak` — exposed `Next.js` in responses - Missing `X-Content-Type-Options` — MIME sniffing not disabled - Sensitive data in URLs — query parameters could carry form field names - Timestamp disclosure and suspicious comments — noise from compiled React bundles. Most of these are straightforward HTTP hygiene. The compiled-bundle findings are typical false positives in Next.js apps and were not actionable. What we implemented Centralized security headers Headers are defined in `lib/security-headers.ts` and applied globally through `next.config.ts`: export const securityHeaders = [ { key: ‘X-Frame-Options’, value: ‘DENY’ }, { key: ‘X-Content-Type-Options’, value: ‘nosniff’ }, { key: ‘Strict-Transport-Security’, value: ‘max-age=63072000; includeSubDomains; preload’ }, { key: ‘Referrer-Policy’, value: ‘strict-origin-when-cross-origin’ }, { key: ‘Content-Security-Policy’, value: csp }, // … ]; `poweredByHeader: false` removes the `X-Powered-By: Next.js` response header. The CSP restricts scripts, styles, images, and network connections to trusted origins. `connect-src` explicitly allows ` https://api.emailjs.com ` for the contact form. `frame-ancestors ‘none’` prevents the site from being embedded in iframes. Middleware as a first line of defense `middleware.ts` handles three concerns before requests reach the application: 1. Sensitive query parameter stripping Parameters like `email`, `password`, `token`, and `message` are removed and the client is redirected to a clean URL. This prevents PII from lingering in browser history or leaking through `Referer` headers. 2. Image optimizer hardening ZAP’s active scan hammered `/_next/image?url=…` with SQL injection and path traversal payloads, producing hundreds of server errors. We added an allowlist in `lib/image-security.ts`: const ALLOWED_IMAGE_PATHS = new Set([‘/profile.jpeg’]); const ALLOWED_IMAGE_PREFIXES = [‘/images/’]; Unauthorized or malformed image URLs return `400` or `403` before Next.js processes them. The profile photo was switched from `next/image` to a static ` ` tag, and `images.unoptimized: true` further reduces the optimizer attack surface. 3. Path and probe blocking Requests containing traversal sequences (`..`), common probe targets (`/etc/passwd`, `/WEB-INF/`), or injection characters in the path are rejected with `400`. Probes using the `Next-Action` header — which this site does not use — return `404`. Self-hosted assets External shields.io badge images were replaced with styled `TechStackBadge` components. This eliminated a third-party CDN dependency and cleared Subresource Integrity warnings without maintaining per-URL hash values for dynamic badge URLs. The three findings that stayed After hardening, the active scan reported three Medium alerts: 1. Absence of Anti-CSRF tokens on the contact form 2. CSP: `script-src unsafe-inline` 3. CSP: `style-src unsafe-inline` These deserve context, not panic. CSRF on a client-side form The contact form submits through EmailJS in the browser . There is no server-side POST handler that mutates application state. A CSRF token would be validated nowhere meaningful — the entire submission flow is client-side. CSRF protection matters when a server endpoint changes state on behalf of an authenticated user. This form does not fit that model. Adding a token would satisfy the scanner without improving security. A proper fix — if this ever becomes a server-handled form — would be a `/api/contact` route with token validation and rate limiting. `unsafe-inline` in CSP Next.js injects inline scripts for hydration and framework initialization. Tailwind and React apply inline styles at runtime. Removing `unsafe-inline` requires nonce-based CSP : generating a unique nonce per request in middleware, injecting it into every script and style tag, and reflecting it in the CSP header. That is achievable but adds meaningful complexity to a static portfolio. The practical XSS risk is low when the site has no user-generated content and no injection sinks. A simple risk framework When a scanner flags something, I ask three questions: 1. Is this a real attack vector for this application? 2. What does fixing it cost in complexity and maintenance? 3. What is the likelihood of exploitation given the site’s threat model? If the answer to question one is no, document the finding and move on. Do not chase a clean report for its own sake. What to fix first on any Next.js site If you are hardening a similar project, prioritize in this order: 1. Security headers — CSP, HSTS, `X-Frame-Options`, `X-Content-Type-Options` 2. Remove information leaks — `X-Powered-By`, sensitive query params, debug error pages 3. Shrink attack surfaces — restrict `/_next/image`, block path traversal probes, self-host third-party assets 4. Then evaluate scanner noise — CSRF on client-only forms, `unsafe-inline` CSP, vendor-bundle comments Fix what is exploitable. Document what you accept and why. Conclusion A clean ZAP report is not the same as a secure application, and a noisy report is not the same as a vulnerable one. The goal is to eliminate real attack vectors — missing headers, open injection surfaces, leaky URLs — and make informed decisions about framework-level tradeoffs. For this portfolio, that meant going from eight findings and six server errors to three documented Medium alerts with zero HTTP 500 responses during active scanning. That is a posture I am comfortable shipping.

July 3, 2026 5 min read
programmingzed-attack-proxynextjsowasp-zed-attack-proxyinsights
Let's chat