nextjs - ✅(Solved) Fix Next.js 16 Image Optimization returns "url parameter is not allowed" for external domains despite correct remotePatterns configuration [3 pull requests, 6 comments, 7 participants]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…
GitHub stats
vercel/next.js#88873Fetched 2026-04-08 02:03:48
View on GitHub
Comments
6
Participants
7
Timeline
17
Reactions
8
Author
Timeline (top)
commented ×6subscribed ×4cross-referenced ×3labeled ×2

Fix Action

Fix / Workaround

  1. Observe 400 Bad Request with "url parameter is not allowed" for storage.googleapis.com
  2. Downgrade to Next.js 15.5.9 - same URL now works
  • The issue appears specific to certain hostnames (public cloud storage like storage.googleapis.com)
  • Custom domains (img.onelife.vn) work fine with identical remotePatterns config
  • This suggests Next.js 16 may have added hostname-based filtering/blocking for security that's too aggressive
  • Workaround: Currently using <img> tags instead of <Image> for storage.googleapis.com URLs, or proxying through img.onelife.vn

PR fix notes

PR #89536: Fix: Image component ignores images.qualities in Jest environment

Description (problem / solution / changelog)

  • Fixes #89492
  • Fixes #18415

Problem

When testing Next.js <Image /> components with Jest, custom images.qualities configured in next.config.js are ignored, causing:

  • Image URLs to use default quality (q=75) instead of configured values
  • Warning: "Image with src ... is using quality X which is not configured in images.qualities [75]"

Root Cause

In production and development, Next.js uses webpack's DefinePlugin to inject the image configuration via process.env.__NEXT_IMAGE_OPTS. However, Jest tests don't use webpack, so process.env.__NEXT_IMAGE_OPTS is undefined, causing the Image component to fall back to default configuration.

Solution

This PR implements a two-part fix:

  1. Jest globals population (packages/next/src/build/jest/jest.ts):

    • Expose nextConfig.images via Jest globals.__NEXT_IMAGE_OPTS
  2. Runtime fallback (image components):

    • When process.env.__NEXT_IMAGE_OPTS is undefined, fall back to globalThis.__NEXT_IMAGE_OPTS
    • Applied to:
      • packages/next/src/client/image-component.tsx
      • packages/next/src/shared/lib/image-external.tsx
      • packages/next/src/client/legacy/image.tsx

Changes

  • ✅ Minimal, targeted fix (4 files, ~40 lines)
  • ✅ No production/development behavior changes
  • ✅ No test-only conditionals or environment checks
  • ✅ Comprehensive test coverage added

Testing

Added test in test/production/app-dir/image-jest-qualities/ that:

  • Configures custom qualities [90, 100] in next.config.js
  • Renders <Image quality={100} /> in Jest with jsdom
  • Asserts generated image URLs contain q=100 not q=75

Test correctly fails on canary (shows warning about unconfigured quality) and passes with fix.

Verification

  • ✅ New test passes with fix
  • ✅ Existing image tests unaffected
  • ✅ Addresses original issue reproduction exactly

Changed files

  • packages/next/src/build/jest/jest.ts (modified, +32/-2)
  • packages/next/src/build/swc/jest-transformer.ts (modified, +2/-0)
  • packages/next/src/build/swc/options.ts (modified, +16/-1)
  • test/production/app-dir/image-jest-qualities/app/app/page.tsx (added, +8/-0)
  • test/production/app-dir/image-jest-qualities/app/jest.config.js (added, +11/-0)
  • test/production/app-dir/image-jest-qualities/app/next.config.js (added, +5/-0)
  • test/production/app-dir/image-jest-qualities/image-jest-qualities.test.ts (added, +114/-0)

PR #91686: fix(next/image): Improve error message for private IP (SSRF) rejections

Description (problem / solution / changelog)

