Skip to content

The wkhtmltopdf repository was archived on 2 January 2023 and is read-only. The last release, 0.12.6, came out on 10 June 2020. PdfBroker.io runs wkhtmltopdf as a hosted service alongside WeasyPrint, so this article covers what the archival actually changes for a running system, which parts are urgent and which are not, and what the realistic paths forward are.

Short version: the binary you have still works. What stops working is your ability to install it.

What actually happened

Three separate things get run together in most write-ups, and they have very different timelines.

The GitHub repository went read-only in January 2023. No more releases, no more patches, no more issue triage. The code is still there and still builds.

The rendering engine underneath it is much older than that. wkhtmltopdf uses a patched fork of QtWebKit, and QtWebKit itself was dropped by Qt in 2016. So the CSS support you are getting today is roughly what a browser could do in 2014 — no CSS Grid, no modern flexbox, no gap.

And then Debian removed the package from testing on 5 February 2025. That is the one with a date on your calendar.

The CVE, and why it is probably not your emergency

CVE-2022-35583 is an SSRF in 0.12.6, scored 9.8 critical. An attacker who controls the HTML you render can inject an <iframe> pointing at an internal address and get wkhtmltopdf to fetch it for them — cloud metadata endpoints, internal admin panels, anything your renderer can reach on the network.

It is unfixed and will stay unfixed. Upstream declined to patch it, taking the position that this is an application-level concern rather than a defect in the tool, and that callers should sanitise HTML before passing it in. Ubuntu's security team recorded that response in July 2023. The vulnerability is still listed as vulnerable in Focal, Jammy and Noble.

Whether this matters to you depends on one question: does any part of your HTML come from a user?

If you render invoices from your own templates with your own data, an attacker has nothing to inject and the CVSS score overstates your exposure considerably. If you render user-submitted HTML, or HTML containing user-supplied URLs, or Markdown that users can put raw HTML into, then you have a live SSRF and no patch is coming. Egress filtering on the container that runs the binary is the usual mitigation, and it is worth doing regardless.

Most teams reading this are in the first group. That is why the CVE, on its own, has not forced many migrations in three years.

The thing that will actually break your build

Packaging is what gets people, and it gets them at an awkward moment.

wkhtmltopdf is present in Debian 11 and Debian 12 (0.12.6-1 and 0.12.6-2 respectively). It is not in Debian 13. Homebrew disabled its cask, which surfaced downstream as build failures in projects that had been installing it that way for years — the Frappe framework hit this and had to change its install path.

So the failure mode is not a security incident. It is a Tuesday afternoon where someone bumps a base image from debian:bookworm to debian:trixie in a Dockerfile, and a build that has been green for four years goes red with a package-not-found. The person doing it is usually not the person who chose wkhtmltopdf, and has no idea why a PDF library is in this image at all.

You can pin the old base image. You can vendor the .deb and install it manually. Both work, and both mean you are now maintaining a distribution channel for an unmaintained binary, on an OS release that will eventually stop getting security updates of its own.

Four options, honestly

Do nothing, deliberately. Pin your base image, write down why in the Dockerfile, and set a reminder for when that Debian release goes end of life. This is a legitimate choice for an internal tool that renders a handful of documents a month from trusted templates. It is not a legitimate choice if you never write the reminder down.

Vendor the binary. Copy the .deb into your own artefact store and install from there. It buys you time and costs you a small amount of ongoing attention. Combine it with egress filtering if any input is user-controlled.

Migrate to a maintained renderer. WeasyPrint is the natural target if your documents are documents — invoices, reports, statements, anything built around page layout. It supports CSS Paged Media properly, which wkhtmltopdf never did: @page rules, margin boxes, running headers, page-break-inside: avoid that actually holds. It does not run JavaScript at all, which is either irrelevant or a blocker depending on your templates. Headless Chromium is the other direction, and brings a 300 MB layer and cold starts with it.

Stop shipping the binary and call an API. This is the option we sell, so weigh it accordingly. It removes the binary from your image without changing your HTML, which is the part that makes migration slow.

Keeping wkhtmltopdf without maintaining it

If your templates are tuned to QtWebKit's quirks — and after a few years they always are — the fastest way out of the packaging problem is to keep the same renderer and stop hosting it yourself. PdfBroker.io runs wkhtmltopdf as a service, so the same engine family renders your templates while the binary leaves your Dockerfile.

One thing to check before you switch: the service does not run a single build. Most requests are rendered by wkhtmltopdf 0.12.5 on Qt 4.8.7, and documents that declare @font-face are routed to 0.12.6 on Qt 5.15.13 so that custom fonts resolve. If your templates were tuned specifically against 0.12.6, render a handful of real documents through the API and compare them against your current output before you commit to the move. The differences between the two are small, but they are not nothing, and you would rather find them now than in an invoice run.

