
PEACH
Un cadre d’isolation des locataires
extract-msg converts an Outlook .msg file to PDF with wkhtmltopdf, and since 0.57.0 it documents a security contract: with the default wkAllowExternalFiles=False, external files are purged from the HTML body before the body reaches wkhtmltopdf, so no outbound request is made.
That contract was added in 0.57.0 to fix the SSRF advisory GHSA-cxpg-prmv-g4pp.
The filter is an incomplete blocklist: it only removes CSS url(http://...) references and HTML attributes whose value literally starts with http.
A CSS @import "http://..." rule, which uses a bare string with no url() wrapper, survives the filter, so wkhtmltopdf fetches the attacker-chosen URL and renders the response into the returned PDF.
An attacker who gets a crafted .msg into a service that offers the documented PDF conversion therefore makes the conversion host issue HTTP requests to loopback, internal, or cloud-metadata URLs, with the default options the documentation calls safe.
extract-msg on PyPI, versions >= 0.57.0 (where the filter was introduced) through the latest release 0.58.1 (2026-10-31), and the master default branch at 1f61675f. Verified on both with Python 3.12 and wkhtmltopdf 0.12.6.iframe, img src, link stylesheet, CSS url()) are blocked on the current code; the @import string form is not.@import bypass.Preconditions:
MessageBase.save(pdf=True), MessageBase.getSavePdfBody(), or the python -m extract_msg --pdf CLI. Plain extraction (text, HTML, attachments) never reaches this code, so PDF conversion is a precondition, not the default for every caller.wkhtmltopdf is installed on the conversion host. It is an optional external dependency and its upstream project is archived and unmaintained, so this is a deployment choice rather than a default.wkAllowExternalFiles is left at its default False. The attack works precisely because the documentation says this default purges external files..msg file (an emailed attachment, an upload, or any file a pipeline converts) and, to read the fetched response back, receives or can observe the produced PDF. For blind SSRF (reaching an internal endpoint) the returned PDF is not even needed..msg whose HTML body contains <style>@import "http://<internal-target>";</style>.url() references and http attributes but leaves the @import string untouched, so the HTML handed to wkhtmltopdf still imports the external URL.wkhtmltopdf fetches http://<internal-target> from the conversion host and renders the response into the PDF, which the attacker receives.
Practical impact in an automated MSG-to-PDF pipeline:Before calling wkhtmltopdf, getSavePdfBody strips external references from the HTML body, but only two narrow shapes: HTML attributes whose value starts with http, and CSS url(http://...) via patchCss.
# https://github.com/TeamMsgExtractor/msg-extractor/blob/1f61675f39dd03ac0565147d242acfcf7d3428ba/extract_msg/msg_classes/message_base.py#L616-L635
elif not wkAllowExternalFiles:
if tag.get('href', '').lower().startswith('http'):
tag['href'] = ''
elif tag.get('src', '').lower().startswith('http'):
tag['src'] = ''
elif tag.get('data', '').lower().startswith('http'):
if tag.name.lower() == 'object':
tag['data'] = ''
...
if not wkAllowExternalFiles:
# Do main css first.
for tag in bs.find_all('style'):
if tag.string:
tag.string = patchCss(tag.string)patchCss substitutes only the url() pattern:
# https://github.com/TeamMsgExtractor/msg-extractor/blob/1f61675f39dd03ac0565147d242acfcf7d3428ba/extract_msg/utils.py#L833-L837
def patchCss(cssData: str) -> str:
"""
Takes the css data and patches out urls from it for security.
"""
return constants.re.CSS_URL.sub('none', cssData)
# https://github.com/TeamMsgExtractor/msg-extractor/blob/1f61675f39dd03ac0565147d242acfcf7d3428ba/extract_msg/constants/re.py#L57-L58
# Used for stripping urls from CSS.
CSS_URL: Final[_RE_STR_TYPE] = re.compile(r'url\(\s*["\']?(https?:\/\/[^"\')\s]+)["\']?\s*\)', re.I)A CSS @import "http://..." rule references its resource with a bare quoted string and no url() wrapper, so CSS_URL does not match it and patchCss leaves it intact.
The imported URL then reaches wkhtmltopdf, which fetches it.
wkhtmltopdf is already invoked with --disable-local-file-access and --disable-javascript, but as the original advisory itself notes, those flags do not block network HTTP fetches; HTML sanitization is the only control, and it is incomplete.
The same narrowness lets more external-fetch constructs through: <img srcset="http://..."> (the filter never inspects srcset), a leading space before the scheme <img src=" http://..."> (the plain startswith('http') check misses it), and the poster and background attributes (never inspected). They are the same root cause and we cover them in the mitigation.
We act as an attacker who supplies a crafted .msg to a service that offers the documented MSG-to-PDF conversion, the realistic pipeline usage shown in extract-msg's own documentation.
We reproduce entirely inside one disposable container; the only network the conversion uses is a loopback HTTP stub started in that same container, standing in for an internal service, so no external or third-party system is contacted.
PoC files are in this gist.
repro.sh builds the stock stack (wkhtmltopdf 0.12.6, Python 3.12) and installs extract-msg two ways: the latest PyPI release 0.58.1, and the master default branch at 1f61675f. make_msg.py writes the malicious HTML body (body-import.html) into a valid example message shipped in the repository.<style> block is @import "http://127.0.0.1:8000/theme.css";. The loopback stub server.py logs each request and returns CSS that reflects an internal value (body::after { content: "INTERNAL_SECRET_9f31a2"; }).run_poc.py calls the documented default getSavePdfBody() (no wkAllowExternalFiles), and the run also exercises the documented python -m extract_msg --pdf CLI. On both the latest release and master:=== [release] documented python API: getSavePdfBody() ===
pdf-bytes 18226
=== [release] documented CLI: python -m extract_msg --pdf ===
--- [release] loopback hits: /theme.css /theme.css
--- [release] secret lines in API PDF: 1
Invoice 4021 attached. Please review the statement below.
INTERNAL_SECRET_9f31a2
--- [release] secret lines in CLI PDF: 1The conversion host fetched the loopback URL and the internal value was rendered into the returned PDF, with the default options the documentation describes as purging external files. A probe of the individual CSS and attribute constructs on the latest release shows exactly what the filter catches and misses:
import-string FETCHED (bypass)
import-url blocked
style-bg-url blocked
attr-bg-url blocked
font-face-url blocked
image-set-string blocked
img-src blocked
img-srcset FETCHED (bypass)
src-leading-space FETCHED (bypass)
video-poster FETCHED (bypass)
body-background FETCHED (bypass)The url() forms the 0.57.0 fix added are blocked; the @import string, img srcset, a leading space before http, and the poster and background attributes are not.
Latest release 0.58.1: the documented default getSavePdfBody() fetches the loopback URL via the surviving @import and renders the internal value into the PDF.
The construct probe and the mitigation result: the five bypass constructs fetch on the released code; with the patch applied the end-to-end PoC is blocked and every construct is blocked.

An attacker who supplies a crafted .msg to a service that offers extract-msg's documented PDF conversion makes the conversion host issue HTTP requests to attacker-chosen URLs, including loopback, internal, and cloud-metadata endpoints, and has a limited amount of each response reflected into the returned PDF.
This is server-side request forgery from the host, achieved with the default wkAllowExternalFiles=False that the documentation states purges external files.
We demonstrated it against a loopback stub; replacing the URL with an internal or metadata endpoint gives the same request from the host.
We claim no integrity or availability impact, and the amount of response data recoverable is limited to what renders as CSS, so confidentiality impact is partial rather than full.
We rate it CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:C/C:L/I:N/A:N (4.0, Medium).
AV:N because the attacker is remote and the request targets network-reachable services; AC:H because the attack needs the optional PDF-conversion feature with the archived, unmaintained wkhtmltopdf installed and, for the non-blind read, the produced PDF returned to the attacker, which are non-default deployment conditions; PR:N and UI:N because an automated pipeline needs neither privileges nor interaction; S:C because the request crosses from the conversion host's scope to internal network services; C:L for the partial, CSS-shaped reflection of the response; I:N/A:N because we claim neither.
We deliberately do not carry over the prior advisory's C:H 7.4/8.6 scores: the non-default preconditions and the limited reflection make a conservative Medium the right score for the realistic deployment.
The fix is to make the HTML sanitizer cover every external-reference construct, since wkhtmltopdf's own flags do not block network fetches.
Our patch, against master@1f61675f, extends the filter to:
@import rules in both the string and url() forms (a new CSS_IMPORT_EXTERNAL pattern applied in patchCss before the existing url() substitution);isExternalUrl helper that ignores the leading whitespace and interior tab/newline that HTML URL parsing discards), so a leading space can no longer smuggle a URL past the check;srcset (and poster/background), instead of the previous elif chain that only ever stripped one attribute per tag.def patchCss(cssData: str) -> str:
cssData = constants.re.CSS_IMPORT_EXTERNAL.sub('', cssData)
return constants.re.CSS_URL.sub('none', cssData)This covers every variant of the root cause we found: the @import string and url() forms, img srcset, the leading-space src, and the poster and background attributes.
As defense in depth, operators who convert untrusted messages should also run wkhtmltopdf with no outbound network access (a network namespace with no egress, or a deny-all proxy), because --disable-local-file-access and --disable-javascript, which extract-msg already passes, do not stop HTTP fetches, as the original advisory states.
We tested the patch against master@1f61675f: with it applied, the end-to-end PoC is blocked (no loopback request, no secret in the API or CLI PDF) and every one of the eleven probed constructs is blocked, while the data path is otherwise unchanged. The regression tests pass (pytest: 3 passed, 18 subtests passed), covering patchCss, isExternalUrl, and testWkSecurity extended with the @import string/url, img srcset, and leading-space src vectors.
The full patch is mitigation.diff.
Source: NVD
Évaluation gratuite des vulnérabilités
Évaluez vos pratiques de sécurité cloud dans 9 domaines de sécurité afin d’évaluer votre niveau de risque et d’identifier les lacunes dans vos défenses.
Obtenez une démo personnalisée
"La meilleure expérience utilisateur que j’ai jamais vue, offre une visibilité totale sur les workloads cloud."
"Wiz fournit une interface unique pour voir ce qui se passe dans nos environnements cloud."
"Nous savons que si Wiz identifie quelque chose comme critique, c’est qu’il l’est réellement."