<html> <body> <!--StartFragment--><p data-start="103" data-end="122">Related to #88873</p> <p data-start="124" data-end="373">In Next.js 16, <code data-start="139" data-end="159">fetchExternalImage</code> performs a DNS lookup and rejects requests if the resolved IP address is non-public (e.g. RFC1918 ranges), unless <code data-start="274" data-end="306">images.dangerouslyAllowLocalIP</code> is enabled. This is part of the SSRF protection introduced in v16.</p> <p data-start="375" data-end="565">This check occurs <strong data-start="393" data-end="417">after DNS resolution</strong>, meaning that even publicly valid hostnames (e.g. <code data-start="468" data-end="492">storage.googleapis.com</code>) may be rejected if they resolve to private IPs in certain environments.</p> <p data-start="567" data-end="604">This commonly happens in setups like:</p> <ul data-start="606" data-end="716"> <li data-section-id="f4tbpc" data-start="606" data-end="645"> <p data-start="608" data-end="645">Docker / containerized environments</p> </li> <li data-section-id="17mijxt" data-start="646" data-end="679"> <p data-start="648" data-end="679">GCP / internal load balancing</p> </li> <li data-section-id="1d8cde2" data-start="680" data-end="716"> <p data-start="682" data-end="716">Split-horizon DNS configurations</p> </li> </ul> <p data-start="718" data-end="733">In these cases:</p> <ul data-start="735" data-end="886"> <li data-section-id="16wt0vf" data-start="735" data-end="789"> <p data-start="737" data-end="789">The hostname is valid and matches <code data-start="771" data-end="787">remotePatterns</code></p> </li> <li data-section-id="la8mh9" data-start="790" data-end="843"> <p data-start="792" data-end="843">But the resolved IP is private (e.g. <code data-start="829" data-end="840">172.x.x.x</code>)</p> </li> <li data-section-id="180ga1" data-start="844" data-end="886"> <p data-start="846" data-end="886">Result: <code data-start="854" data-end="868">/_next/image</code> returns <strong data-start="877" data-end="884">400</strong></p> </li> </ul> <hr data-start="888" data-end="891"> <h3 data-section-id="ydusx9" data-start="893" data-end="904">Problem</h3> <p data-start="906" data-end="966">Currently, this failure path returns the same generic error:</p> <pre class="overflow-visible! px-0!" data-start="968" data-end="1006"><div class="relative w-full mt-4 mb-1"><div class=""><div class="relative"><div class="h-full min-h-0 min-w-0"><div class="h-full min-h-0 min-w-0"><div class="border border-token-border-light border-radius-3xl corner-superellipse/1.1 rounded-3xl"><div class="h-full w-full border-radius-3xl bg-token-bg-elevated-secondary corner-superellipse/1.1 overflow-clip rounded-3xl lxnfua_clipPathFallback"><div class="pointer-events-none absolute end-1.5 top-1 z-2 md:end-2 md:top-1"></div><div class="pe-11 pt-3"><div class="relative z-0 flex max-w-full"><div id="code-block-viewer" dir="ltr" class="q9tKkq_viewer cm-editor z-10 light:cm-light dark:cm-light flex h-full w-full flex-col items-stretch ͼk ͼy"><div class="cm-scroller"><div class="cm-content q9tKkq_readonly"><span>"url parameter is not allowed"</span></div></div></div></div></div></div></div></div></div><div class=""><div class=""></div></div></div></div></div></pre> <p data-start="1008" data-end="1138">This message is also used for <strong data-start="1038" data-end="1069"><code data-start="1040" data-end="1056">remotePatterns</code> mismatches</strong>, which creates ambiguity between two fundamentally different issues:</p> <div class="TyagGW_tableContainer"><div tabindex="-1" class="group TyagGW_tableWrapper flex flex-col-reverse w-fit"> Failure Type | Root Cause | Required Fix -- | -- | -- remotePatterns mismatch | Misconfiguration | Update config Private IP (SSRF guard) | DNS / environment | Allow local IPs, fix DNS, or use proxy </div></div> <p data-start="1383" data-end="1425">Because both cases produce the same error:</p> <ul data-start="1427" data-end="1633"> <li data-section-id="10bu702" data-start="1427" data-end="1464"> <p data-start="1429" data-end="1464">Users often assume a config issue</p> </li> <li data-section-id="uz550d" data-start="1465" data-end="1510"> <p data-start="1467" data-end="1510">Debugging becomes unnecessarily difficult</p> </li> <li data-section-id="vn7kgj" data-start="1511" data-end="1633"> <p data-start="1513" data-end="1633">This is especially confusing during upgrades from Next.js 15 → 16, where behavior changed due to added security checks</p> </li> </ul> <hr data-start="1635" data-end="1638"> <h3 data-section-id="6ne8r9" data-start="1640" data-end="1661">What this PR does</h3> <p data-start="1663" data-end="1731">This PR improves diagnostics without changing any security behavior:</p> <ol data-start="1733" data-end="2381"> <li data-section-id="1pm1gp3" data-start="1733" data-end="1955"> <p data-start="1736" data-end="1818"><strong data-start="1736" data-end="1775">Introduces a distinct error message</strong> for the private IP / SSRF rejection path</p> <ul data-start="1822" data-end="1955"> <li data-section-id="1dbk4bq" data-start="1822" data-end="1899"> <p data-start="1824" data-end="1899">Clearly indicates that the failure is due to resolved IP being non-public</p> </li> <li data-section-id="10yro0u" data-start="1903" data-end="1955"> <p data-start="1905" data-end="1955">Points users to <code data-start="1921" data-end="1953">images.dangerouslyAllowLocalIP</code></p> </li> </ul> </li> <li data-section-id="1mnrcoz" data-start="1957" data-end="2049"> <p data-start="1960" data-end="1999"><strong data-start="1960" data-end="1982">Exports a constant</strong> for this error</p> <ul data-start="2003" data-end="2049"> <li data-section-id="1f9bs7s" data-start="2003" data-end="2049"> <p data-start="2005" data-end="2049">Ensures consistency and avoids duplication</p> </li> </ul> </li> <li data-section-id="16uylm1" data-start="2051" data-end="2217"> <p data-start="2054" data-end="2086"><strong data-start="2054" data-end="2073">Adds unit tests</strong><br data-start="2073" data-end="2076"> Covers:</p> <ul data-start="2090" data-end="2217"> <li data-section-id="fphu1b" data-start="2090" data-end="2117"> <p data-start="2092" data-end="2117">Literal private IP URLs</p> </li> <li data-section-id="iodp39" data-start="2121" data-end="2158"> <p data-start="2123" data-end="2158">DNS-resolved private IP scenarios</p> </li> <li data-section-id="w01n6z" data-start="2162" data-end="2217"> <p data-start="2164" data-end="2217">Behavior with and without <code data-start="2190" data-end="2215">dangerouslyAllowLocalIP</code></p> </li> </ul> </li> <li data-section-id="1lg29fr" data-start="2219" data-end="2381"> <p data-start="2222" data-end="2249"><strong data-start="2222" data-end="2247">Updates documentation</strong></p> <ul data-start="2253" data-end="2381"> <li data-section-id="11q0i8j" data-start="2253" data-end="2274"> <p data-start="2255" data-end="2274">v16 upgrade guide</p> </li> <li data-section-id="cue3cc" data-start="2278" data-end="2313"> <p data-start="2280" data-end="2313"><code data-start="2280" data-end="2292">next/image</code> configuration docs</p> </li> <li data-section-id="eof4xk" data-start="2317" data-end="2381"> <p data-start="2319" data-end="2381">Explicitly calls out this failure mode and how to resolve it</p> </li> </ul> </li> </ol> <hr data-start="2383" data-end="2386"> <h3 data-section-id="1dz3o8e" data-start="2388" data-end="2408">Why this matters</h3> <ul data-start="2410" data-end="2692"> <li data-section-id="1mrs1ik" data-start="2410" data-end="2480"> <p data-start="2412" data-end="2480">Makes SSRF-related failures <strong data-start="2440" data-end="2478">distinguishable from config errors</strong></p> </li> <li data-section-id="1puu7wr" data-start="2481" data-end="2553"> <p data-start="2483" data-end="2553">Reduces debugging time for users in containerized/cloud environments</p> </li> <li data-section-id="fh6jzu" data-start="2554" data-end="2616"> <p data-start="2556" data-end="2616">Helps clarify <strong data-start="2570" data-end="2599">upgrade-related breakages</strong> from v15 → v16</p> </li> <li data-section-id="1ktvnt" data-start="2617" data-end="2692"> <p data-start="2619" data-end="2692">Improves developer experience <strong data-start="2649" data-end="2690">without weakening security guarantees</strong></p> </li> </ul> <hr data-start="2694" data-end="2697"> <h3 data-section-id="1fo4che" data-start="2699" data-end="2712">Non-goals</h3> <ul data-start="2714" data-end="2846"> <li data-section-id="10upuhq" data-start="2714" data-end="2753"> <p data-start="2716" data-end="2753">No changes to SSRF protection logic</p> </li> <li data-section-id="30taus" data-start="2754" data-end="2801"> <p data-start="2756" data-end="2801">No changes to how DNS resolution is handled</p> </li> <li data-section-id="10h6f9o" data-start="2802" data-end="2846"> <p data-start="2804" data-end="2846">No relaxation of private IP restrictions</p> </li> </ul> <hr data-start="2848" data-end="2851"> <h3 data-section-id="nyzj5a" data-start="2853" data-end="2881">Question for maintainers</h3> <p data-start="2883" data-end="2982">Would the team be open to exploring follow-up work around handling this class of cases differently?</p> <p data-start="2984" data-end="2996">For example:</p> <ul data-start="2998" data-end="3118"> <li data-section-id="1gbfc3b" data-start="2998" data-end="3118"> <p data-start="3000" data-end="3118">Scenarios where a hostname is allowlisted (<code data-start="3043" data-end="3059">remotePatterns</code>) but resolves to private IPs due to infrastructure setup</p> </li> </ul> <p data-start="3120" data-end="3165">This would require careful consideration, as:</p> <ul data-start="3167" data-end="3310"> <li data-section-id="8up1ba" data-start="3167" data-end="3226"> <p data-start="3169" data-end="3226">The current behavior prioritizes strict SSRF protection</p> </li> <li data-section-id="s12jjx" data-start="3227" data-end="3310"> <p data-start="3229" data-end="3310">Any relaxation (e.g. trusting hostnames over resolved IPs) could introduce risk</p> </li> </ul> <p data-start="3312" data-end="3426">Happy to explore this direction further, but wanted to align on whether this is something the team would consider.</p> <!--EndFragment--> </body> </html>

