Skip to content

How Urls Are Checked

m4bwav edited this page Sep 30, 2026 · 1 revision

Both methods start from the text of the URL. HasImageExtension stops there. IsImageUrlAsync asks the server only when the text does not already say "image". Neither method reads local files, looks at the image's bytes or depends on the current culture.

HasImageExtension: the extension of the path

  1. Null, empty and blank strings are false. Leading and trailing spaces are ignored.
  2. A string that starts with a scheme counts only when the scheme is http or https, in any case. file:, ftp:, data:, mailto: and a Windows path such as C:\temp\x.png are false, and so is localhost:8080/a.png, whose localhost: reads as a scheme.
  3. A protocol-relative //host/a.png is read as http. Any other string (photos/cat.png?w=1, /images/a.png) is read as a path, the same way on every OS.
  4. The query and the fragment are ignored. The extension is the text after the last dot of the last path segment, so a dotted folder (dir.png/file) and a trailing dot (a.png.) don't count.
  5. The extension is compared with the 16 in ImageUrl.ImageExtensions without regard to case or culture: png, jpg, jpeg, gif, raw, bmp, svg, psd, webp, avif, apng, ico, tif, tiff, heic, heif.

The verification program printed these answers (none made a request):

HasImageExtension("https://example.com/photos/cat.PNG?w=200") => True   (no request)
HasImageExtension("https://example.com/report.pdf") => False   (no request)
HasImageExtension("https://example.com/a.png#top") => True   (no request)
HasImageExtension("https://example.com/a.pdf?name=a.png") => False   (no request)
HasImageExtension("https://example.com/dir.png/file") => False   (no request)
HasImageExtension("https://example.com/a.png.") => False   (no request)
HasImageExtension("https://example.com/") => False   (no request)
HasImageExtension("Http://example.com/a.PNG") => True   (no request)
HasImageExtension("  https://example.com/a.png  ") => True   (no request)
HasImageExtension("https://example.com/%E2%82%AC.png") => True   (no request)
HasImageExtension("https://example.com/x.heic") => True   (no request)
HasImageExtension("https://example.com/x.TIF") => True   (no request)
HasImageExtension("photos/cat.PNG?w=1") => True   (no request)
HasImageExtension("/images/a.png") => True   (no request)
HasImageExtension("images\a.png") => True   (no request)
HasImageExtension("//cdn.example.com/a.png") => True   (no request)
HasImageExtension("a b.png") => True   (no request)
HasImageExtension("x.exe.png") => True   (no request)
HasImageExtension("x.png.exe") => False   (no request)
HasImageExtension(".png") => True   (no request)
HasImageExtension("png") => False   (no request)
HasImageExtension("") => False   (no request)
HasImageExtension(null) => False   (no request)
HasImageExtension("file:///tmp/a.png") => False   (no request)
HasImageExtension("ftp://example.com/a.png") => False   (no request)
HasImageExtension("data:image/png;base64,iVBORw0KGgo=") => False   (no request)
HasImageExtension("C:\temp\x.png") => False   (no request)
HasImageExtension("localhost:8080/a.png") => False   (no request)
HasImageExtension("x.GIF") under tr-TR => True   (no request)

The strings are shown as they are, not as C# literals: images\a.png has one backslash. The same answers came back on .NET Framework 4.8 and .NET 8.

IsImageUrlAsync: asking the server

  1. The URL, trimmed, must parse as an absolute http or https URL. Anything else is false at once, with no request: a relative avatar.png, images.test/avatar with no scheme, a protocol-relative //images.test/avatar.png, ftp: and null.
  2. When the path has an image extension, the answer is true with no request, even if the token passed is already cancelled.
  3. Otherwise it sends one GET and reads only the response headers. A 2xx status with a media type starting image/, in any case, is true. Every other answer is false: another status (404 and 500 included, whatever their media type), another media type, or none. A 204 with Content-Type: image/png counts as true.
  4. A 3xx answer with a Location is followed by the package itself, up to 10 times: relative locations, other hosts, and http to https. A location with another scheme, or one from https to http, ends the check with false and is not requested. A loop gives up after 11 requests with false.
  5. When the server cannot be reached or does not answer in HTTP, it throws HttpRequestException; on a timeout or a cancelled token, TaskCanceledException (Edge cases and errors).

What the stand-in answers on each path:

