Documentation page: Restricting Resource Loading for Untrusted HTML. This page is also available as Markdown at restricting-resource-loading-for-untrusted-html.md.

Restricting Resource Loading for Untrusted HTML

When PD4ML converts a document, it loads everything the HTML refers to: stylesheets, images, web fonts, attachments. If the HTML is not entirely yours — written by users, edited by customers, received from another system — those references are chosen by someone else, and PD4ML fetches them with the network access and file permissions of your server. addAllowedResourceLocation(...) limits loading to locations you trust; everything else is not loaded.

PD4ML pd4ml = new PD4ML();
pd4ml.addAllowedResourceLocation("https://cdn.example.com/assets/");
pd4ml.addAllowedResourceLocation("file:/opt/app/templates/");
pd4ml.readHTML(new ByteArrayInputStream(userHtml.getBytes(StandardCharsets.UTF_8)),
        new URL("https://cdn.example.com/assets/"), "UTF-8");
pd4ml.writePDF(out);

addAllowedResourceLocation(...) and * patterns are available from PD4ML v4.2.0, with both layout engines. The pd4ml.allowed.resource.location parameter it builds on (Constants.PD4ML_ALLOWED_RESOURCE_LOCATION) exists in earlier versions as a comma-separated list of URL prefixes.

Why it matters

A reference in HTML is an instruction to your server. Without a restriction, untrusted HTML can make PD4ML:

  • read local files: <img src="file:/etc/...">, <link rel="stylesheet" href="file:/C:/app/config/..."> — the server reads them with the permissions of the process running PD4ML;
  • call internal services: <img src="http://10.0.0.5/admin/...">, http://169.254.169.254/... (the metadata endpoint of cloud servers) — requests that come from inside your network, where a firewall no longer protects the target. This is known as server-side request forgery (SSRF);
  • put what it fetched into the PDF: an image or a web font that was loaded ends up in the output document, which goes back to whoever supplied the HTML;
  • wait for slow or hostile servers: every reference is a connection your conversion waits for.

Use addAllowedResourceLocation(...) whenever the HTML, or any part of it, comes from a source you do not fully control: user-submitted content, templates customers can edit, HTML from e-mails or third-party APIs, or your own templates with user data inserted without escaping.

How it works

addAllowedResourceLocation(locations) takes a comma-separated list of locations. A resource is loaded only if its URL starts with one of them:

Location Allows
https://cdn.example.com/assets/ everything under that folder of that server
file:/opt/app/templates/ local files under /opt/app/templates/
/opt/app/templates/, C:/app/templates/ the same: a local path stands for its file: URL
https://*.example.com/ every subdomain of example.com
https://cdn.example.com/*/img/ the img folder of every folder directly under /
  • Accumulating: each call adds its locations to the ones added before, so different parts of an application can contribute their own.
  • Case: the comparison ignores case.
  • Patterns: * matches any run of characters except /. It stays within one host name or one path segment: https://*.example.com/ does not match https://evil.com/x.example.com/.
  • ..: a resource path containing .. is rejected, whatever the locations.
  • No locations, no restriction: null or "" adds nothing, and as long as no location has been added, resources load from anywhere.
  • A rejected resource is skipped: the document is still converted, without it (an image is left out, a stylesheet is not applied); setLogLevel(Constants.LOG_CACHE) logs which resource was rejected and why. With setErrorPolicy(ErrorPolicy.PEDANTIC) or STRICT, a rejected resource stops the conversion with an IOException naming it instead.

The same restriction can be set as a parameter; the method adds to a value set this way:

pd4ml.setParam(Constants.PD4ML_ALLOWED_RESOURCE_LOCATION, "https://cdn.example.com/, /opt/app/templates/");

What is checked

The restriction applies to everything PD4ML loads while converting, including:

  • the HTML document itself, when it is read from a URL with readHTML(URL);
  • stylesheets (<link rel="stylesheet">, @import) and the images they refer to (background-image, list-style-image);
  • images (<img>, <input type="image">, SVG images);
  • web fonts declared with @font-face (v42 engine);
  • attachments (<pd4ml:attachment>).

It does not apply to:

  • inline data: URIs — their content is part of the HTML already;
  • classpath resources (java:), such as PD4ML's own default resources;
  • the font directories set with useTTF(...), which you configure yourself.

Recommendations

  • End a server location with /: https://cdn.example.com (no slash) also matches https://cdn.example.com.attacker.net/, because the check compares the beginning of the URL. https://cdn.example.com/ does not.
  • Allow servers you control. Redirects of an allowed server are followed: a location is only as trustworthy as the server behind it.
  • Pass untrusted HTML as text, with a trusted base URL: readHTML(InputStream, URL, encoding) with the base URL of your assets. Relative references resolve against it, and the document itself is not loaded from anywhere. When you read a document from a URL instead, that URL must be within the allowed locations too, or the document is not loaded.
  • Turn off what you don't need: setParam(Constants.PD4ML_DISABLE_EXTERNAL_ATTACHMENTS, "true") stops loading <pd4ml:attachment> files altogether, and setParam(Constants.PD4ML_SOCKET_TIMEOUT, "5000") keeps a slow server from holding up the conversion (milliseconds; without it, the JVM's defaults apply, which may mean no timeout).
  • Allow-list, not block-list: list the locations your documents need, rather than trying to exclude dangerous ones — there are too many ways to write an internal address.

In the API reference