Changed files

  • docs/01-app/02-guides/upgrading/version-16.mdx (modified, +2/-0)
  • docs/01-app/03-api-reference/02-components/image.mdx (modified, +2/-0)
  • packages/next/src/server/image-optimizer.ts (modified, +9/-2)
  • test/unit/image-optimizer/fetch-external-image.test.ts (modified, +48/-0)

PR #92338: fix(next/image): prevent DNS rebinding SSRF via IP-pinned fetch agent

Description (problem / solution / changelog)

Summary

Addresses a critical SSRF vulnerability via DNS rebinding in next/image's fetchExternalImage function. Related to #88873.

The Vulnerability (TOCTOU DNS Rebinding)

The current SSRF protection in fetchExternalImage has a Time-of-Check-Time-of-Use (TOCTOU) gap:

Step 1: dns.lookup("evil.com")       → 8.8.8.8    (public IP ✓)
Step 2: isPrivateIp("8.8.8.8")      → false       (validation passes ✓)
                                     ⬇ DNS TTL expires, record changes
Step 3: fetch("https://evil.com/...") → 127.0.0.1  (SSRF! ✗)

Between the DNS validation (lookup()) and the HTTP fetch (fetch()), an attacker controlling a DNS server can change the DNS record from a public IP to 127.0.0.1 (or any internal IP). The fetch() call performs its own DNS resolution, bypassing the private IP check entirely.

