Hardening the Next.js Image Optimizer Against SSRF
A wildcard hostname in remotePatterns turns /_next/image into an open server-side fetch proxy. The close-out pattern: local-only, one explicit host, a negative test.
TL;DR
KotaPortal's Next.js image config used a wildcard hostname in remotePatterns, turning the /_next/image endpoint into a potential SSRF vector since it fetches any HTTPS URL server-side. The fix removed the wildcard, allowing only same-origin images plus one explicit Google avatar host in both remotePatterns and CSP. A negative end-to-end test now verifies unlisted hosts get rejected.
A security review of KotaPortal, a regional tourism portal, flagged one line in the Next.js config: a double-asterisk hostname inside images.remotePatterns, paired with a Content Security Policy image rule that allowed the https scheme globally. The first guess was that this was harmless because the browser would block external image requests anyway.
That assumption ignored what the endpoint actually does. The built-in /_next/image route accepts any HTTPS URL in its query parameter and fetches it server-side. Next.js documents remotePatterns as the mechanism to "allow images from specific external paths and block all others" [1]. Omitting protocol, port, pathname, or search implies the ** wildcard, which the docs explicitly call not recommended "because it may allow malicious actors to optimize urls you did not intend" [1]. Matching is exact and case-sensitive per URL part, and an unmatched host responds with 400 Bad Request [2]; the same rules apply in both router variants' documentation [6]; the wildcard disables that defense entirely. OWASP classifies this exposure as Server-Side Request Forgery: abusing server functionality to read or update internal resources by choosing target URLs, such as cloud metadata services or internal-only HTTP interfaces [3].
Step 1: Remove the Wildcard, Go Local-Only
Because every content image in KotaPortal was already same-origin (paths under uploads and branding), the whole remotePatterns block was deleted, and the optimizer then accepts local paths only. The CSP img-src directive, which defines valid sources for images and favicons, was narrowed to self, data, and blob in the same change [4]. Two allowlists, one decision: leaving the https scheme in the CSP would have kept the browser side just as permissive as before.
Step 2: One Explicit Host for the OAuth Avatars
The narrowing broke Google login immediately. User avatars come from the OIDC userinfo picture claim, hosted at lh3.googleusercontent.com [5], and render through a raw img tag. A raw tag never consults the optimizer host list, but it is still bound by CSP, so the avatar request died at the browser.
The fix re-opened exactly one host, explicitly, in both places:
The fix re-opened exactly one host, explicitly, in both places. The remotePatterns list gained a single entry for that hostname over https, and the CSP img-src list received the same host. A comment in the config states the rule: any new remote host must be added explicitly to both lists, never as a wildcard.
The CSP img-src list received the same host. A comment in the config states the rule: any new remote host must be added explicitly to both lists, never as a wildcard.
Step 3: Pin It with a Negative Test
An end-to-end test now guards the endpoint. It requests an image from a host outside the allowlist and expects anything but 200:
curl -s -o /dev/null -w "%{http_code}" \
"https://example.com/_next/image?url=https%3A%2F%2Fevil.example%2Fx.png&w=384&q=75"
# expected output: 400 (any status other than 200)
If that request ever returns 200, the optimizer has quietly become an open proxy again. Hardening an image optimizer this way only lasts when the behavior is locked by a test, not by the good intentions of a config file.
Sources