Authentication is OAuth 2.0 client credentials. Get a token from https://login.pdfbroker.io/connect/token using the client id and secret from Members → API, then send it as a bearer token. Tokens are valid for an hour, so cache them.

TOKEN=$(curl -s -X POST https://login.pdfbroker.io/connect/token \
  -d grant_type=client_credentials \
  -d client_id=$PDFBROKER_CLIENT_ID \
  -d client_secret=$PDFBROKER_CLIENT_SECRET | jq -r .access_token)

curl -X POST https://api.pdfbroker.io/api/pdf/wkhtmltopdf \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -o invoice.pdf \
  -d '{
        "htmlBase64String": "'"$(base64 -w0 invoice.html)"'",
        "wkHtmlToPdfArguments": {
          "page-size": "A4",
          "javascript-delay": "500"
        }
      }'

The arguments object takes the same flags you were passing on the command line, in the same kebab-case, so an existing invocation ports across mostly by copying it. Some are dropped, and they are dropped silently rather than rejected, so it is worth knowing which before you wonder why a flag had no effect.

The informational flags go first, since they would print text instead of producing a PDF: help, h, H, extended-help, manpage, readme, license, htmldoc, version, V, and read-args-from-stdin. So do debug-javascript and no-debug-javascript.

The two that actually catch people out are enable-local-file-access and allow. The service sets its own file-access policy per request and grants access to exactly one temporary directory, so a flag that widens it cannot be honoured. Local images and fonts come in through the resources object instead — that is the replacement, and it is a better one, because the files travel with the request rather than having to exist on a server you no longer manage.

In C#, with IHttpClientFactory handling the token via a delegating handler:

public sealed record WkHtmlToPdfRequest(
    string? Url,
    string? HtmlBase64String,
    IReadOnlyDictionary<string, string> WkHtmlToPdfArguments);

public interface IPdfRenderer
{
    Task<byte[]> RenderAsync(WkHtmlToPdfRequest request, CancellationToken ct = default);
}

public sealed class PdfBrokerRenderer : IPdfRenderer
{
    private readonly HttpClient _http;

    public PdfBrokerRenderer(HttpClient http) => _http = http;

    public async Task<byte[]> RenderAsync(
        WkHtmlToPdfRequest request,
        CancellationToken ct = default)
    {
        using var response = await _http.PostAsJsonAsync("api/pdf/wkhtmltopdf", request, ct);
        response.EnsureSuccessStatusCode();
        return await response.Content.ReadAsByteArrayAsync(ct);
    }
}

Registering it:

builder.Services.AddHttpClient<IPdfRenderer, PdfBrokerRenderer>(client =>
{
    client.BaseAddress = new Uri("https://api.pdfbroker.io/");
})
.AddHttpMessageHandler<PdfBrokerTokenHandler>();

Putting it behind IPdfRenderer is worth the five minutes. It is the seam you will use later if you switch engines, and it lets you fake the renderer in tests instead of shelling out to a binary that may or may not exist on the build agent.

There is also a url field if you would rather point the renderer at a page than send HTML, and a resources object for images and fonts. The wkhtmltopdf service documentation has the full request shape.

When you are ready to move off it

Switching to WeasyPrint is a change of endpoint and a change of arguments:

using var response = await _http.PostAsJsonAsync("api/pdf/weasyprint", new
{
    HtmlBase64String = html,
    WeasyPrintToPdfArguments = new Dictionary<string, string>
    {
        ["pdf-variant"] = "pdf/a-2b"
    }
}, ct);

Your templates are the actual work, not the call. Headers and footers have to be rewritten as CSS rather than ported, page numbers move from string substitution to counters, and anything that depended on JavaScript running has to move into your application — the CSS differences that break your templates goes through each of them. Set aside real time for it and do it while the old path still works, which is the argument for moving the binary out of your image first and changing renderers second rather than doing both in one release.

Running the API also gets you PDF/A and PDF/UA output, which wkhtmltopdf cannot produce at all. If the European Accessibility Act applies to your documents, that is a separate reason to be on WeasyPrint, and the PDF variants reference covers the conformance levels.

What to do this week

Find out whether any of the HTML you render contains input from a user. That single answer tells you whether the CVE is a background risk or a thing to fix now.

Then grep your Dockerfiles for wkhtmltopdf and check what base image each one pins. If any of them tracks a moving tag, your build is going to break on someone else's schedule rather than yours.

Neither of those requires a decision about renderers. They just tell you how much time you have.