Attack Scenario

  1. Attacker configures evil.com in the app's remotePatterns
  2. evil.com DNS has a very short TTL (0-1s)
  3. Request: GET /_next/image?url=https://evil.com/img.png&w=128&q=75
  4. fetchExternalImage calls lookup("evil.com") → returns 8.8.8.8 (passes validation)
  5. DNS TTL expires, attacker's DNS now returns 127.0.0.1
  6. fetch("https://evil.com/img.png") resolves to 127.0.0.1SSRF
  7. Attacker can read internal services, metadata endpoints, etc.

The Fix: IP Pinning

After DNS validation passes, we create a custom HTTP(S) agent with a lookup callback that always returns the pre-validated IP address:

function createPinnedAgent(protocol, validatedIp, family) {
  const lookupFn = (_hostname, _options, callback) => {
    // Always return the IP we already validated — no re-resolution
    callback(null, validatedIp, family)
  }
  return new https.Agent({ lookup: lookupFn })
}

This eliminates the TOCTOU gap — the TCP connection is guaranteed to use the same IP that was validated.

Files Changed

FileChange
packages/next/src/server/image-optimizer.ts[MODIFY] Add createPinnedAgent() and use it in fetchExternalImage
test/unit/image-optimizer-dns-rebinding.test.ts[NEW] Unit tests documenting the attack vector and fix

