Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/PdfHelper/Helpers/MimeMessageHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,8 @@ public static async Task<Stream> 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);
}
Expand Down
127 changes: 127 additions & 0 deletions src/PdfHelper/Helpers/SafeResourceRetriever.cs
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
4 changes: 3 additions & 1 deletion src/PdfHelper/PdfHelper.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
<TargetFramework>netstandard2.0</TargetFramework>
<AssemblyName>Sitl.PdfHelper</AssemblyName>
<RootNamespace>Sitl.Pdf</RootNamespace>
<Version>1.6.1</Version>

<Version>1.6.2</Version>

<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<DocumentationFile>bin\Sitl.PdfHelper.xml</DocumentationFile>
Expand Down
105 changes: 105 additions & 0 deletions src/Tests/PdfHelperTests/SafeResourceRetriever.cs
Original file line number Diff line number Diff line change
@@ -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($"<html><body>Test<img src=\"{url}\"/></body></html>"), 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 = "<html><body>Test"
+ $"<img src=\"http://localhost:{port}/image.jpg\"/>"
+ $"<img src=\"{new Uri(imagePath).AbsoluteUri}\"/>"
+ $"<img src=\"{imagePath}\"/>"
+ "</body></html>";

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 = $"<html><body>Test<img src=\"{url}\"/></body></html>";

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