Security
A document decides what the converter does: which files it opens and which hosts it talks to. If the HTML you convert is your own, there is little here to think about. If any of it comes from somewhere else – a rich-text field, an uploaded template – then its author is choosing what your server fetches.
Two things a document could ask for, and no longer can by default:
<!-- cloud instance credentials, from inside your VPC -->
<img src="http://169.254.169.254/latest/meta-data/">
<!-- a file the document has no business seeing -->
<img src="/etc/passwd">
What is refused by default
Destinations that resolve to an internal address: loopback, private, link-local (where cloud metadata services live), multicast, reserved. A host is judged by the address it resolves to, on every hop of a redirect chain.
Local reads outside the document’s own directory – the directory of the
pathargument, or the working directory for a document rendered from a string. Symlinks and..are resolved first.Schemes the fetcher does not understand, such as
ftp:.
Public HTTP(S) is unaffected. A refused resource is logged by
xhtml2pdf.files at warning level and left out of the document; the render
continues, as it does for a resource that is merely unreachable.
Choosing your own policy
The default is a floor. An application rendering untrusted HTML should say what it actually allows:
from pathlib import Path
from xhtml2pdf import pisa
from xhtml2pdf.config.resources import ResourceAccessPolicy
policy = ResourceAccessPolicy(
allowed_hosts=frozenset({"cdn.example.com"}),
base_dir=Path("/srv/app/media"),
extra_roots=(Path("/srv/app/fonts"),),
)
pisa.CreatePDF(user_html, dest=out, resource_policy=policy)
allow_remote(defaultTrue)Fetch over http(s) at all.
Falsefor a renderer that should only ever use local assets and inlinedata:URIs.allow_private_networks(defaultFalse)Permit internal addresses, for assets that really do live on the LAN.
allowed_hosts(defaultNone)Fetch only from these hosts. This is also the answer to DNS rebinding, which the address check alone cannot close: the connection resolves the name again and it can answer differently.
base_dir,extra_roots,allow_local_outside_baseWhere local reads may go.
base_dirdefaults to the working directory, so a policy you build by hand confines local reads whether or not you thought about it; naming a directory of your own is better, because the working directory of a server holds its source, its settings and often its database.base_dir=Nonenames no directory and denies local reads outright.allow_local_outside_base=Truelifts the confinement and keeps the network rules.max_resource_bytes(default 20 MiB)How large one fetched resource may be, checked against the declared
Content-Length, against the body as it is read, and again against what a gzipped body expands to. The document chooses the server it downloads from, so without this a 200 KB response can arrive as 200 MB of memory.Noneremoves the limit.
A renderer with several entry points can set the policy once around a build
instead of passing it to every call. The resource_policy argument still
wins over it:
from xhtml2pdf.config.resources import use_policy
with use_policy(policy):
pisa.CreatePDF(html, dest=out)
Two policies come ready-made. default_policy(base_dir=None) builds the one
described at the top of this page, which is what a build that chooses nothing
gets. PERMISSIVE_POLICY turns all of it off and fetches anything: it is
what --unsafe-resources selects, and what xhtml2pdf already uses for a
resource you named – the source document itself – rather than one the
markup asked for.
from xhtml2pdf.config.resources import PERMISSIVE_POLICY, default_policy
A refused resource raises ResourceAccessError inside the fetcher, which a
render logs and steps over: the document comes out with that resource missing
rather than failing. Only a caller using the fetcher directly sees the
exception.
Warning
link_callback is not a security boundary, and this is where an existing
integration usually meets the policy. It rewrites a URI; it does not
authorise one. A callback that maps /static/x.css onto a shared asset
directory needs that directory named in base_dir or extra_roots. It
is also optional, defaults to None, and around ten fetches inside the
library never reach it – fonts, watermarks, signatures, the image reader.
The policy sits below all of them.
Calling the fetcher yourself
xhtml2pdf.files.getFile("/etc/passwd") from your own code is your code
asking for your own file, not an attacker-controlled fetch, and is
unrestricted unless you pass policy=.
On the command line
The document you name is your own, so local reads are not confined there; the
network rules still apply. --resource-root asks for confinement, for
converting HTML that came from somewhere else. The flags are listed in
Command-line interface.
Before 0.2.19, --start-viewer built a shell command by string
interpolation, so a destination whose name contained shell metacharacters was
executed rather than opened (CWE-78). If you compose that name from user
input, upgrade.
What this does not protect you from
Resource exhaustion. A document can ask for a very large image or thousands of pages. Nothing bounds conversion time or memory; run untrusted conversions where you can bound both.
What you do with the PDF afterwards. xhtml2pdf writes a file; it does not sanitise what reads it.
Reporting a vulnerability
Privately, by email, rather than in a public issue: luisza {at} gmail.com.
Include the component affected, how to reproduce it, and the impact. The
project patches the default branch and issues a fix release; older versions
are not backported. Full statement in SECURITY.md.