If your .NET app still calls DinkToPdf, NReco.PdfGenerator, Rotativa, or WkHtmlToXSharp in production, you're shipping a wrapper around a binary that hasn't had a security patch in years — and one of its open holes is a CVSS 9.8.
wkhtmltopdf's repository has been frozen and read-only since January 2023, and the whole project org was archived by mid-2024. Development has stopped; nobody is accepting patches. The underlying QtWebKit engine forked from upstream around 2012 — no modern Flexbox, no Grid, no modern JavaScript, no ICU updates. And CVE-2022-35583 — an SSRF that lets an attacker reach your internal network by injecting an <iframe> pointing at an internal IP — sits at CVSS 9.8, unpatched, and permanently so.
If your app takes any user-influenced HTML and turns it into a PDF behind a public endpoint, that's not a "someday" cleanup. This post is the migration I'd actually run.
Disclosure: I work on SelectPdf, a .NET PDF library, and the code below is SelectPdf because that's what I can vouch for line by line. I'll be honest about where it fits and where it doesn't — including a Windows/Linux fork you need to know about before you write any code. The broader point stands no matter what you pick: stop wrapping the dead binary.
First, the honest part: Windows or Linux?
I'm putting this at the top because it decides everything downstream, and nothing annoys an engineer more than finding it in paragraph 30.
- If you deploy to Windows (App Service on Windows, Windows containers, a VM, IIS) — the SelectPdf library is a direct NuGet reference: one synchronous API call, no separate service to stand up. This whole guide applies. Keep reading.
- If you deploy to Linux (Linux containers, Linux App Service, AKS) — the library is Windows-only. Don't fight it. The cross-platform answer is SelectPdf's Online REST API: your app POSTs HTML and gets back PDF bytes over HTTPS, so it runs on any OS and any language. Same rendering engine, none of the native-dependency wrestling.
Yes — wkhtmltopdf's Linux story is genuinely bad now (newer glibc/libssl combos crash it, and it's no one's job to fix). I'm not going to pretend the SelectPdf library fixes Linux; it doesn't run there. It moves the rendering off-box to the API instead. If rendering must stay self-hosted on Linux and you don't want a hosted call, that's a legitimate reason to look at a headless-Chrome option instead — which brings us to the other question everyone asks.
"Why not just use Playwright or PuppeteerSharp?"
Fair question, and for some teams that's the right call — they're free, cross-platform, and render with real Chromium. Here's the honest trade:
With Playwright/PuppeteerSharp you operate the browser yourself. You run a provisioning step (playwright install, or bundling the binaries) and version-manage Chromium; you own the headless process lifecycle (zombie processes are real under load); you write your own concurrency throttling; and you write and maintain the automation script. On Linux you also chase down the font and shared-library packages so text doesn't render as boxes. On Windows App Service specifically, a raw headless browser tends to fight the sandbox too — a similar restriction to the Win32k/GDI wall that bites wkhtmltopdf (more on that below).
Now the honest part, about what is not a difference: with SelectPdf's Chromium engine there is still a browser process, and it ships a bundled Chromium (CEF) runtime of broadly similar size — so the disk/image footprint and cold-start cost are roughly a wash. Chromium is Chromium. What differs is who provisions and babysits it. SelectPdf ships the matched runtime as a NuGet package that MSBuild copies into your output automatically (no separate install step, no version drift), launches and supervises the render process for you, isolates it so a crash doesn't take your app down, and caps parallelism through one built-in setting (MaximumConcurrentConversions) — all behind a single synchronous call that returns bytes. Its CEF engine is also built specifically to run inside the App Service sandbox where a raw headless browser struggles. You don't write automation, and you don't provision a browser fleet.
And there's a scope difference that outlasts the migration: Playwright and Puppeteer only print HTML to PDF. The moment your requirements grow past "render this page" — merge the invoice with a cover sheet, stamp a watermark, fill an AcroForm, digitally sign it, encrypt it, or extract the text back out for search — you're bolting on a second PDF library (PdfSharp, iText, QuestPDF) and gluing two toolchains together. SelectPdf's commercial library does the rendering and that post-processing in one dependency: create PDFs from scratch, merge/split, stamp, fill and flatten forms, sign, encrypt, extract text and images, and emit tagged PDF/UA or PDF/A for accessibility and archival. (The free Community Edition is HTML→PDF only — these extras are the paid library.) If rendering HTML is genuinely all you'll ever do, that breadth doesn't matter and Puppeteer is fine. If you can feel a "can you also…" ticket coming, one library beats two.
If your team is happy operating headless Chrome directly and never needs more than print-to-PDF, Puppeteer is a perfectly defensible choice. Pick with eyes open.
(One more note on the "is it even alive?" question, since wkhtmltopdf just taught everyone to ask it: SelectPdf shipped **v26.3 on June 16, 2026, and the core packages sit around **22M+ NuGet downloads. Actively maintained, with a paid tier where a human answers.)
What "archived" actually means for your stack
Most teams know wkhtmltopdf is "old" but haven't priced in the archive:
- No more CVE patches. Ever. CVE-2022-35583 is filed, triaged, and will never be fixed upstream. Any SSRF reported after the repo froze lands in a read-only tracker.
- QtWebKit is frozen near 2012. CSS Grid, container queries, modern JS, modern font features — anything your designers shipped in the last decade renders unreliably or not at all.
-
The .NET wrappers fix none of this.
DinkToPdf,NReco.PdfGenerator,Rotativa.AspNetCore,WkHtmlToXSharpall P/Invoke into the samelibwkhtmltoxnative binary. If wkhtmltopdf isn't patched, neither are they. (DinkToPdf itself hasn't shipped a release since 2017.)
The migration, in code
Here's a typical DinkToPdf setup — note the injected IConverter, which DinkToPdf requires as a singleton because the native lib can't be loaded repeatedly:
// Old: DinkToPdf
using DinkToPdf;
using DinkToPdf.Contracts;
public class InvoiceController : ControllerBase
{
private readonly IConverter _converter;
public InvoiceController(IConverter converter) => _converter = converter;
[HttpGet("invoice/{id}")]
public IActionResult Get(int id)
{
var html = RenderInvoiceHtml(id);
var doc = new HtmlToPdfDocument
{
GlobalSettings = {
PaperSize = PaperKind.A4,
Orientation = Orientation.Portrait,
Margins = new MarginSettings { Top = 20, Bottom = 20 }
},
Objects = { new ObjectSettings { HtmlContent = html } }
};
var pdf = _converter.Convert(doc);
return File(pdf, "application/pdf", $"invoice-{id}.pdf");
}
}
Same endpoint with SelectPdf:
// New: SelectPdf
using SelectPdf;
public class InvoiceController : ControllerBase
{
[HttpGet("invoice/{id}")]
public IActionResult Get(int id)
{
var html = RenderInvoiceHtml(id);
var converter = new HtmlToPdf();
converter.Options.RenderingEngine = RenderingEngine.Chromium;
converter.Options.PdfPageSize = PdfPageSize.A4;
converter.Options.PdfPageOrientation = PdfPageOrientation.Portrait;
converter.Options.MarginTop = 20;
converter.Options.MarginBottom = 20;
PdfDocument doc = converter.ConvertHtmlString(html);
try
{
byte[] bytes = doc.Save();
return File(bytes, "application/pdf", $"invoice-{id}.pdf");
}
finally
{
doc.Close();
}
}
}
About that lifecycle change
You probably noticed the DI singleton is gone. That's deliberate, and it's the question a careful reviewer should ask: is new HtmlToPdf() per request safe?
Yes. Unlike DinkToPdf's converter, there's no "load the native lib exactly once" dance — you construct a converter per conversion. Parallelism is governed by a single knob, Options.MaximumConcurrentConversions (default 4), which you set once at startup, before the first conversion; requests beyond the limit queue for a slot rather than thrashing the box. So the mental model is: new freely per request, cap total concurrency globally. If you'd rather keep a DI registration for testability, register the converter as transient — it doesn't need to be a singleton.
NuGet packages
# Free Community Edition — HTML->PDF only, 5-page cap;
# no PDF post-processing (merge/split, signing, encryption, extraction)
dotnet add package Select.HtmlToPdf.NetCore
dotnet add package Select.HtmlToPdf.NetCore.Chromium.Windows
# Commercial library once you outgrow 5 pages (from $499, perpetual)
dotnet add package Select.Pdf.NetCore
dotnet add package Select.Pdf.NetCore.Chromium.Windows
Be honest with yourself about that 5-page cap: it's free forever, but a typical multi-page invoice or report blows past it, so for real workloads the CE is effectively an evaluation and you'll want a license (pricing). The Chromium.Windows runtime packages are AnyCPU (they run in both x86 and x64 workers); the commercial library additionally ships .x64-pinned variants, the CE does not. On .NET Framework the same packages work — SelectPdf still targets net20, net40, net461, net472, and netstandard2.0, so migrating doesn't force a TFM bump.
Migrating your wkhtmltopdf flags
The ConvertHtmlString call is the easy 20%. The part that actually took you an afternoon in wkhtmltopdf was the CLI flags — headers, footers, page numbers, margins. Here's the mapping so you're not reverse-engineering it:
| wkhtmltopdf | SelectPdf |
|---|---|
--page-size A4 |
Options.PdfPageSize = PdfPageSize.A4 |
--orientation Landscape |
Options.PdfPageOrientation = PdfPageOrientation.Landscape |
--margin-top 20 (etc.) |
Options.MarginTop = 20 (MarginBottom/Left/Right) |
--header-html header.html |
Options.DisplayHeader = true; then converter.Header.Add(new PdfHtmlSection(url))
|
--footer-html footer.html |
Options.DisplayFooter = true; then converter.Footer.Add(...)
|
[page] / [topage] footer tokens |
{page_number} / {total_pages} in a PdfTextSection
|
--print-media-type |
Options.CssMediaType = HtmlToPdfCssMediaType.Print |
Footer with page numbers, end to end:
converter.Options.DisplayFooter = true;
converter.Footer.Height = 40;
var pageNo = new PdfTextSection(0, 10,
"Page {page_number} of {total_pages}",
new System.Drawing.Font("Arial", 9));
pageNo.HorizontalAlign = PdfTextHorizontalAlign.Right;
converter.Footer.Add(pageNo);
That — headers, footers, page numbering — all works in the free Community Edition too, within the 5-page cap.
The Azure App Service angle
This is the one I get asked about most, so I'll be explicit.
Azure App Service runs your app in a Windows sandbox that blocks most User32/GDI32 (Win32k.sys) system calls — exactly what a GDI-based browser engine bundled in a .NET app needs. That's what makes wkhtmltopdf flaky there: P/Invoke load failures, missing GDI calls, font issues. Bundled headless-Chrome setups tend to struggle in the same sandbox too, for related reasons (process/GPU restrictions rather than GDI specifically).
SelectPdf's Chromium engine — a CEF/Chromium backend introduced in v26.2, on Chromium 148 as of v26.3 — is built to run inside that sandbox. The same code you run on your dev machine runs unchanged on Azure App Service (Basic plan and above), and on Azure Functions when hosted on a Premium or App Service plan (not Consumption or Free/Shared). From your code it's the one line you already saw:
var converter = new HtmlToPdf();
converter.Options.RenderingEngine = RenderingEngine.Chromium;
PdfDocument doc = converter.ConvertUrl("https://example.com/report");
doc.Save("report.pdf");
doc.Close();
No "but it works locally" Slack thread at 11pm.
Closing the SSRF loop
We opened on CVE-2022-35583, so let's not hand-wave the fix. Two things change when you migrate:
-
You get a kill-switch for local-file exfiltration. Set
HtmlToPdfOptions.DenyLocalFileAccess = trueper conversion, orGlobalProperties.ForceDenyLocalFileAccess = trueprocess-wide, andfile://reads are blocked — shutting off the local-file-read half of the classic wkhtmltopdf attack. - The internal-IP SSRF half is a network problem, and the right answer depends on trust. If you convert genuinely untrusted, user-supplied HTML, don't render it on your own infrastructure next to your internal network at all — push it to the Online REST API, which runs the conversion in an isolated, SSRF-guarded environment off your infrastructure. And unlike the archived binary, when a rendering-side issue is found, there's a maintained product that can actually ship a fix.
That's the real difference: not "SelectPdf is magically SSRF-proof," but "you get controls, isolation options, and someone who patches."
What to actually do this week
A pragmatic checklist:
-
grepthe solution forDinkToPdf,NReco.PdfGenerator,Rotativa,WkHtmlToX,wkhtmltopdf. Every hit is a candidate. - Decide Windows vs Linux first (see the top) — library or Online API. Don't skip this.
- Pick the one endpoint that renders user-influenced HTML. Highest-risk surface; start there.
-
dotnet add package Select.HtmlToPdf.NetCore+Select.HtmlToPdf.NetCore.Chromium.Windows. Free, 5-page cap. - Port the converter call and your header/footer flags using the mapping above; run your existing tests; eyeball the output diff.
- If layout shifts (it might — wkhtmltopdf has 2012-era quirks your CSS may lean on), the fix is almost always a CSS cleanup, not a converter tweak.
- Past 5 pages, get a license — but validate output on the free build first.
If SelectPdf isn't right for your stack, still migrate. Anything maintained beats an archived 9.8. And if you try it and hit a snag, drop me a line — I read the inbox.
Try it:
- Free Community Edition: selectpdf.com/community-edition
- Live demo (paste any URL): selectpdf.com/demo
- Cross-platform Online API: selectpdf.com/api-pricing
- Docs: selectpdf.com/pdf-library
- NuGet:
Select.HtmlToPdf.NetCore
If this was useful, follow — the next post is "Top 10 HTML-to-PDF Libraries for C# / .NET (2026): A Practical Comparison," a fair head-to-head of the real options in this space, including where SelectPdf isn't the right pick.
Top comments (0)