Path Answer
/avatar 200, image/png
/about 200, text/html; charset=utf-8
/upper 200, IMAGE/PNG
/svg 200, image/svg+xml
/x-imagefile 200, application/x-imagefile
/octet 200, application/octet-stream, with PNG bytes
/no-type 200, no Content-Type
/gone 404, image/png
/error 500, text/plain
/empty 204, image/png
/photo 302 to /avatar
/to-other 302 to http://other.test/avatar
/loop 302 to /loop
/to-ftp 302 to ftp://images.test/a.png
/to-https 302 to https://secure.test/avatar
/close closes the connection without an answer
/garbage NOT HTTP AT ALL
/slow accepts the request and never answers

secure.test uses a throwaway certificate that the shared client, rightly, does not trust. Each row below is one call with the shared client. The brackets hold the request lines the stand-in received, and x 11 means the same line 11 times.

IsImageUrlAsync("http://images.test/avatar") => True   [GET http://images.test/avatar]
IsImageUrlAsync("http://images.test/about") => False   [GET http://images.test/about]
IsImageUrlAsync("http://images.test/upper") => True   [GET http://images.test/upper]
IsImageUrlAsync("http://images.test/svg") => True   [GET http://images.test/svg]
IsImageUrlAsync("http://images.test/x-imagefile") => False   [GET http://images.test/x-imagefile]
IsImageUrlAsync("http://images.test/octet") => False   [GET http://images.test/octet]
IsImageUrlAsync("http://images.test/no-type") => False   [GET http://images.test/no-type]
IsImageUrlAsync("http://images.test/gone") => False   [GET http://images.test/gone]
IsImageUrlAsync("http://images.test/error") => False   [GET http://images.test/error]
IsImageUrlAsync("http://images.test/empty") => True   [GET http://images.test/empty]
IsImageUrlAsync("http://images.test/photo") => True   [GET http://images.test/photo; GET http://images.test/avatar]
IsImageUrlAsync("http://images.test/to-other") => True   [GET http://images.test/to-other; GET http://other.test/avatar]
IsImageUrlAsync("http://images.test/loop") => False   [GET http://images.test/loop x 11]
IsImageUrlAsync("http://images.test/to-ftp") => False   [GET http://images.test/to-ftp]
IsImageUrlAsync("http://images.test/to-https") => HttpRequestException   [GET http://images.test/to-https; CONNECT secure.test:443]
IsImageUrlAsync("https://secure.test/avatar") => HttpRequestException   [CONNECT secure.test:443]
IsImageUrlAsync("http://images.test/close") => HttpRequestException   [GET http://images.test/close x 4]
IsImageUrlAsync("http://images.test/garbage") => HttpRequestException   [GET http://images.test/garbage]
IsImageUrlAsync("http://images.test/a.png?size=1") => True   (no request)
IsImageUrlAsync("http://images.test/photo.webp") => True   (no request)
IsImageUrlAsync("  http://images.test/avatar  ") => True   [GET http://images.test/avatar]
IsImageUrlAsync("http://user:secret@images.test/avatar?size=large") => True   [GET http://images.test/avatar?size=large]
IsImageUrlAsync("images.test/avatar") => False   (no request)
IsImageUrlAsync("//images.test/avatar.png") => False   (no request)
IsImageUrlAsync("avatar.png") => False   (no request)
IsImageUrlAsync("ftp://images.test/a.png") => False   (no request)
IsImageUrlAsync("") => False   (no request)
IsImageUrlAsync(null) => False   (no request)

The to-https row shows the redirect being followed: the client opened a tunnel to secure.test and then refused its certificate. On .NET Framework 4.8 every row was the same except /close, which was requested twice instead of four times. The repeats come from the HTTP handler retrying a connection that closed without an answer, as seen through the stand-in proxy; the package itself sends one request per hop. .NET 8 matched .NET 10 exactly.

The request

The stand-in received this from the shared client for http://user:secret@images.test/avatar?size=large, and then this from a plain new HttpClient() passed in:

head: shared client => True
GET http://images.test/avatar?size=large HTTP/1.1
Host: images.test
User-Agent: IsImageUrlDotNet/2.0 (+https://github.com/m4bwav/IsImageUrlDotNet)
head: new HttpClient() => True
GET http://images.test/avatar?size=large HTTP/1.1
Host: images.test

The request line holds the full URL because it went to a proxy. The query is kept. The user name and password are dropped, and no Authorization header is sent. Only the shared client adds a User-Agent. On .NET Framework the same headers arrived in another order, with Proxy-Connection: Keep-Alive added by the framework.

Redirects when your client follows them

IsImageUrlAsync(url, client) applies the rules above only when the client's handler leaves redirects alone (AllowAutoRedirect = false). A handler that follows them applies its own rules first, and the package then follows any 3xx it is still handed. Edge cases and errors has the measured difference, including a redirect loop that took 561 requests.

Clone this wiki locally