Update FaviconExtractor and configure improved defensive settings

This commit is contained in:
Leendert de Borst
2026-08-02 22:55:25 +02:00
parent 5703ced8a3
commit 86a7bca474
2 changed files with 98 additions and 19 deletions

View File

@@ -27,6 +27,10 @@ public static class FaviconExtractor
{
private const int MaxSizeBytes = 20 * 1024; // 20KB max size; images above this are resized/re-encoded.
private const int MaxResponseBytes = 5 * 1024 * 1024; // 5MB cap per response body, measured after decompression.
private const int MaxDecodedPixels = 2048 * 2048; // Pixel budget per image; see IsWithinDecodePixelBudget.
private const int MaxFaviconCandidates = 10; // Distinct favicon URLs fetched per page; see TryExtractFaviconFromNodes.
private static readonly TimeSpan _extractionDeadline = TimeSpan.FromSeconds(5); // Wall-clock budget for one full extraction.
private static readonly TimeSpan _requestTimeout = TimeSpan.FromSeconds(3); // Per-request budget, so one slow host still leaves room for another candidate.
private static readonly int[] _resizeWidths = [96, 64, 48, 32];
private static readonly int[] _jpegFallbackQualities = [80, 65, 50];
private static readonly string[] _allowedSchemes = ["http", "https"];
@@ -99,12 +103,15 @@ public static class FaviconExtractor
using HttpClient client = CreateHttpClient();
// Set a limit for how long the extraction can take.
using var deadline = new CancellationTokenSource(_extractionDeadline);
try
{
// Attempt the operation up to two times to handle common cookiewall redirects or transient issues.
for (int attempt = 0; attempt < 2; attempt++)
{
var result = await TryGetFaviconAsync(client, uri);
var result = await TryGetFaviconAsync(client, uri, deadline.Token);
if (result != null)
{
return result;
@@ -116,6 +123,11 @@ public static class FaviconExtractor
// Abort on any request exception.
return null;
}
catch (OperationCanceledException)
{
// Overall deadline hit: give up rather than letting the favicon extraction result in a too long client side hang.
return null;
}
// Return null if the favicon extraction failed.
return null;
@@ -224,6 +236,12 @@ public static class FaviconExtractor
/// <returns>True if all addresses are publicly routable, false otherwise.</returns>
internal static bool AreIpAddressesPublic(IPAddress[] addresses)
{
// An empty set has nothing to vouch for, so it must never be treated as validated.
if (addresses.Length == 0)
{
return false;
}
foreach (var address in addresses)
{
if (!IPAddressValidator.IsPublicIPAddress(address))
@@ -235,6 +253,32 @@ public static class FaviconExtractor
return true;
}
/// <summary>
/// Checks that an image's declared pixel dimensions fit within the decode budget to prevent
/// potential memory exhaustion for very large images.
/// </summary>
/// <param name="imageBytes">The raw image bytes.</param>
/// <returns>True if the image is a decodable raster image within the pixel budget, false otherwise.</returns>
internal static bool IsWithinDecodePixelBudget(byte[] imageBytes)
{
using var data = SKData.CreateCopy(imageBytes);
using var codec = SKCodec.Create(data);
// Not a decodable raster image at all; nothing downstream can render it either.
if (codec is null)
{
return false;
}
var info = codec.Info;
if (info.Width <= 0 || info.Height <= 0)
{
return false;
}
return (long)info.Width * info.Height <= MaxDecodedPixels;
}
/// <summary>
/// Opens the TCP connection for an outgoing request and resolve a hostname only once.
/// </summary>
@@ -279,18 +323,19 @@ public static class FaviconExtractor
/// </summary>
/// <param name="client">The HTTP client.</param>
/// <param name="uri">The URI to get the favicon from.</param>
/// <param name="cancellationToken">Token maxing the overall extraction.</param>
/// <returns>The favicon bytes.</returns>
private static async Task<byte[]?> TryGetFaviconAsync(HttpClient client, Uri uri)
private static async Task<byte[]?> TryGetFaviconAsync(HttpClient client, Uri uri, CancellationToken cancellationToken)
{
var response = await FollowRedirectsAsync(client, uri);
var response = await FollowRedirectsAsync(client, uri, cancellationToken);
if (response == null || !response.IsSuccessStatusCode)
{
return null;
}
var faviconNodes = await GetFaviconNodesFromHtml(response, uri);
return await TryExtractFaviconFromNodes(faviconNodes, client, uri);
var faviconNodes = await GetFaviconNodesFromHtml(response, uri, cancellationToken);
return await TryExtractFaviconFromNodes(faviconNodes, client, uri, cancellationToken);
}
/// <summary>
@@ -298,10 +343,11 @@ public static class FaviconExtractor
/// </summary>
/// <param name="response">The response to get the favicon nodes from.</param>
/// <param name="uri">The URI to get the favicon nodes from.</param>
/// <param name="cancellationToken">Token maxing the overall extraction.</param>
/// <returns>The favicon nodes.</returns>
private static async Task<HtmlNodeCollection[]> GetFaviconNodesFromHtml(HttpResponseMessage response, Uri uri)
private static async Task<HtmlNodeCollection[]> GetFaviconNodesFromHtml(HttpResponseMessage response, Uri uri, CancellationToken cancellationToken)
{
string htmlContent = await response.Content.ReadAsStringAsync();
string htmlContent = await response.Content.ReadAsStringAsync(cancellationToken);
HtmlDocument htmlDoc = new();
htmlDoc.LoadHtml(htmlContent);
@@ -332,9 +378,12 @@ public static class FaviconExtractor
/// <param name="faviconNodes">The favicon nodes.</param>
/// <param name="client">The HTTP client.</param>
/// <param name="baseUri">The base URI.</param>
/// <param name="cancellationToken">Token maxing the overall extraction.</param>
/// <returns>The favicon bytes.</returns>
private static async Task<byte[]?> TryExtractFaviconFromNodes(HtmlNodeCollection[] faviconNodes, HttpClient client, Uri baseUri)
private static async Task<byte[]?> TryExtractFaviconFromNodes(HtmlNodeCollection[] faviconNodes, HttpClient client, Uri baseUri, CancellationToken cancellationToken)
{
var seenUrls = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var nodeCollection in faviconNodes)
{
if (nodeCollection == null || nodeCollection.Count == 0)
@@ -355,7 +404,17 @@ public static class FaviconExtractor
faviconUrl = new Uri(baseUri, faviconUrl).ToString();
}
var faviconBytes = await FetchAndProcessFaviconAsync(client, faviconUrl);
if (!seenUrls.Add(faviconUrl))
{
continue;
}
if (seenUrls.Count > MaxFaviconCandidates)
{
return null;
}
var faviconBytes = await FetchAndProcessFaviconAsync(client, faviconUrl, cancellationToken);
if (faviconBytes != null)
{
return faviconBytes;
@@ -371,8 +430,9 @@ public static class FaviconExtractor
/// </summary>
/// <param name="client">The HTTP client.</param>
/// <param name="url">The URL to fetch the favicon from.</param>
/// <param name="cancellationToken">Token maxing the overall extraction.</param>
/// <returns>The favicon bytes.</returns>
private static async Task<byte[]?> FetchAndProcessFaviconAsync(HttpClient client, string url)
private static async Task<byte[]?> FetchAndProcessFaviconAsync(HttpClient client, string url, CancellationToken cancellationToken)
{
try
{
@@ -383,7 +443,7 @@ public static class FaviconExtractor
}
// Follow redirects with validation
var response = await FollowRedirectsAsync(client, faviconUri);
var response = await FollowRedirectsAsync(client, faviconUri, cancellationToken);
if (response == null || !response.IsSuccessStatusCode)
{
@@ -396,17 +456,22 @@ public static class FaviconExtractor
return null;
}
var imageBytes = await response.Content.ReadAsByteArrayAsync();
var imageBytes = await response.Content.ReadAsByteArrayAsync(cancellationToken);
if (imageBytes.Length == 0)
{
return null;
}
// Don't rely on the HTTP Content-Type header: sniff the real format from the file's
// magic bytes. Servers frequently mislabel favicons (e.g. a PNG served as image/x-icon),
// and some serve formats clients can't safely render.
// magic bytes. Servers frequently mislabel favicons and some serve formats clients can't safely render.
var format = DetectImageFormat(imageBytes);
// Reject oversized images.
if (format != ImageFormatSignature.Svg && !IsWithinDecodePixelBudget(imageBytes))
{
return null;
}
if (_clientSafeFormats.Contains(format))
{
// Recognized, client-safe format: keep as-is, only shrinking if it exceeds the cap.
@@ -419,6 +484,11 @@ public static class FaviconExtractor
// something a client might fail to render.
return ReencodeWithinCap(imageBytes);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// The overall deadline expired: abort the extraction.
throw;
}
catch
{
return null;
@@ -442,7 +512,7 @@ public static class FaviconExtractor
var client = new HttpClient(handler)
{
Timeout = TimeSpan.FromSeconds(5),
Timeout = _requestTimeout,
MaxResponseContentBufferSize = MaxResponseBytes,
};
@@ -520,8 +590,9 @@ public static class FaviconExtractor
/// </summary>
/// <param name="client">The HTTP client.</param>
/// <param name="uri">The initial URI to request.</param>
/// <param name="cancellationToken">Token maxing the overall extraction.</param>
/// <returns>The final HTTP response after following redirects, or null if blocked/failed.</returns>
private static async Task<HttpResponseMessage?> FollowRedirectsAsync(HttpClient client, Uri uri)
private static async Task<HttpResponseMessage?> FollowRedirectsAsync(HttpClient client, Uri uri, CancellationToken cancellationToken)
{
var currentUri = uri;
int redirectCount = 0;
@@ -542,7 +613,7 @@ public static class FaviconExtractor
request.Headers.Add("Referer", uri.ToString());
}
var response = await client.SendAsync(request);
var response = await client.SendAsync(request, cancellationToken);
if ((int)response.StatusCode >= 300 && (int)response.StatusCode < 400)
{

View File

@@ -28,12 +28,14 @@ internal static class IPAddressValidator
(new byte[] { 100, 64, 0, 0 }, 10), // CGNAT
(new byte[] { 192, 0, 0, 0 }, 24), // IETF Protocol Assignments
(new byte[] { 192, 0, 2, 0 }, 24), // TEST-NET-1
(new byte[] { 192, 88, 99, 0 }, 24), // 6to4 relay anycast
(new byte[] { 198, 18, 0, 0 }, 15), // benchmarking
(new byte[] { 198, 51, 100, 0 }, 24), // TEST-NET-2
(new byte[] { 203, 0, 113, 0 }, 24), // TEST-NET-3
(new byte[] { 224, 0, 0, 0 }, 4), // multicast
(new byte[] { 240, 0, 0, 0 }, 4), // reserved
(new byte[] { 0, 0, 0, 0 }, 8), // local
(new byte[] { 127, 0, 0, 0 }, 8), // loopback
};
/// <summary>
@@ -41,8 +43,14 @@ internal static class IPAddressValidator
/// </summary>
private static readonly (byte[] Net, int Prefix)[] PrivateV6Blocks = new[]
{
(new byte[] { 0xfc, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, 7), // ULA
(new byte[] { 0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, 32), // documentation
(new byte[] { 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, 96), // IPv4-compatible (deprecated), unspecified and loopback
(new byte[] { 0, 0x64, 0xff, 0x9b, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, 96), // NAT64 well-known prefix
(new byte[] { 0, 0x64, 0xff, 0x9b, 0, 0x01, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, 48), // NAT64 local-use prefix
(new byte[] { 0x01, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, 64), // discard-only
(new byte[] { 0x20, 0x01, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, 32), // Teredo
(new byte[] { 0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, 32), // documentation
(new byte[] { 0x20, 0x02, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, 16), // 6to4
(new byte[] { 0xfc, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, 7), // ULA
};
/// <summary>