From 8c662efc1cdc93aee8c1ee56567a9b8fdacb9483 Mon Sep 17 00:00:00 2001 From: NiLeonet <139857963+NiLeonet@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:12:54 +0200 Subject: [PATCH 1/3] bump dependencies and update to v1.6.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit itext: 9.6.0 → 9.7.0 (WebP support, dynamic page margins) itext.bouncy-castle-adapter: 9.6.0 → 9.7.0 itext.pdfhtml: 6.3.2 → 6.3.3 MailKit: 4.16.0 → 4.17.0 MimeKit: 4.16.0 → 4.17.0 (CVE GHSA-g7hc-96xr-gvvx) MsgReader: 6.0.11 → 6.1.0 OpenMcdf: 3.1.4 → 3.2.0 (CVE GHSA-5qwm-7pvp-w988, GHSA-jxpf-xq2m-q525) Package version: 1.6.0 → 1.6.1 --- src/PdfHelper/PdfHelper.csproj | 16 ++++++++-------- src/Tests/Tests.csproj | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/PdfHelper/PdfHelper.csproj b/src/PdfHelper/PdfHelper.csproj index 5896542..6c45cdb 100644 --- a/src/PdfHelper/PdfHelper.csproj +++ b/src/PdfHelper/PdfHelper.csproj @@ -4,7 +4,7 @@ netstandard2.0 Sitl.PdfHelper Sitl.Pdf - 1.6.0 + 1.6.1 true true bin\Sitl.PdfHelper.xml @@ -22,13 +22,13 @@ - - - - - - - + + + + + + + diff --git a/src/Tests/Tests.csproj b/src/Tests/Tests.csproj index dbd8cf8..0a111fd 100644 --- a/src/Tests/Tests.csproj +++ b/src/Tests/Tests.csproj @@ -29,7 +29,7 @@ - + all From e51218d944b7606d0fb391f2a35e6e0637f773db Mon Sep 17 00:00:00 2001 From: Nicolas Leonet <139857963+NiLeonet@users.noreply.github.com> Date: Mon, 5 Oct 2026 09:25:11 +0200 Subject: [PATCH 2/3] CVE-2025-56589 --- src/PdfHelper/Helpers/MimeMessageHelper.cs | 2 + .../Helpers/SafeResourceRetriever.cs | 127 ++++++++++++++++++ .../PdfHelperTests/SafeResourceRetriever.cs | 105 +++++++++++++++ 3 files changed, 234 insertions(+) create mode 100644 src/PdfHelper/Helpers/SafeResourceRetriever.cs create mode 100644 src/Tests/PdfHelperTests/SafeResourceRetriever.cs diff --git a/src/PdfHelper/Helpers/MimeMessageHelper.cs b/src/PdfHelper/Helpers/MimeMessageHelper.cs index bd5b1c6..d180b9e 100644 --- a/src/PdfHelper/Helpers/MimeMessageHelper.cs +++ b/src/PdfHelper/Helpers/MimeMessageHelper.cs @@ -153,6 +153,8 @@ public static async Task ConvertToPdfAsync(MimeMessage mimeMessage, Page var options = new ConverterProperties(); options.SetBaseUri("file:///"); + // Email HTML is untrusted: only public http/https resources are downloaded + options.SetResourceRetriever(new SafeResourceRetriever()); HtmlConverter.ConvertToPdf(html, pdfDoc, options); } diff --git a/src/PdfHelper/Helpers/SafeResourceRetriever.cs b/src/PdfHelper/Helpers/SafeResourceRetriever.cs new file mode 100644 index 0000000..65abbe1 --- /dev/null +++ b/src/PdfHelper/Helpers/SafeResourceRetriever.cs @@ -0,0 +1,127 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Sockets; + +namespace Sitl.Pdf { + + // Resource retriever used by pdfHTML when converting untrusted HTML (e.g. emails) to PDF. + // Remote resources (images, stylesheets, fonts) are still downloaded, but only when they are safe to fetch: + // - Only absolute http/https URLs on default ports (80/443): file://, UNC paths and relative URLs are blocked + // (prevents local file inclusion and NTLM hash leaks through SMB). + // - The host must only resolve to public IP addresses: loopback, private, link-local... addresses are blocked + // (prevents SSRF against the internal network or cloud metadata endpoints). + // - Redirects are followed manually and each target is checked again (a public URL could redirect to an internal one). + // - Size, count and time limits prevent a crafted HTML from overloading the server. + // These checks follow the OWASP SSRF prevention cheat sheet (application layer, "Case 2"): + // https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html + // Inline "data:" resources are resolved by iText itself and do not go through this retriever. + // Known limitation: the host is resolved before the request, so a DNS server answering differently + // between the check and the request (DNS rebinding) is not fully covered. + // Both retriever interfaces are implemented: iText.StyledXmlParser one is obsolete in favor of iText.IO one, + // but pdfHTML ConverterProperties.SetResourceRetriever() still only accepts the obsolete one. +#pragma warning disable CS0618 // Remove the obsolete interface once ConverterProperties accepts iText.IO.Resolver.Resource.IResourceRetriever + internal class SafeResourceRetriever : iText.IO.Resolver.Resource.IResourceRetriever, iText.StyledXmlParser.Resolver.Resource.IResourceRetriever { +#pragma warning restore CS0618 + private const int MaxResourceSize = 5 * 1024 * 1024; + private const long MaxTotalSize = 20 * 1024 * 1024; + private const int MaxResourceCount = 200; + private const int MaxRedirects = 5; + private static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(10); + private static readonly TimeSpan TotalTimeout = TimeSpan.FromSeconds(30); + + // HttpClient is shared (recommended usage), without automatic redirects and with a response size limit. + private static readonly HttpClient Client = new HttpClient(new HttpClientHandler { AllowAutoRedirect = false }) { + Timeout = RequestTimeout, + MaxResponseContentBufferSize = MaxResourceSize + }; + + private readonly Stopwatch _stopwatch = Stopwatch.StartNew(); + private int _resourceCount; + private long _totalSize; + + public Stream GetInputStreamByUrl(Uri url) { + var bytes = GetByteArrayByUrl(url); + return bytes != null ? new MemoryStream(bytes) : null; + } + + public byte[] GetByteArrayByUrl(Uri url) { + // Returning null makes pdfHTML skip the resource (the conversion goes on without it). + try { + if (!IsAllowedUrl(url)) return null; + if (_resourceCount >= MaxResourceCount || _totalSize >= MaxTotalSize || _stopwatch.Elapsed >= TotalTimeout) return null; + _resourceCount++; + + for (int i = 0; i <= MaxRedirects; i++) { + using (var response = Client.GetAsync(url).ConfigureAwait(false).GetAwaiter().GetResult()) { + if (IsRedirect(response.StatusCode)) { + // Redirect target (often http to https) must pass the same checks as the original URL + if (response.Headers.Location == null) return null; + url = response.Headers.Location.IsAbsoluteUri ? response.Headers.Location : new Uri(url, response.Headers.Location); + if (!IsAllowedUrl(url)) return null; + continue; + } + + if (!response.IsSuccessStatusCode) return null; + var bytes = response.Content.ReadAsByteArrayAsync().ConfigureAwait(false).GetAwaiter().GetResult(); + _totalSize += bytes.Length; + return bytes; + } + } + return null; + } catch { + return null; + } + } + + private static bool IsRedirect(HttpStatusCode statusCode) { + var code = (int)statusCode; + return code == 301 || code == 302 || code == 303 || code == 307 || code == 308; + } + + internal static bool IsAllowedUrl(Uri url) { + if (url == null || !url.IsAbsoluteUri) return false; + if (url.Scheme != Uri.UriSchemeHttp && url.Scheme != Uri.UriSchemeHttps) return false; + if (!url.IsDefaultPort) return false; + + // Every address the host resolves to must be public, as the HTTP client may use any of them. + IPAddress[] addresses; + if (IPAddress.TryParse(url.DnsSafeHost, out var ip)) addresses = new[] { ip }; + else addresses = Dns.GetHostAddresses(url.DnsSafeHost); + + return addresses.Length > 0 && addresses.All(IsPublicAddress); + } + + internal static bool IsPublicAddress(IPAddress ip) { + if (ip.IsIPv4MappedToIPv6) ip = ip.MapToIPv4(); + if (IPAddress.IsLoopback(ip)) return false; + + if (ip.AddressFamily == AddressFamily.InterNetwork) { + var b = ip.GetAddressBytes(); + if (b[0] == 0) return false; // 0.0.0.0/8 (this network) + if (b[0] == 10) return false; // 10.0.0.0/8 (private) + if (b[0] == 100 && b[1] >= 64 && b[1] <= 127) return false; // 100.64.0.0/10 (carrier-grade NAT) + if (b[0] == 169 && b[1] == 254) return false; // 169.254.0.0/16 (link-local, cloud metadata) + if (b[0] == 172 && b[1] >= 16 && b[1] <= 31) return false; // 172.16.0.0/12 (private) + if (b[0] == 192 && b[1] == 0 && b[2] == 0) return false; // 192.0.0.0/24 (IETF protocol assignments) + if (b[0] == 192 && b[1] == 168) return false; // 192.168.0.0/16 (private) + if (b[0] == 198 && (b[1] == 18 || b[1] == 19)) return false;// 198.18.0.0/15 (benchmarking) + if (b[0] >= 224) return false; // 224.0.0.0/4 multicast, 240.0.0.0/4 reserved, broadcast + return true; + } + + if (ip.AddressFamily == AddressFamily.InterNetworkV6) { + if (ip.Equals(IPAddress.IPv6Any) || ip.Equals(IPAddress.IPv6None)) return false; + if (ip.IsIPv6LinkLocal || ip.IsIPv6SiteLocal || ip.IsIPv6Multicast) return false; + var b = ip.GetAddressBytes(); + if ((b[0] & 0xFE) == 0xFC) return false; // fc00::/7 (unique local) + return true; + } + + return false; + } + } +} diff --git a/src/Tests/PdfHelperTests/SafeResourceRetriever.cs b/src/Tests/PdfHelperTests/SafeResourceRetriever.cs new file mode 100644 index 0000000..a1c53b9 --- /dev/null +++ b/src/Tests/PdfHelperTests/SafeResourceRetriever.cs @@ -0,0 +1,105 @@ +using System.Diagnostics; +using System.Net; +using System.Net.Sockets; +using iText.Kernel.Pdf; +using MimeKit; +using Sitl.Pdf.Tests.Resources; +using Xunit.Abstractions; + +namespace Sitl.Pdf.Tests.PdfHelperTests { + + // Checks the resources that email HTML is allowed to load when converted to PDF (SSRF / local file inclusion protection). + public class SafeResourceRetriever(ITestOutputHelper output) : TestBase(output) { + + [Theory] + [InlineData("http://127.0.0.1/image.png")] + [InlineData("http://localhost/image.png")] + [InlineData("http://[::1]/image.png")] + [InlineData("http://10.255.255.1/image.png")] + [InlineData("http://172.16.0.1/image.png")] + [InlineData("http://192.168.255.254/image.png")] + [InlineData("http://169.254.169.254/latest/meta-data/")] + [InlineData("http://[fd00::1]/image.png")] + [InlineData("http://example.com:8080/image.png")] + [InlineData("ftp://example.com/image.png")] + public async Task ConvertFromEmailBlocksUrl(string url) { + // A blocked URL is skipped immediately: no image and no network timeout (a request would wait up to 10s) + var stopwatch = Stopwatch.StartNew(); + using var pdfHelper = await PdfHelper.FromEmailAsync(CreateEmail($"Test"), EmailType.Mime, PageSizes.A4); + stopwatch.Stop(); + + Assert.Equal(0, CountImages(pdfHelper.ToByteArray())); + Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(5), $"Conversion took {stopwatch.Elapsed}, the URL was probably requested"); + } + + [Fact] + public async Task ConvertFromEmailBlocksLocalResources() { + // Local HTTP server that must never be called + int port; + var socket = new TcpListener(IPAddress.Loopback, 0); + socket.Start(); + port = ((IPEndPoint)socket.LocalEndpoint).Port; + socket.Stop(); + + using var listener = new HttpListener(); + listener.Prefixes.Add($"http://localhost:{port}/"); + listener.Start(); + var requestReceived = listener.GetContextAsync(); + + // Local image file that must never be embedded + var imagePath = GetTestFileName(extension: ".jpg"); + File.WriteAllBytes(imagePath, ResourceLoader.ReadAsBytes("SampleImage1.jpg")); + + var html = "Test" + + $"" + + $"" + + $"" + + ""; + + using var pdfHelper = await PdfHelper.FromEmailAsync(CreateEmail(html), EmailType.Mime, PageSizes.A4); + pdfHelper.Save(GetTestFileName()); + + Assert.Equal(0, CountImages(pdfHelper.ToByteArray())); + var completed = await Task.WhenAny(requestReceived, Task.Delay(TimeSpan.FromSeconds(1))); + Assert.False(completed == requestReceived, "The local HTTP server should not have been called"); + } + + [Theory] + [InlineData("https://www.google.com/images/branding/googlelogo/1x/googlelogo_color_272x92dp.png")] + [InlineData("http://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png")] // http to https redirect + public async Task ConvertFromEmailWithRemoteImage(string url) { + // Requires Internet access + var html = $"Test"; + + using var pdfHelper = await PdfHelper.FromEmailAsync(CreateEmail(html), EmailType.Mime, PageSizes.A4); + pdfHelper.Save(GetTestFileName()); + + Assert.Equal(1, CountImages(pdfHelper.ToByteArray())); + } + + private static byte[] CreateEmail(string html) { + var message = new MimeMessage(); + message.From.Add(MailboxAddress.Parse("sender@example.com")); + message.To.Add(MailboxAddress.Parse("recipient@example.com")); + message.Subject = "Test"; + message.Body = new TextPart("html") { Text = html }; + + using var ms = new MemoryStream(); + message.WriteTo(ms); + return ms.ToArray(); + } + + private static int CountImages(byte[] pdf) { + using var pdfDoc = new PdfDocument(new PdfReader(new MemoryStream(pdf))); + var count = 0; + for (int i = 1; i <= pdfDoc.GetNumberOfPages(); i++) { + var xObjects = pdfDoc.GetPage(i).GetResources().GetResource(PdfName.XObject); + if (xObjects == null) continue; + foreach (var name in xObjects.KeySet()) { + if (PdfName.Image.Equals(xObjects.GetAsStream(name)?.GetAsName(PdfName.Subtype))) count++; + } + } + return count; + } + } +} From f9fca8b91f7cfe22c93ee071d8c187ea3495d532 Mon Sep 17 00:00:00 2001 From: NiLeonet <139857963+NiLeonet@users.noreply.github.com> Date: Mon, 5 Oct 2026 09:51:11 +0200 Subject: [PATCH 3/3] Migration 1.6.2 Migration 1.6.2 --- src/PdfHelper/PdfHelper.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PdfHelper/PdfHelper.csproj b/src/PdfHelper/PdfHelper.csproj index 6c45cdb..8f0ab4d 100644 --- a/src/PdfHelper/PdfHelper.csproj +++ b/src/PdfHelper/PdfHelper.csproj @@ -4,7 +4,7 @@ netstandard2.0 Sitl.PdfHelper Sitl.Pdf - 1.6.1 + 1.6.2 true true bin\Sitl.PdfHelper.xml