Repository navigation
How Urls Are Checked
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.
- Null, empty and blank strings are false. Leading and trailing spaces are ignored.
- 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 asC:\temp\x.pngare false, and so islocalhost:8080/a.png, whoselocalhost:reads as a scheme. - A protocol-relative
//host/a.pngis 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. - 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. - The extension is compared with the 16 in
ImageUrl.ImageExtensionswithout 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.
- 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/avatarwith no scheme, a protocol-relative//images.test/avatar.png,ftp:and null. - When the path has an image extension, the answer is true with no request, even if the token passed is already cancelled.
- 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 withContent-Type: image/pngcounts as true. - A 3xx answer with a
Locationis 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. - 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 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.
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.
This wiki describes IsImageUrlDotNet 2.0.0 and was last updated on 2026-09-29. The library is MIT licensed. Report problems in the issues. On NuGet: IsImageUrlDotNet.
Using it
The releases
Contributing
Elsewhere