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 matchhttps://evil.com/x.example.com/. ..: a resource path containing..is rejected, whatever the locations.- No locations, no restriction:
nullor""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. WithsetErrorPolicy(ErrorPolicy.PEDANTIC)orSTRICT, a rejected resource stops the conversion with anIOExceptionnaming 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 matcheshttps://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, andsetParam(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.
