PDFPipe

Document security / Who can reach the document

Rendering HTML or a URL that a user supplied, safely

What a renderer can be made to do when the thing it renders is chosen by somebody else, which is a server-side request forgery problem before it is a rendering problem.

The exposure

A renderer fetches things. Given a URL, it fetches that URL, and given markup, it fetches every stylesheet, image, font and frame the markup references. If any of those are chosen by a user, the renderer becomes a way to make requests from inside your infrastructure to addresses the user cannot reach directly: internal services, metadata endpoints, private ranges. The response can then be exfiltrated into the document. That is the exposure, and it exists whether or not the user ever sees the rendered result.

The decisions

Reasons rather than a description of the code. Each one has a default that is fine for a page and wrong for a file somebody keeps.

  • Treat any user-influenced URL or markup as untrusted input to a fetching engine, not as content.
  • Rely on the platform check and understand what it covers: url and webhook_url are rejected with ssrf_blocked when the host resolves to a private or reserved address, and redirects are vetted too rather than only the first hop.
  • Do not add your own allowlist by string matching on the URL. Hostnames can resolve anywhere, and a check that does not resolve the name is not a check.
  • Prefer supplying markup you assembled from user data over rendering markup a user supplied, because the first is a templating problem and the second is an execution problem.
  • Escape user data when interpolating it into markup, since a value containing a tag becomes an element and an element can reference a URL.
  • Never put a credential in the markup or in an asset URL. Anything the document references is fetched by a renderer and may end up in the file.

In practice

A fragment, with the thing that goes wrong kept in a comment where it is the more instructive half.

js
// Preferred: assemble the markup yourself from user data. This is a
// templating problem, and escaping solves it.
const html = template({
  customerName: escapeHtml(user.name),      // a tag here becomes an element
  note: escapeHtml(user.note),
});
await pdf.post("/v1/pdf", { html, options: { format: "A4" } });

/* If a user supplies a URL, the platform check is doing real work:

     url and webhook_url are rejected with ssrf_blocked when the host
     resolves to a private or reserved address, and redirects are vetted
     rather than only the first hop.

   That is the check that matters, because the naive version fails:

     // Not a check. The hostname can resolve anywhere.
     if (url.startsWith("https://")) allow(url);
     if (!url.includes("localhost")) allow(url);

   A name under the user's control can resolve to a private address, and
   a public URL can redirect to one. Only resolution-time checking on
   every hop catches either.                                          */

// And never this: the credential is fetched by a renderer and can end
// up inside the document.
//   <img src="https://internal/report?token=SECRET">

What people do instead

Validating a user-supplied URL by inspecting the string. It rejects the obvious cases and accepts a hostname under the user's control that resolves wherever they choose, which is the actual attack rather than the one the check was imagining.

How this is found out

By a security assessment, or by an attacker. There is no ordinary symptom: the renders succeed and the documents look normal, because from the pipeline's point of view nothing went wrong.

Frequently asked

Does this page tell me what the law requires?

No, and deliberately not. Retention periods, erasure obligations and residency rules vary by jurisdiction, by industry and by the kind of document, and they change. What these pages describe is the shape of the problem and the mechanisms a system needs in order to implement whatever answer your own advisers give you. Where a genuine tension exists, such as an immutable record against a right to erasure, it is named as a tension rather than resolved.

Why is so much of this about the contents rather than access control?

Because access control decides who can obtain a copy and has no opinion at all about what happens to the copy. Once a document is on somebody's laptop, forwarded to a colleague or printed, every control listed here has already stopped applying to it. What is inside the file is therefore the part that keeps mattering, which is the opposite of the balance you would strike for a page.

How much of this applies at a small volume?

Most of it, because these are decisions rather than infrastructure. Redacting by omitting rather than covering costs nothing. Deciding what goes on a template costs one review. Classification is one object in code. Legal hold and an audit trail are the two that take real work, and both are far cheaper to build before they are requested than under the deadline that comes with the request.

Related security topics

The decisions that depend on each other, then the rest of the same group.

Most of these are decisions rather than features, and the cheapest time to make them is before the first document is delivered rather than after one reaches the wrong person.