diff --git a/apps/server/Utilities/AliasVault.FaviconExtractor/FaviconExtractor.cs b/apps/server/Utilities/AliasVault.FaviconExtractor/FaviconExtractor.cs
index 9e1aa8abf..bff5d8fa4 100644
--- a/apps/server/Utilities/AliasVault.FaviconExtractor/FaviconExtractor.cs
+++ b/apps/server/Utilities/AliasVault.FaviconExtractor/FaviconExtractor.cs
@@ -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
/// True if all addresses are publicly routable, false otherwise.
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;
}
+ ///
+ /// Checks that an image's declared pixel dimensions fit within the decode budget to prevent
+ /// potential memory exhaustion for very large images.
+ ///
+ /// The raw image bytes.
+ /// True if the image is a decodable raster image within the pixel budget, false otherwise.
+ 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;
+ }
+
///
/// Opens the TCP connection for an outgoing request and resolve a hostname only once.
///
@@ -279,18 +323,19 @@ public static class FaviconExtractor
///
/// The HTTP client.
/// The URI to get the favicon from.
+ /// Token maxing the overall extraction.
/// The favicon bytes.
- private static async Task TryGetFaviconAsync(HttpClient client, Uri uri)
+ private static async Task 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);
}
///
@@ -298,10 +343,11 @@ public static class FaviconExtractor
///
/// The response to get the favicon nodes from.
/// The URI to get the favicon nodes from.
+ /// Token maxing the overall extraction.
/// The favicon nodes.
- private static async Task GetFaviconNodesFromHtml(HttpResponseMessage response, Uri uri)
+ private static async Task 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
/// The favicon nodes.
/// The HTTP client.
/// The base URI.
+ /// Token maxing the overall extraction.
/// The favicon bytes.
- private static async Task TryExtractFaviconFromNodes(HtmlNodeCollection[] faviconNodes, HttpClient client, Uri baseUri)
+ private static async Task TryExtractFaviconFromNodes(HtmlNodeCollection[] faviconNodes, HttpClient client, Uri baseUri, CancellationToken cancellationToken)
{
+ var seenUrls = new HashSet(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
///
/// The HTTP client.
/// The URL to fetch the favicon from.
+ /// Token maxing the overall extraction.
/// The favicon bytes.
- private static async Task FetchAndProcessFaviconAsync(HttpClient client, string url)
+ private static async Task 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
///
/// The HTTP client.
/// The initial URI to request.
+ /// Token maxing the overall extraction.
/// The final HTTP response after following redirects, or null if blocked/failed.
- private static async Task FollowRedirectsAsync(HttpClient client, Uri uri)
+ private static async Task 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)
{
diff --git a/apps/server/Utilities/AliasVault.FaviconExtractor/IPAddressValidator.cs b/apps/server/Utilities/AliasVault.FaviconExtractor/IPAddressValidator.cs
index d0ce68939..11aecb1c5 100644
--- a/apps/server/Utilities/AliasVault.FaviconExtractor/IPAddressValidator.cs
+++ b/apps/server/Utilities/AliasVault.FaviconExtractor/IPAddressValidator.cs
@@ -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
};
///
@@ -41,8 +43,14 @@ internal static class IPAddressValidator
///
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
};
///