CVE-2026-102991: posixpath and ntpath disagree about C:
Mako checks a template URI with one standard-library module and opens the file with another. On Windows those are ntpath and posixpath, and they read C:/../../secret.txt differently: one sees a rooted path with nothing to climb, the other sees a directory named C: and climbs out of the template root. The fix in 1.4.2 made both sides ask the same module, and replaced three spelling-specific tests with one matrix that runs every spelling under both.
Disclaimer. This is personal security research, carried out on my own equipment against a public open-source project. It has no connection to any current or former employer, and no client, employer or production environment was involved at any point. Windows was emulated inside a Python process on my own machine, the way the project’s own test suite emulates it; no Windows host was used. The issue was reported privately to the project’s security contact, and it is written up here after the advisory, the fixed release and the CVE record were public.
Two lines in mako/template.py decide whether a template URI is allowed to leave the template directory. Between April and September of this year they were edited three times, and each edit came with a CVE:
# 1.3.10
u_norm = self.uri
if u_norm.startswith("/"):
u_norm = u_norm[1:]
u_norm = os.path.normpath(u_norm)
# 1.3.11 (CVE-2026-41205)
u_norm = self.uri.lstrip("/")
u_norm = os.path.normpath(u_norm)
# 1.3.12 (CVE-2026-44307)
u_norm = self.uri.replace("\\", "/").lstrip("/")
u_norm = os.path.normpath(u_norm)
# 1.4.2 (CVE-2026-102991)
u_norm = self.uri.replace("\\", "/").lstrip("/")
u_norm = posixpath.normpath(u_norm)
Each version is followed by the same test:
if u_norm.startswith(".."):
raise exceptions.TemplateLookupException(
'Template uri "%s" is invalid - '
"it cannot be relative outside "
"of the root path." % self.uri
)
The third edit is mine. This post is about why there had to be a third, and why the change that shipped with it is the first one that is not about spelling.
What normpath does with a .. it cannot climb
The check looks sound: normalise the URI, and if the result starts with .., the URI points above the root. On a relative path it works. normpath("a/../../x") is "../x", the leading .. survives, and the guard fires.
On a path the normaliser considers rooted, the same .. behaves differently. There is nothing above the root to climb to, so normpath drops it:
>>> posixpath.normpath("../x")
'../x'
>>> posixpath.normpath("/../x")
'/x'
That is correct behaviour for a filesystem path, and it is the whole problem for this guard. Once the normaliser decides the string is rooted, the evidence the guard is looking for disappears before the guard looks at it. The guard never misjudges a ... It just never gets to see one. Every bypass of these two lines has been a way of making the URI look rooted to normpath.
A second slash
The 1.3.10 code stripped one leading slash, so that /index.html and index.html meant the same template. A URI of //../secret.txt lost one slash, kept the other, and reached normpath as /../secret.txt, which is rooted, which normalises to /secret.txt. No leading .., no exception.
CVE-2026-41205 (GHSA-v92g-xgxw-vvmm, published 15 April) was fixed in 1.3.11 by stripping every leading slash with lstrip("/"). That closes the doubled-slash spelling.
A backslash
On Windows, os.path is ntpath, and to ntpath a backslash is a separator too. \..\secret.txt has no forward slash for lstrip to remove, so it reached ntpath.normpath intact, was read as rooted, and came out as \secret.txt.
CVE-2026-44307 (GHSA-2h4p-vjrc-8xpq, published 28 April) was fixed in 1.3.12 by turning backslashes into forward slashes before the lstrip, in both the guard and the lookup that resolves the file. That closes the backslash spelling. Both advisories credit the same researcher, 0xHunSec.
A drive letter
I came to Mako in September with those two fixes in front of me. Both had normalised a character. The question I had was whether there is a way to make a path rooted under ntpath that does not involve a leading separator at all.
There is, and Windows users type it every day: C:/. ntpath splits a drive designator off and treats what follows as rooted on that drive. Nothing in the 1.3.12 line touches it. replace has no backslash to change, lstrip has no leading slash to remove, and ntpath.normpath does the rest:
>>> ntpath.normpath("C:/../../secret.txt")
'C:\\secret.txt'
That alone would only make the guard useless for one class of URI. What made it a read outside the root is that the other half of the operation disagrees about what the string means. TemplateLookup.get_template() in mako/lookup.py resolves the file with posixpath, on every platform:
u = re.sub(r"^\/+", "", uri.replace("\\", "/"))
...
srcfile = posixpath.normpath(posixpath.join(dir_, u))
if os.path.isfile(srcfile):
posixpath has no idea what a drive is. To it, C: is a directory name like any other, so with a template root of C:/tpl the two .. first pop C: and then tpl:
>>> posixpath.normpath(posixpath.join("C:/tpl", "C:/../../secret.txt"))
'C:/secret.txt'
The guard asked ntpath, which said the URI was rooted and had no .. left in it. The lookup asked posixpath, which said the URI climbed two levels. The file that got opened was the one posixpath described.
Emulating Windows the way their tests already did
I did not have a Windows machine. Mako’s test suite already had an answer for that, in test/test_template.py:
current_path = os.path
import ntpath
os.path = ntpath
Swapping the module out is a faithful emulation for this particular code, because ntpath and posixpath are pure string functions in the standard library and do not consult the operating system. So the proof of concept used that technique and nothing else. It ran in a python:3.12-alpine container against mako==1.4.1, with a template root of C:/tpl and a sentinel file one level above it:
POSITIVE CONTROL:
ordinary template 'ok.html' -> render='POSITIVE-CONTROL-RENDERED-OK'
NEGATIVE CONTROLS (both published CVEs, and plain ..):
CVE-2026-41205 '//' '//../secret.txt' -> REFUSED (TemplateLookupException)
CVE-2026-44307 '\' '\..\secret.txt' -> REFUSED (TemplateLookupException)
plain .. '../secret.txt' -> REFUSED (TemplateLookupException)
THE VARIANT:
drive + .. 'C:/../../secret.txt' -> render='SENTINEL=THIS_FILE_IS_OUTSIDE_THE_TEMPLATE_ROOT'
The negative controls mattered more to me than the result. A report that says “a third variant” has to show that the first two fixes still hold on the same harness, in the same run. Otherwise it may just be a broken environment that would let anything through.
The report also said what it had not done. The final open() was never exercised on a real Windows host, and every normalisation in it is deterministic, so I said so instead of implying more. It also listed what the variant does not do. It cannot cross drives: reaching D: from a C: root needs a leading .., and the guard catches that. It is not code execution, even though a configured module_directory gets its compiled .py written outside it as well. And it is not reachable through a raw URL path, because any URL normaliser collapses C:/../.. first. The vectors are the ones the first advisory already named: query strings, form and JSON bodies, route parameters, and <%include file="${...}"/>.
The advisory knew something my report didn’t
My report described depth as cheap. The guard cannot fire for any URI that starts with a drive letter, so a wrong number of .. costs the attacker one TopLevelLookupException and another try.
The advisory the maintainers published says something stronger, and it is right. posixpath.normpath does not fail on a .. it cannot climb. On a rooted path it stays at the root, as the first section showed. So a URI with far more .. than the template root is deep simply reaches the root and descends from there. There is nothing to guess. I re-ran it with an absolute template root nine levels deep:
mako 1.4.1
C:/ + ../*8 + tmp/mako-depth-sentinel.txt -> REFUSED TopLevelLookupException
C:/ + ../*20 + tmp/mako-depth-sentinel.txt -> DEPTH-FREE-SENTINEL
C:/ + ../*40 + tmp/mako-depth-sentinel.txt -> DEPTH-FREE-SENTINEL
Twenty and forty reach the same file, and eight is not enough. One fixed payload works wherever the templates live. That is the same root-absorbs-.. behaviour the guard was defeated by, now on the attacker’s side. I had the mechanism in hand and did not follow it to that point. The maintainers did.
1.4.2: change the module, not the spelling
I sent the report to security@tidelift.com on 19 September. Private vulnerability reporting was switched off on the repository, despite its two published advisories, and its security policy pointed to Tidelift. Jeremy Katz picked it up two days later and asked whether I would accept being copied in with the maintainers. On 22 September, Mike Bayer opened issue #441 with a placeholder description, committed the fix a minute later, released 1.4.2, and published GHSA-5639-2j2p-m4mx, all within about an hour. That was three days after the report.
The fix, 000ed85e, changes one word in the guard: os.path.normpath becomes posixpath.normpath. Under posixpath, C: is an ordinary directory name, so the .. stays in front where the guard can see it:
>>> posixpath.normpath("C:/../../secret.txt")
'../secret.txt'
That is the edit my report proposed. The difference from the two before it is where the change was made. 1.3.11 and 1.3.12 each added a rewrite of the string before it reached the normaliser, aimed at one character. 1.4.2 does not rewrite anything new. It makes the guard ask the same module the lookup asks, so the two halves of the operation can no longer disagree about whether a path is rooted. The commit message says it in a sentence: normalisation is now done with posixpath, “the same module get_template() already commits to, so that both sides of the operation agree on what the URI means.”
The part of the commit I found most useful was the tests. Before it, each CVE had its own test, written for its own spelling. The fix deletes them and replaces them with one parametrised test in each of test_lookup.py and test_template.py. It takes eleven spellings of the same traversal and runs every one under posixpath and under ntpath. The list includes /C:/ and //C:/, combinations nobody had reported. A comment in it notes that the drive letter “consumes one level of the posixpath resolution, hence the extra ‘..’“. The commit message describes the result:
All three forms are then covered by one matrix, and reverting any one of the three fixes fails only the rows belonging to it.
That is a test for the disagreement rather than for the spellings. Re-run against 1.4.2, the proof of concept above refuses everything except ok.html, including the depth-free payloads.
CVE-2026-102991 was published by GitHub as the CNA on 30 September. Like the records for its two siblings, it carries no credits field, so the three records for this one line credit nobody. The advisories hold the names: 0xHunSec on the first two, and me on this one.
Advisory GHSA-5639-2j2p-m4mx · CVE CVE-2026-102991 · Fix 000ed85e · Affected <= 1.4.1, fixed in 1.4.2
Report sent to Tidelift 19 September · triaged 21 September · fix, release 1.4.2 and advisory published 22 September · reporter credit accepted 23 September · CVE record published 30 September by GitHub, with no credits on the record.
Eurico Nicacio — @h3llh0und