đź’ˇ Full working example available on GitHub: merge-untrusted-html-uploads-dotnet

Safely merging untrusted HTML documents is a GroupDocs.Merger workflow for .NET that denies the external resources an upload references - remote URLs, UNC paths and local files - unless you explicitly allow them.

The Old Way Was Painful

Imagine your invoicing portal merges a customer’s invoice upload with a delivery note from the same order into one HTML document for archiving. The invoice is plain HTML - a stylesheet link, a logo image, a couple of paragraphs - except you did not write it, the customer’s browser or export tool did. Before GroupDocs.Merger 26.10, the merge loaded the external resources that HTML referenced - remote stylesheets, remote images, absolute local file paths - and had no option to stop it.

That is a problem the moment the upload is not fully trusted. A stylesheet URL can also be a tracking pixel. An image src can point at http://169.254.169.254/latest/meta-data/iam/security-credentials/ - a cloud metadata address - and your merge step would request it on the uploader’s behalf. An absolute file:/// path was read too, so a reference to another customer’s upload could pull that file into the merged output.

The real cost of merging blindly: a merge job that fetches whatever an upload asks for turns your server into a proxy for SSRF, and a file:/// reference into a way for one customer’s merge to read another customer’s file.

There’s a Better Way

GroupDocs.Merger for .NET fixes this at the default. Since version 26.10, every LoadOptions constructor sets SkipExternalResources = true, and a Merger created without load options uses that same default - nothing external loads unless you say otherwise.

Prerequisites:

  • .NET (any of the four target frameworks GroupDocs.Merger for .NET supports)
  • GroupDocs.Merger for .NET 26.10 or later (get a temporary license)
  • Two HTML documents to join, at least one from a source you don’t fully trust

Install with one command:

dotnet add package GroupDocs.Merger --version 26.10.0

The New Way: Merging HTML Without Trusting It

Here’s what joining two HTML uploads looks like with the secure defaults, a handler that records every decision, and - in a second pass - a handler that supplies one trusted asset.

Step 1 - Create Load Options for the Format

LoadOptions has no parameterless constructor and is sealed since 26.10; the sample names the format explicitly.

// LoadOptions(FileType.HTML) already denies everything external by default
var audit = new ResourceAudit();
var loadOptions = new LoadOptions(FileType.HTML) { ExternalResourceHandler = audit };

Step 2 - Join the Uploads and Inspect the Decisions

// Both uploads follow the options of the Merger they are joined to
using (var merger = new Merger(firstUpload, loadOptions))
{
    merger.Join(secondUpload);
    merger.Save(outputPath);
}

foreach (string decision in audit.Decisions)
{
    Console.WriteLine(decision);
}

Key points:

  • SkipExternalResources: implicitly true here, so every remote URL, UNC path and local file reference is denied, including a file in the upload’s own folder.
  • One LoadOptions per join: the second upload does not get its own options - it inherits whatever the Merger it was joined to was created with.

Step 3 - Allow Exactly One Trusted Resource

// Supply the logo's bytes directly instead of requesting it over the network
var trustedAssets = new Dictionary<string, byte[]>
{
    [logoUrl] = File.ReadAllBytes(logoPath)
};
var audit = new ResourceAudit(trustedAssets);
var loadOptions = new LoadOptions(FileType.HTML) { ExternalResourceHandler = audit };

The handler’s OnLoading method sets args.Skipped = false and args.Data to those bytes when the requested URI matches, so the document gets the logo without GroupDocs.Merger making a request anywhere.

When we ran the default merge against the invoice and delivery-note uploads, all seven external references across both files came back blocked - the remote stylesheet, the remote logo, the metadata-style URL, the sibling-folder image, a file:/// path, a UNC path, and a tracking URL with a query string. joined.html keeps the text from both uploads; a placeholder stands in for each blocked image. With the trusted-logo handler attached, the logo came back supplied and the other six stayed blocked.

Side-by-Side: Before vs. After

Before (no option) After (GroupDocs.Merger 26.10)
External requests Every reference loaded automatically Denied unless explicitly allowed
Absolute local file paths Loaded into the output Denied like any other reference
Trusted exceptions Not needed - everything loaded One resource via a handler, or a whitelisted prefix
Bad whitelist entry No whitelist existed Rejected with ArgumentException at construction
Code required No way to restrict loading A few lines on LoadOptions

The contrast is in what is possible, not just what is convenient: before 26.10 there was no setting that stopped the load; after it, denying is the default and every exception is explicit.

Will this break HTML that already depended on loading remote images?

If that HTML is still being merged with default LoadOptions, yes - the images will not load, and a placeholder takes their place in the output. The fix is explicit, not a workaround: list the trusted sources in WhitelistedResources, or attach an IExternalResourceHandler that supplies the specific assets you still want to render. Either way, the merge keeps running; it just stops fetching anything you have not named.

Real-World Example: Invoice and Delivery-Note Consolidation

A document pipeline that merges customer-submitted HTML into an archive copy is exactly the scenario this page’s sample project models: an invoice upload and a delivery note from the same order, joined into one file. Neither upload is something the pipeline’s own team wrote, so neither can be assumed safe to read literally. The invoice names an amount due and a customer, Northwind Traders, and carries a logo reference, a tracking stylesheet, a cloud-metadata-style URL, a same-folder image reference, and a file:/// path - seven external references between the two uploads, none of which the archiving pipeline should fetch on the customer’s behalf.

Before the merge job goes live, it is worth deciding, document type by document type, whether any exception is needed at all. This sample’s invoicing scenario needed exactly one: the company’s own logo, served from a file already on disk rather than fetched from the remote URL the HTML names. Everything else - the tracking stylesheet, the metadata-style address, the sibling file, the UNC and file:/// paths - had no legitimate reason to be fetched, so the defaults alone were the right answer for them.

// Reject a whitelist entry that is not an absolute URI prefix
var loadOptions = new LoadOptions(FileType.HTML)
{
    WhitelistedResources = new List<string> { "cdn.example.com" }
};

Passing a bare host name instead of an absolute prefix like https://cdn.example.com/brand/ throws, with the message Whitelisted resource 'cdn.example.com' must be an absolute URI without a query, fragment or encoded '/', '\' or '.'. (Parameter 'WhitelistedResources') - before either upload is even opened. Catching a misconfigured whitelist at startup is worth more than catching it after a resource silently failed to load in production.

What Else Can You Do with GroupDocs.Merger for .NET?

  • Validate whitelist entries early: a malformed WhitelistedResources entry fails at Merger construction, so a deployment’s startup checks catch it before any document is processed.
  • Supply trusted assets from disk: IExternalResourceHandler can answer a request with bytes you already have, with no network access at all.
  • Apply one policy to every joined document: LoadOptions is set once on the Merger, and every document later joined to it follows the same rules.
  • Cover more than HTML: word processing, HTML/MHTML, presentations, spreadsheets, SVG and EPUB documents all honor the same options.

Conclusion

Merging HTML you did not write no longer means trusting everything inside it. GroupDocs.Merger for .NET denies external resources by default since version 26.10, and the two ways to grant an exception - a trusted-asset handler and WhitelistedResources - give you exactly as much access as a given pipeline needs, nothing more.

Ready to try it yourself?

Additional Resources