DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Our image optimiser is public, answers nine hostnames and one quality value, and the pages that can reach it have no images at all

Nakodo renders creator avatars and video thumbnails inside the signed-in app. The 76 public pages contain zero <img> elements: every drawing on them is inline SVG. So the image optimiser is a piece of infrastructure that exists entirely for pages you cannot see, while its endpoint sits on the public domain where anybody can poke at it.

I poked at it. Everything below is reproducible against production.

What the config says

images: {
  // Avatars and thumbnails are fetched by our server, so browsers never talk
  // to the platforms' image hosts directly.
  remotePatterns: [
    { protocol: "https", hostname: "yt3.ggpht.com" },
    { protocol: "https", hostname: "yt3.googleusercontent.com" },
    { protocol: "https", hostname: "i.ytimg.com" },
    { protocol: "https", hostname: "**.tiktokcdn.com" },
    { protocol: "https", hostname: "**.tiktokcdn-eu.com" },
    { protocol: "https", hostname: "**.tiktokcdn-us.com" },
    { protocol: "https", hostname: "**.cdninstagram.com" },
    { protocol: "https", hostname: "**.fbcdn.net" },
    { protocol: "https", hostname: "platform-lookaside.fbsbx.com" },
  ],
  qualities: [75],
  // An avatar's address changes when the picture does (Instagram and TikTok
  // sign theirs afresh on each read), so a resized one is kept for a week
  // rather than resized again every 4 hours. Well inside the 30 days YouTube
  // data may be kept.
  minimumCacheTTL: 7 * 24 * 60 * 60,
},
Enter fullscreen mode Exit fullscreen mode

Nine hostnames: four exact, five wildcards. Three platforms, six of the entries belonging to two of them, because Instagram's images come off Facebook's CDNs and TikTok's come off three regional ones.

The first comment is the reason any of this exists. If a browser loaded avatars straight from the platforms, every customer looking at a list of creators would be making requests to YouTube, Instagram and TikTok with their own IP address and whatever cookies they happen to have for those domains. Proxying the images through our own server costs us bandwidth and buys a page that does not quietly introduce the viewer to three ad networks.

What production actually accepts

Widths, against a real thumbnail on an allowed host:

w=16    400
w=32    200
w=48    200
w=64    200
w=96    200
w=128   200
w=256   200
w=384   200
w=640   200
w=750   200
w=828   200
w=1080  200
w=1200  200
w=1920  200
w=2048  200
w=3840  200
w=100   400
w=500   400
Enter fullscreen mode Exit fullscreen mode

Fifteen accepted widths, and they are exactly imageSizes concatenated with deviceSizes, neither of which we configure. The one that surprised me is w=16. Next's docs for the current version give the default as:

imageSizes: [32, 48, 64, 96, 128, 256, 384],
Enter fullscreen mode Exit fullscreen mode

Sixteen used to be in that list. It is not any more, so a hard-coded /_next/image?...&w=16 that worked on an older major is a 400 now. Worth knowing if you build those URLs by hand anywhere.

Quality is simpler, because we pinned it:

$ curl -s -o /dev/null -w '%{http_code}\n' ".../_next/image?url=$IMG&w=256&q=75"
200
$ curl -s -o /dev/null -w '%{http_code}\n' ".../_next/image?url=$IMG&w=256&q=100"
400
Enter fullscreen mode Exit fullscreen mode

qualities is not optional in Next 16, and the docs say why in plain terms: "This field is required starting with Next.js 16 because unrestricted access could allow malicious actors to optimize more qualities than you intended." The quality prop in a component snaps to the nearest allowed value; a direct request with a disallowed one is a 400. One quality value means one variant per width per URL, which is the number that decides how much work a stranger can make you do.

Every refusal looks identical

$ curl -s ".../_next/image?url=https%3A%2F%2Fexample.com%2Fa.png&w=256&q=75"
Bad request

INVALID_IMAGE_OPTIMIZE_REQUEST

