mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
1880 words
9 minutes
An Empirical Evaluation of Third-Party Image Proxies for the AniList Content Delivery Network
2026-08-09

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.jpg

2.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.

sequenceDiagram participant B as Browser (residential client) participant P as Image proxy (datacenter) participant O as AniList CDN (s4.anilist.co) B->>P: GET /?url=encoded cover P->>O: server-side fetch(cover) O-->>P: refused (datacenter) P-->>B: HTTP 4xx / 5xx B->>O: direct GET (residential) O-->>B: 200 OK

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:

  1. 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.
  2. The same AniList URL passed through wsrv.nl, with the URL encoded and unencoded, and with and without transformation parameters (w, h, fit, format).
  3. The same URL passed through images.weserv.nl (the legacy domain).
  4. 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.
  5. The same URL passed through Statically.io, both with and without resizing parameters.
  6. Control probes of wsrv.nl and Statically.io using a GitHub avatar URL, which is known to be served openly.
  7. 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.

TargetMethodOriginHTTP statusInterpretation
AniList coverGETresidential200Origin reachable directly
AniList cover (HEAD)HEADresidential200Origin reachable directly
AniList cover, Googlebot UAGETresidential200User agent does not matter
AniList cover, “Weserv” UAGETresidential200User agent does not matter
wsrv.nlGETdatacenter400”Domain or TLD blocked by policy”
images.weserv.nlGETdatacenter400Same blocking policy
Self-hosted Cloudflare WorkerGETdatacenter502Origin refused datacenter fetch
Statically.io (AniList)GETdatacenter403Blocked
Statically.io (GitHub control)GETdatacenter403Service unreachable, not origin-specific
api.allorigins.winGETdatacentertimeoutUnusable
corsproxy.ioGETdatacenter403Blocked
api.codetabs.comGETdatacenter521Origin down
DuckDuckGo image proxyGETdatacenterconnection refusedUnusable
wsrv.nl (GitHub control)GETdatacenter200Proxy itself functions normally

Table 1: Measured HTTP responses by probe.

4.1 Key observations#

  • Every direct request to s4.anilist.co from 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.

sequenceDiagram participant R as Residential client participant W as wsrv.nl participant C as Self-hosted Cloudflare Worker participant O as AniList CDN (s4.anilist.co) R->>O: GET cover O-->>R: 200 OK W->>O: GET cover O-->>W: 400: Domain or TLD blocked by policy C->>O: GET cover O-->>C: 502: Bad Gateway

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:

  1. 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.
  2. 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.
  3. Runtime fallback. A proxy URL is used as the primary src and 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#

  1. AniList GraphQL API documentation. https://anilist.gitbook.io/anilist-apiv2-docs/
  2. wsrv.nl image proxy. https://wsrv.nl
  3. Images.Weserv.nl documentation. https://images.weserv.nl/docs/
  4. Statically.io documentation. https://statically.io
  5. Cloudflare Workers documentation. https://developers.cloudflare.com/workers/
  6. 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 of cover, contain, crop, or pad.
  • format: webp or avif; any other value is served untransformed.
  • q: image quality.

The request handler proceeds in two stages:

  1. Transform path. If an Images binding named IMAGES is present, the worker calls env.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.
  2. Passthrough path. If the binding is missing, or the binding call fails (for example the free-tier 9422 over-quota error), the worker falls back to a plain fetch(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.

sequenceDiagram participant C as Client participant W as Worker participant B as Images binding participant O as Origin C->>W: GET /?url=... alt url missing W-->>C: 400: missing url else url blocked (SSRF guard) W-->>C: 400: blocked url else binding available W->>B: env.IMAGES.fetch(url, options) alt transform succeeds B-->>W: transformed image W-->>C: image (webp / avif) else transform fails W->>O: fetch(url) alt origin responds O-->>W: image bytes W-->>C: image else origin unreachable W-->>C: 502: origin fetch failed end end else no binding W->>O: fetch(url) alt origin responds O-->>W: image bytes W-->>C: image else origin unreachable W-->>C: 502: origin fetch failed end end

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 login
npx wrangler@latest deploy

Route 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 deploy instead 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:

Terminal window
$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.

Share

If this article helped you, please share it with others!

An Empirical Evaluation of Third-Party Image Proxies for the AniList Content Delivery Network
https://vermilion10-blog.pages.dev/posts/anilist-cdn-blocking/
Author
vermilion10
Published at
2026-08-09
License
CC BY-NC-SA 4.0

Some information may be outdated

Table of Contents