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 }; ///