Abstract
AniList, a popular anime tracking platform, serves cover art and profile imagery from its own content delivery network (CDN), hosted at s4.anilist.co. This paper documents an empirical investigation into whether third-party image proxying services can reliably fetch and serve these images for use on a third-party web page. Direct requests from a residential client returned HTTP 200 in every case. However, all evaluated server-side proxy services failed: wsrv.nl and images.weserv.nl returned HTTP 400 with the message “Domain or TLD blocked by policy”; a self-hosted Cloudflare Worker returned HTTP 502; Statically.io returned HTTP 403 even for a control image; and allorigins.win, corsproxy.io, codetabs.com, and DuckDuckGo image proxying were also unusable. User-agent variation did not affect the outcome from a residential client, which indicates an IP-address-based access control policy on the origin. The paper concludes that datacenter-hosted image proxies are not a viable mechanism for serving AniList imagery and discusses three practical mitigations.
Keywords
image proxy; content delivery network; access control; hotlinking; AniList; wsrv.nl; Cloudflare Workers; web performance
1. Introduction
Modern web pages frequently display imagery hosted by third parties. Rather than linking directly to the origin, developers sometimes route images through an image proxy or image CDN. Such services can provide on-the-fly resizing, format conversion (for example, WebP or AVIF), and edge caching. Common examples include wsrv.nl (a rebrand of the Images.Weserv.nl service) and Statically.io.
A personal portfolio site integrates the AniList profile API and renders cover art and profile images. The site originally intended to route these images through wsrv.nl to obtain resized and cached variants. During development, the images failed to render. This paper records the diagnostic process, the measured responses from a range of proxying approaches, and the conclusions drawn from those measurements.
2. Background
2.1 AniList and its content delivery network
AniList is an online community and database service for anime and manga. Its public GraphQL API returns, among other fields, coverImage URLs that reference its own image CDN. Observed asset URLs take the form:
https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx199547-LAaG3cmKCGhr.jpg2.2 Image proxy services
Image proxies fetch an image on behalf of a client and return the transformed or cached result. A typical request is constructed as:
https://proxy.example/?url=<encoded origin URL>Because the proxy fetches the origin from its own servers, the origin sees a request that originates from a datacenter rather than from the end user’s browser. The two request paths are contrasted in Figure 1.
Figure 1: Request paths between a browser, a datacenter image proxy, and the AniList CDN.
3. Methodology
All requests were on 2026-08-09. Origin requests were sent from a residential client using the HTTP client bundled with Windows PowerShell 5.1. Proxy requests were sent to the public endpoints of each service. For each probe, the HTTP status code and, where applicable, the response content type and payload size were recorded.
The following probes were executed:
- Direct fetch of the AniList origin URL (
s4.anilist.co), both HEAD and GET, using three distinct user-agent strings: the default client user agent, a Googlebot user agent, and a synthetic “Weserv” user agent. - The same AniList URL passed through wsrv.nl, with the URL encoded and unencoded, and with and without transformation parameters (
w,h,fit,format). - The same URL passed through images.weserv.nl (the legacy domain).
- The same URL passed through a self-hosted Cloudflare Worker implementing an image proxy with a Cloudflare Images binding transform path and a plain passthrough fallback path. The construction and complete source code of this worker are given in Appendix A.
- The same URL passed through Statically.io, both with and without resizing parameters.
- Control probes of wsrv.nl and Statically.io using a GitHub avatar URL, which is known to be served openly.
- Additional proxy candidates: api.allorigins.win, corsproxy.io, api.codetabs.com, and external-content.duckduckgo.com.
4. Results
The measured responses are summarized in Table 1.
| Target | Method | Origin | HTTP status | Interpretation |
|---|---|---|---|---|
| AniList cover | GET | residential | 200 | Origin reachable directly |
| AniList cover (HEAD) | HEAD | residential | 200 | Origin reachable directly |
| AniList cover, Googlebot UA | GET | residential | 200 | User agent does not matter |
| AniList cover, “Weserv” UA | GET | residential | 200 | User agent does not matter |
| wsrv.nl | GET | datacenter | 400 | ”Domain or TLD blocked by policy” |
| images.weserv.nl | GET | datacenter | 400 | Same blocking policy |
| Self-hosted Cloudflare Worker | GET | datacenter | 502 | Origin refused datacenter fetch |
| Statically.io (AniList) | GET | datacenter | 403 | Blocked |
| Statically.io (GitHub control) | GET | datacenter | 403 | Service unreachable, not origin-specific |
| api.allorigins.win | GET | datacenter | timeout | Unusable |
| corsproxy.io | GET | datacenter | 403 | Blocked |
| api.codetabs.com | GET | datacenter | 521 | Origin down |
| DuckDuckGo image proxy | GET | datacenter | connection refused | Unusable |
| wsrv.nl (GitHub control) | GET | datacenter | 200 | Proxy itself functions normally |
Table 1: Measured HTTP responses by probe.
4.1 Key observations
- Every direct request to
s4.anilist.cofrom a residential client succeeded with HTTP 200, irrespective of the user-agent string. - wsrv.nl served a control GitHub avatar with HTTP 200, which demonstrates that the proxy service itself was operational at the time of testing.
- The same proxy returned HTTP 400 for AniList assets, with the body
{"status":"error","code":400,"message":"Domain or TLD blocked by policy"}. This message indicates an allowlist or denylist on the proxy side. - A self-hosted Cloudflare Worker, which has no policy of its own and performs a plain server-side
fetch(), also failed with HTTP 502. This confirms that the origin itself rejects requests originating from datacenter IP ranges, independent of any proxy service policy. - Statically.io returned HTTP 403 even for the GitHub control image, which suggests a service-level fault or broad blocking at the time of testing, and excludes it as a candidate regardless of origin.
5. Discussion
5.1 Root cause
The combination of results supports an IP-address-based access control policy at the origin CDN. The origin serves residential clients without regard to user agent, but refuses requests from datacenter IP ranges. Two independent datacenter paths were tested, one a commercial proxy (wsrv.nl, whose block message is explicit) and one a proxy the author fully controls (the Cloudflare Worker, which returned a generic upstream failure). Both failed, which rules out any single proxy’s policy as the sole explanation and implicates the origin. The three paths and their measured outcomes are shown in Figure 2.
Figure 2: Measured outcomes for residential and datacenter request paths.
5.2 Implications for image proxying
The practical consequence is that no server-side proxy hosted in a conventional cloud or content delivery network can fetch AniList imagery. This renders the common image-proxy pattern unusable for this particular origin. Workarounds such as sending a browser-like user-agent string are ineffective, because the decision is made at the network layer rather than the application layer. Appendix A documents the construction of the self-hosted proxy used in this investigation, including its complete source code, so that the experiment can be reproduced.
5.3 Mitigations
Three viable alternatives remain for a page that must display AniList imagery:
- Direct CDN links. The browser fetches the origin image directly. This is the simplest approach and is guaranteed to work, because the end user’s client is the client that the origin agrees to serve. The trade-off is that third-party URLs appear in the page source and network inspection.
- Self-hosted static assets. Images are downloaded once from a client machine (which the origin serves) and committed or deployed as first-party static assets. The page then references same-origin URLs, which hides the third-party origin entirely. The trade-off is that the asset set is a snapshot and must be refreshed when the source list changes.
- Runtime fallback. A proxy URL is used as the primary
srcand a raw origin URL is applied by the client only when the proxy fails. This preserves a clean page source in the best case but incurs a wasted failed request per image and still exposes origin URLs in the network trace after the fallback fires.
5.4 Limitations
The block was not characterized beyond HTTP status codes; the origin did not reveal whether the rejection is based on IP reputation, geographic region, or ASN membership. Only a small set of proxy providers was tested, and the behavior of services such as Statically.io may change over time. Replication of the measurements at a later date may yield different results.
6. Conclusion
AniList imagery cannot be served through datacenter-hosted image proxies. The origin CDN at s4.anilist.co rejects requests from datacenter IP ranges while serving residential clients unconditionally, which was demonstrated across two independent proxy paths. Sites that integrate AniList imagery should either link to the origin directly, self-host downloaded copies of the images, or rely on a client-side fallback strategy.
7. References
- AniList GraphQL API documentation. https://anilist.gitbook.io/anilist-apiv2-docs/
- wsrv.nl image proxy. https://wsrv.nl
- Images.Weserv.nl documentation. https://images.weserv.nl/docs/
- Statically.io documentation. https://statically.io
- Cloudflare Workers documentation. https://developers.cloudflare.com/workers/
- Cloudflare Images pricing. https://developers.cloudflare.com/images/pricing/
Appendix A. Constructing the img-proxy Worker
This appendix records how the self-hosted proxy used in Section 3 (probe 4) and reported in Section 4 (HTTP 502) was created and deployed. The complete source is included so the experiment can be reproduced.
A.1 Design
The worker was intended to behave like wsrv.nl. It accepts the following query parameters:
url: the encoded origin image URL (required).w,h: target dimensions in pixels.fit: one ofcover,contain,crop, orpad.format:webporavif; any other value is served untransformed.q: image quality.
The request handler proceeds in two stages:
- Transform path. If an Images binding named
IMAGESis present, the worker callsenv.IMAGES.fetch(src, options), which resizes and re-encodes the origin image. This path relies on the Cloudflare Images transformations feature, which is included in the free plan up to 5,000 unique transformations per month. - Passthrough path. If the binding is missing, or the binding call fails (for example the free-tier
9422over-quota error), the worker falls back to a plainfetch(src)and returns the origin image unchanged.
A server-side request forgery (SSRF) guard rejects non-HTTP URLs and hosts in the private, loopback, and link-local ranges, including the cloud metadata endpoint 169.254.169.254. Every response carries CORS headers and a one-day cache policy for both the client and the Cloudflare edge. The full request handling flow is shown in Figure 3.
Figure 3: Request handling flow inside the img-proxy worker.
A.2 Deployment
Two deployment routes were evaluated.
Route 1: Wrangler (command line). The project is declared in wrangler.toml, which also attaches the Images binding:
name = "img-proxy"main = "src/index.ts"compatibility_date = "2026-07-08"
[[images]]binding = "IMAGES"The worker is then deployed with:
npx wrangler@latest loginnpx wrangler@latest deployRoute 2: Cloudflare dashboard. The dashboard drag-and-drop uploader routes files through the Workers Builds pipeline, which is intended for projects that require a build step. Uploading a single plain-JavaScript worker was rejected with the following message:
This uploader does not yet support projects that require a build process. At least one JavaScript file was found. Please use
wrangler deployinstead for full feature support.
The practical workaround was to create a worker from a template and paste the code into the inline editor (Workers & Pages, open the worker, Edit code, replace the template, then Deploy). The Images binding, if desired, is added under Settings, Bindings.
After deployment the worker was reachable at https://img-proxy.asagirireika98.workers.dev. In the portfolio application, that URL was assigned to config.imgProxy.baseUrl and the AniList image fields were routed through it. This integration was later removed, because the proxy cannot serve AniList imagery, and the images are now loaded directly from the AniList CDN.
A.3 Source code
The complete worker source follows. The listing is self-contained: it can be pasted into the dashboard editor or written to worker.js and uploaded.
// img-proxy: a self-hosted image proxy for Cloudflare Workers.// The whole worker is a single ES module, which is all Cloudflare needs; it// can be pasted into the dashboard editor or kept as a worker.js file. Its// query surface mirrors wsrv.nl so clients do not have to know where an image// actually lives://// GET /?url=<encoded>&w=&h=&fit=cover&format=webp&q=//// Two serving paths are chained. The preferred one resizes and re-encodes the// image through the Cloudflare Images binding (free for up to 5,000 unique// transformations a month); when the binding is absent or the transformation// fails, the worker returns the origin image untouched instead.
const CORS_HEADERS = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, OPTIONS", "Access-Control-Allow-Headers": "*",};
// Cache-Control governs the visitor's browser, while CDN-Cache-Control tells// the Cloudflare edge to hold the result for a day. Together they give the// proxy its caching behaviour and spare the origin repeated fetches.const CACHE_HEADERS = { "Cache-Control": "public, max-age=86400, s-maxage=86400", "CDN-Cache-Control": "public, s-maxage=86400",};
// A desktop browser user agent and an image-aware Accept header keep the// origin from treating the worker as a generic bot when it follows a URL.const BROWSER_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36";
const ALLOWED_FITS = ["cover", "contain", "crop", "pad"];const BINDING_FORMATS = ["webp", "avif"];
function err(status, message) { // Every failure is reported the same way: a JSON body describing the error, // with the CORS header added so a browser can still read it cross-origin. return new Response(JSON.stringify({ error: message }), { status, headers: CORS_HEADERS, });}
// A proxy that accepts arbitrary URLs is an open door to internal services,// so this guard rejects anything that is not an ordinary HTTP(S) request and// anything that points at a private, loopback, or cloud-metadata host. DNS// name rebinding is a residual risk, though it is acceptable for a// personal-use proxy.function isBlockedUrl(raw) { let u; try { u = new URL(raw); } catch { return true; } if (u.protocol !== "http:" && u.protocol !== "https:") return true;
const host = u.hostname.toLowerCase(); if ( host === "localhost" || host.endsWith(".local") || host.endsWith(".internal") ) { return true; } if (host === "::1" || host === "[::1]") return true; if (host === "169.254.169.254") return true;
const ipv4 = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); if (ipv4) { const a = parseInt(ipv4[1], 10); const b = parseInt(ipv4[2], 10); if ( a === 0 || a === 10 || a === 127 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 169 && b === 254) ) { return true; } }
return false;}
export default { async fetch(request, env) { // Browsers send a CORS preflight before reading the image, so OPTIONS is // answered directly rather than treated as a proxied request. if (request.method === "OPTIONS") { return new Response(null, { headers: CORS_HEADERS }); } if (request.method !== "GET") { return err(405, "method not allowed"); }
const q = new URL(request.url).searchParams; const src = q.get("url") || ""; if (!src) return err(400, "missing url param"); if (isBlockedUrl(src)) return err(400, "blocked url");
// The remaining parameters are the wsrv-compatible transformation set; // only `fit` has to be validated up front. const w = parseInt(q.get("w") || "", 10); const h = parseInt(q.get("h") || "", 10); const fit = q.get("fit") || "cover"; const quality = parseInt(q.get("q") || "", 10); const format = (q.get("format") || q.get("output") || "").toLowerCase();
if (!ALLOWED_FITS.includes(fit)) return err(400, "invalid fit");
// Preferred path: the origin URL is handed to the Images binding, which // performs the resizing and format conversion itself, so the response can // be streamed straight back. Any failure here, whether the binding is // missing, the free tier is over-quota, or the origin refuses the // datacenter fetch, falls through to the plain passthrough below. if (env && env.IMAGES) { const options = { fit }; if (Number.isFinite(w) && w > 0) options.width = w; if (Number.isFinite(h) && h > 0) options.height = h; if (Number.isFinite(quality) && quality > 0) options.quality = quality; if (BINDING_FORMATS.includes(format)) options.format = format;
try { const res = await env.IMAGES.fetch(src, options); if (res.ok) { return new Response(res.body, { headers: { ...CORS_HEADERS, ...CACHE_HEADERS, "Content-Type": res.headers.get("Content-Type") || "image/webp", }, }); } } catch { // the binding failed, so the request continues to the fallback path } }
// Fallback path: the worker fetches the image itself and relays the bytes // unchanged, preserving the origin content type. If even this fails, the // origin is genuinely unreachable and a 502 is returned. try { const res = await fetch(src, { headers: { "User-Agent": BROWSER_UA, Accept: "image/avif,image/webp,image/*,*/*;q=0.8", }, }); if (!res.ok) { return err(502, `origin returned ${res.status}`); } return new Response(res.body, { headers: { ...CORS_HEADERS, ...CACHE_HEADERS, "Content-Type": res.headers.get("Content-Type") || "application/octet-stream", }, }); } catch { return err(502, "origin fetch failed"); } },};A.4 Verification
The deployed worker was probed with a control image and with an AniList image:
$src = 'https://s4.anilist.co/file/anilistcdn/media/anime/cover/medium/bx199547-LAaG3cmKCGhr.jpg'$worker = 'https://img-proxy.asagirireika98.workers.dev/?url=' + [uri]::EscapeDataString($src)Invoke-WebRequest -Uri $worker -UseBasicParsing# Expected: 502 Bad Gateway (AniList refuses datacenter fetches)
$control = 'https://avatars.githubusercontent.com/u/180294769'Invoke-WebRequest -Uri ('https://img-proxy.asagirireika98.workers.dev/?url=' + [uri]::EscapeDataString($control)) -UseBasicParsing# Expected: 200 OK (worker functions normally for unblocked origins)The worker therefore behaves correctly as a general-purpose image proxy but cannot serve AniList imagery, which corroborates the analysis in Section 5.
If this article helped you, please share it with others!
Some information may be outdated