fra1::5hnlr-1793272251826-0ec17a7d202a
Enter fullscreen mode Exit fullscreen mode

A host that is not on the list, a width that is not in the fifteen, and a quality that is not 75 all produce the same 400, the same body, and the same error code. Only the request id differs.

From a security point of view that is correct: nothing about the allowlist leaks, and an attacker cannot use error messages to map what you proxy. From a debugging point of view it is the worst 400 I have met this year, because the three mistakes have three different fixes and the response cannot tell you which one you made. If you hit it, change one parameter at a time.

What a success looks like

HTTP/2 200
cache-control: public, max-age=604800, must-revalidate
content-disposition: attachment; filename="hqdefault.jpg"
content-security-policy: script-src 'none'; frame-src 'none'; sandbox;
content-type: image/jpeg
vary: Accept
x-vercel-cache: MISS
Enter fullscreen mode Exit fullscreen mode

Four things in there are worth a sentence each.

max-age=604800 is our minimumCacheTTL of seven days, up from the framework default of 14400 seconds (four hours). The reason is in the config comment and it is not a performance reason: avatar URLs on two of the three platforms are signed and change when the picture changes, so the cache key dies on its own when the image does. Re-optimising every four hours would be work with no possible benefit. The upper bound is not performance either, it is a promise: our privacy notice says YouTube data is refreshed or deleted within 30 days, and a cached derivative of a thumbnail is that data. Seven days sits well inside it.

content-disposition: attachment plus script-src 'none'; frame-src 'none'; sandbox is Next defending the endpoint from being used as a file host: whatever those nine hosts return, a browser that follows the URL downloads it rather than executing it.

vary: Accept and content-type: image/jpeg go together. With no Accept header, curl gets the original format back. Send a browser-ish header and the same URL gives a different body:

$ curl -s -o /dev/null -w '%{content_type} %{size_download}\n' -H 'Accept: image/avif,image/webp,*/*' ".../_next/image?url=$IMG&w=256&q=75"
image/webp 4222
$ curl -s -o /dev/null -w '%{content_type} %{size_download}\n' ".../_next/image?url=$IMG&w=256&q=75"
image/jpeg 5808
Enter fullscreen mode Exit fullscreen mode

WebP at the same width is 4,222 bytes against 5,808, so 27% off for one header. Note that asking for AVIF gets WebP anyway: formats defaults to ['image/webp'] and we never changed it. That is a genuine open choice rather than an oversight. AVIF encodes smaller and costs noticeably more CPU per optimisation, and our images are 32 to 96 pixel avatars where the absolute saving is a couple of kilobytes. It stays as it is until somebody can show me the numbers on a real page.

The part that made me write this down

$ grep -rln 'from "next/image"' src
src/components/brand/avatar.tsx
Enter fullscreen mode Exit fullscreen mode

One call site. Nine allowlisted hosts, fifteen widths, one quality, a seven day cache, a public endpoint on the production domain, and the entire thing exists to render one component that only appears behind sign-in.

That is not waste, it is just a surface worth being honest about. Three facts bound it:

  1. The allowlist is the only authentication. Anyone can cause a cache miss for any URL on those nine hosts, and we pay for the fetch and the transform. The ceiling per distinct source URL is fifteen variants, after which it is cache hits for a week.
  2. The allowlist is wider than the call site needs. Avatars and thumbnails come from three platforms, and the entries exist per CDN rather than per platform, which is how a list of nine grows out of a product that shows one kind of picture. Every entry should be traceable to something currently rendered, and I am going to go and check that each of these still is.
  3. Nothing public can reach it. The 76 pages in our sitemap have no <img> at all, so no crawler, no social preview and no visitor on the marketing site ever touches /_next/image. Social cards are a separate route that renders them on the server.

The general version of the lesson: an image optimiser is a URL that takes a remote address and a size and does paid work. Both halves of that deserve an allowlist, and in Next 16 both halves have one. Go and curl your own /_next/image with a width nobody uses and a quality you never configured, and see what it tells you.

Top comments (0)