mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-08-03 02:38:19 -04:00
Update FaviconExtractor and configure improved defensive settings
This commit is contained in:
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user