Skip to main content

Image Proxy

Lumio's public pages show images that live on third-party CDNs: Twitch, 7TV, BetterTTV and FrankerFaceZ emotes, platform avatars, Twitch panel artwork and Bits graphics. If the visitor's browser loaded them from those CDNs, each request would send the visitor's IP address and user agent to the provider. The image proxy avoids that. The browser loads /img/<provider>/<path> from the Lumio host it is already on, and the Lumio server fetches the upstream image without any visitor data.

The proxy is a route handler in each public app, built from the shared package @lumio/image-proxy (shared/image-proxy):

AppRouteCovered pages
apps/statssrc/app/img/[...path]/route.tsevery Stats page
apps/websrc/app/img/[...path]/route.tsthe share report /share/history/[token], the ideas board /ideas and /ideas/[id], the invite pages /invite/*, and the other marketing and legal pages

The signed-in dashboard, Multichat, popouts and overlays do not use it. They load images directly from the CDNs, because their users are contract parties. GIPHY GIFs are not proxied anywhere; on public pages they still wait for Functional consent (Cookies Consent Library).

Providers​

A proxy URL names a provider key, never a host or a full URL, so the proxy can reach only these hosts, and on each host only these first path segments:

KeyUpstream hostAllowed first segmentQuery
ttvstatic-cdn.jtvnw.netemoticons, jtv_user_pictures, badgesnone
ttvpanelpanels.twitch.tvexactly one segment panel-<channel id>-image-<uuid> (Twitch panel artwork)none
7tvcdn.7tv.appemotenone
bttvcdn.betterttv.netemotenone
ffzcdn.frankerfacez.comemotenone
ytyt3.ggpht.comone opaque segment, or ytc/<one segment>none
discordcdn.discordapp.comavatars, icons (never attachments)size ∈ 16, 32, 64, 128, 256, 512, 1024, 2048, 4096
kickfiles.kick.comimages, emotes, kicks, channel_subscriber_badgesnone
kickextext.cdn.kick.comchatnone
bitsd3aqoihi2n8ty8.cloudfront.netactionsnone

The table lives in shared/image-proxy/src/providers.ts. Adding a provider or a segment widens what the proxy fetches, so it needs an AppSec review.

Rewriting URLs​

proxiedImageUrl(src) from @lumio/image-proxy turns https://static-cdn.jtvnw.net/emoticons/v2/25/default/dark/1.0 into /img/ttv/emoticons/v2/25/default/dark/1.0. It returns null for anything the proxy would refuse. The caller then renders its fallback (initials, the emote code, a placeholder) and never the raw URL.

proxiedImageUrl and the handler share one validator (shared/image-proxy/src/path.ts). A URL is accepted only in its canonical form, so every image has exactly one proxy URL and one cache key:

  • https: only, no userinfo, no port (:443 included), no fragment, nothing the URL parser would normalise (dot segments, upper-case host).
  • Path segments match [A-Za-z0-9._~=-]+: no % (nothing encoded), no \, no empty segment (//, a trailing /), no . or ... The upstream path is at most 512 bytes.
  • A query string only where the provider allows one. Each parameter appears at most once, is never empty, takes a value from the fixed set, and keys are in sorted order.

proxiedSrcSet(srcSet) does the same for every candidate of a srcset and returns null if any candidate cannot be proxied.

Where the components get the rewrite:

  • Stats: ProxiedImagePolicy in the root layout sets AvatarImagePolicy resolveSrc={proxiedImageUrl} from @lumio/ui, so every AvatarImage and ChannelAvatar loads the proxied URL. EmoteImage and the panel artwork (proxiedSlides in channel-panels.tsx) rewrite on the server, so the HTML carries no CDN URL.
  • Web: PublicMediaPolicy (src/components/third-party-media-gate.tsx) sets the same AvatarImagePolicy. Other leaves call useImageSrc() / useImageSrcSet(): ChatEmoteImage, BitsToken, the report's emote, chatter, AutoMod and event avatars, and the emote hover card. Outside a policy (dashboard) these hooks return the URL unchanged.

Request handling​

StepRule
PathCanonical form as above. A syntax error is 400; an unknown provider or first segment is 404. Nothing is fetched.
Outbound headersBuilt fresh. No incoming header is copied: no X-Forwarded-For, Forwarded, CF-Connecting-IP, X-Real-IP, Cookie, Referer, User-Agent or Accept-Language. The proxy sends User-Agent: LumioImageProxy/1 (+https://lumio.vision) and Accept: image/avif,image/webp,image/png,image/gif,image/jpeg, with credentials: "omit" and referrerPolicy: "no-referrer".
Data Cachecache: "no-store" on the fetch and dynamic = "force-dynamic" on the route, so Next keeps no copy.
Redirectsredirect: "manual". At most 2 redirects, each only to a URL proxiedImageUrl would also accept. Anything else is 502.
Timeout5 s, and the fetch is also aborted when the visitor's request goes away (request.signal).
StatusOnly an upstream 200 passes. An upstream 404 is 404; every other status is 502.
Typeimage/png, image/jpeg, image/gif, image/webp or image/avif from the upstream Content-Type. No SVG. The bytes are not inspected, with one exception: files.kick.com serves emotes as binary/octet-stream, so for the kick provider only an octet-stream body is typed by its file signature. It passes only if the signature names one of the five raster types.
SizeAt most 2 MiB. Content-Length is checked first, and the body is counted while it is read.

Logs carry the provider key and the status class only, never the path, because avatar paths contain platform user ids.

Response​

Every response gets fresh headers. Only the validated Content-Type comes from upstream; upstream Set-Cookie, Cache-Control and other headers are dropped.

Header200Errors (400, 404, 502)
Cache-Controlpublic, max-age=86400, s-maxage=86400public, max-age=60
Content-Typethe validated image typenone (empty body)
X-Content-Type-Optionsnosniffnosniff
Content-Security-Policydefault-src 'none'; sandboxsame
Cross-Origin-Resource-Policysame-originsame

Caching and retention​

There is no server-side store: no Redis key, no object storage, no disk, no Next Data Cache. The only copies of an image are HTTP caches, the Cloudflare edge and the visitor's browser, and max-age=86400 bounds both to 24 hours whatever the upstream sends. Invalidation is expiry only.

The 24 hours bound the image bytes. The avatar URL itself is kept by the read model for as long as the stream report exists, until erasure; the proxy adds no retention of its own. After an erasure the page stops referencing the image, and remaining cached copies expire within 24 hours.

Cloudflare settings matter here: a zone Browser Cache TTL above 24 hours, or an Edge Cache TTL cache rule that ignores the origin header, would keep copies longer. Both must respect the origin Cache-Control for /img/*.

Request interception​

/img/ bypasses request interception. The apps/web proxy.ts matcher excludes img/, so the route gets no login redirect, no locale cookie and no CSP nonce, and a publicly cached image never carries a Set-Cookie. apps/stats/src/proxy.ts still runs on /img/ (it sets X-Robots-Tag per deployment), but it never sets the ?lang= locale cookie there, the same rule as for /og/. robots.txt does not disallow /img/, so search engines can fetch images on indexed pages.

Next itself normalises a request path before the handler runs. Dot segments (/../, /%2e%2e/) are resolved first, so they can only reach a path that passes the allow-list. A doubled or trailing / gets a 308 to the canonical path.

Tests​

shared/image-proxy/__tests__/ (vitest project image-proxy) covers the rewrite for every provider, the rewrite and handler refusing every non-canonical or disallowed input, a test that the mocked upstream fetch receives none of the visitor's headers, the redirect, type, size, status and timeout limits, and the Cache-Control values. apps/web/__tests__/third-party-media-gate.test.tsx checks that a public tree renders only /img/ URLs, and apps/web/__tests__/proxy-img-matcher.test.ts checks the matcher exclusion.