Key Design Decisions

  1. Agent-level pinning vs. connecting by IP directly — Using a custom agent preserves TLS SNI verification (the Host header still uses the hostname, not the IP)
  2. Agent cleanup — The pinned agent is destroy()-ed after each fetch to prevent connection pool exhaustion
  3. Redirect safety — Each redirect hop recursively calls fetchExternalImage, which performs its own DNS validation + IP pinning

Security Impact

AspectBeforeAfter
DNS rebinding SSRF✗ Vulnerable✓ Protected
Legitimate traffic✓ Works✓ Works (transparent)
TLS verification✓ Intact✓ Intact (SNI preserved)

How I verified

  • Unit tests validate the pinned lookup behavior for IPv4, IPv6, and cross-hostname scenarios
  • Design review confirms the TOCTOU gap is closed by the agent-level pinning
  • Redirect handling confirmed safe (recursive validation on each hop)

Changed files

  • packages/next/src/server/image-optimizer.ts (modified, +75/-810)
  • test/unit/image-optimizer-dns-rebinding.test.ts (added, +121/-0)

Code Example

images: {
     remotePatterns: [
       {
         protocol: 'https',
         hostname: 'storage.googleapis.com',
         pathname: '/**',
         search: ''
       },
       {
         protocol: 'https',
         hostname: 'img.onelife.vn'
       }
     ]
   }
3. Build and deploy with Docker (ensure next.config.js is copied to production image)
4. Test image optimization endpoint:

-Works: /_next/image?url=https%3A%2F%2Fimg.onelife.vn%2Fexample.webp&w=128&q=75
-Fails: /_next/image?url=https%3A%2F%2Fstorage.googleapis.com%2Fonelife-public%2Fmedia%2Fimages%2F2026%2F01%2FBy5AXgblob&w=128&q=75

5. Observe 400 Bad Request with "url parameter is not allowed" for storage.googleapis.com
6. Downgrade to Next.js 15.5.9 - same URL now works



### Current vs. Expected behavior

**Expected:** Both URLs should return optimized images since both hostnames are configured in remotePatterns:
https://img.onelife.vn/... → ✅ optimized image
https://storage.googleapis.com/... → ✅ optimized image

**Actual** (Current):
https://img.onelife.vn/... → ✅ optimized image (works)
https://storage.googleapis.com/... → ❌ 400 Bad Request "url parameter is not allowed"

**Key finding:**
Same configuration works in Next.js 15.5.9
Same configuration fails in Next.js 16 for storage.googleapis.com only
Other domains like img.onelife.vn work fine in Next.js 16

### Provide environment information

---

### Which area(s) are affected? (Select all that apply)

Image (next/image)

### Which stage(s) are affected? (Select all that apply)

Other (Deployed)

### Additional context

Deployment: Docker with Alpine Node image (node:22.21.1-alpine) Dockerfile confirms next.config.js is copied:
RAW_BUFFERClick to expand / collapse

Link to the code that reproduces this issue

https://codesandbox.io/p/devbox/sweet-field-8rvv9c

To Reproduce

  1. Create a Next.js 16 app with remotePatterns configured for storage.googleapis.com
  2. Add the following to next.config.js:
    images: {
      remotePatterns: [
        {
          protocol: 'https',
          hostname: 'storage.googleapis.com',
          pathname: '/**',
          search: ''
        },
        {
          protocol: 'https',
          hostname: 'img.onelife.vn'
        }
      ]
    }
  3. Build and deploy with Docker (ensure next.config.js is copied to production image)
  4. Test image optimization endpoint:
  • ✅ Works: /_next/image?url=https%3A%2F%2Fimg.onelife.vn%2Fexample.webp&w=128&q=75
  • ❌ Fails: /_next/image?url=https%3A%2F%2Fstorage.googleapis.com%2Fonelife-public%2Fmedia%2Fimages%2F2026%2F01%2FBy5AXgblob&w=128&q=75
  1. Observe 400 Bad Request with "url parameter is not allowed" for storage.googleapis.com
  2. Downgrade to Next.js 15.5.9 - same URL now works

Current vs. Expected behavior

Expected: Both URLs should return optimized images since both hostnames are configured in remotePatterns: https://img.onelife.vn/... → ✅ optimized image https://storage.googleapis.com/... → ✅ optimized image

Actual (Current): https://img.onelife.vn/... → ✅ optimized image (works) https://storage.googleapis.com/... → ❌ 400 Bad Request "url parameter is not allowed"

Key finding: Same configuration works in Next.js 15.5.9 Same configuration fails in Next.js 16 for storage.googleapis.com only Other domains like img.onelife.vn work fine in Next.js 16

Provide environment information

Operating System:
  Platform: linux (Docker Alpine)
  Arch: x64
Binaries:
  Node: 22.21.1
  npm: 10.x
  Yarn: 1.22.x
Relevant Packages:
  next: 16.x.x Pages Router (worked in 15.5.9)
  react: 19.x
  sharp: 0.31.3

Which area(s) are affected? (Select all that apply)

Image (next/image)

Which stage(s) are affected? (Select all that apply)

Other (Deployed)

Additional context

Deployment: Docker with Alpine Node image (node:22.21.1-alpine) Dockerfile confirms next.config.js is copied:

COPY --from=builder /app/next.config.js ./next.config.js

Version comparison:

  • Next.js 15.5.9: ✅ storage.googleapis.com works
  • Next.js 16.x: ❌ storage.googleapis.com returns "url parameter is not allowed"

Observations:

  • The issue appears specific to certain hostnames (public cloud storage like storage.googleapis.com)
  • Custom domains (img.onelife.vn) work fine with identical remotePatterns config
  • This suggests Next.js 16 may have added hostname-based filtering/blocking for security that's too aggressive
  • Workaround: Currently using <img> tags instead of <Image> for storage.googleapis.com URLs, or proxying through img.onelife.vn

extent analysis

TL;DR

The most likely fix is to downgrade to Next.js 15.5.9 or wait for a potential fix in a future Next.js version, as the issue appears to be specific to Next.js 16 and certain hostnames like storage.googleapis.com.

Guidance

  • Verify that the remotePatterns configuration in next.config.js is correctly set up for both storage.googleapis.com and img.onelife.vn.
  • Test the image optimization endpoint with different URLs to confirm that the issue is specific to storage.googleapis.com.
  • Consider using a workaround such as using <img> tags instead of <Image> for storage.googleapis.com URLs or proxying through img.onelife.vn.
  • Review the Next.js documentation and release notes for any changes or updates related to hostname-based filtering or security features that may be causing the issue.

Example

No code snippet is provided as the issue seems to be related to a specific version of Next.js and its configuration.

Notes

The issue appears to be specific to Next.js 16 and certain hostnames, and downgrading to Next.js 15.5.9 resolves the issue. However, this may not be a viable long-term solution, and it's recommended to wait for a potential fix in a future Next.js version.

Recommendation

Apply workaround: Use <img> tags instead of <Image> for storage.googleapis.com URLs or proxy through img.onelife.vn until a fix is available in a future Next.js version. This is because downgrading to Next.js 15.5.9 may not be feasible for all projects, and a workaround can provide a temporary solution.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING