GHSA-r57g-mc79-8hj2: Path Traversal

Published Oct 21, 2026
·
Updated

Affected version / file / line Version: djust v1.1.4 (latest release, published 2026-09-23). File: python/djust/mcp/server.py:354-433 (MCP tool findhandlersfortemplate). Confirmed identical on the commit used for triage, a7e4fafd1667b4db0b1465652d4a63cb17117a07.

Description @mcp.tool() def findhandlersfortemplate(templatepath: str) -> str: """ Args: templatepath: Either a logical template name (e.g. 'demos/counter.html' — what you'd pass to render) OR an absolute filesystem path. Both are searched. """ ... resolvedpath: str | None = None if os.path.isabs(templatepath) and os.path.isfile(templatepath): resolvedpath = templatepath else: ... # logical-name resolution via Django's template loader ... with open(resolvedpath, "r", encoding="utf-8", errors="replace") as f: source = f.read() handlernamesintemplate = sorted({m.group(2) for m in djeventattrre.finditer(source)}) When templatepath is an absolute path to an existing file, it is used directly, with no confinement to the project/template directories — no realpath() + prefix check, no allowlist. open(resolvedpath, ...) then reads the file.

This is inconsistent with the sibling tool in the same file (loaddata, ~line 290-303), which explicitly rejects paths outside the project directory for its own path-like parameter: candidate = os.path.realpath(...) if basedir and not candidate.startswith(os.path.realpath(str(basedir))): return json.dumps({"error": f"fixture path '{p}' resolves outside the project directory", ...}) findhandlersfortemplate has no equivalent check for templatepath before the open() call.

Impact An MCP client with access to this tool (available whenever the Django project is running the MCP server via python manage.py djustmcp — no separate opt-in flag gates this specific tool beyond that) can read the contents of any file on disk that the process has read permission to and that exists, by passing an absolute path — e.g. /etc/passwd, application .env files, SSH keys, or any other sensitive file reachable by the server process's user.

Mitigating factor (reduces this from High to Medium): the tool does not return the raw file content. It runs one fixed regex (dj-(?:click|submit|change|input|keydown|keyup)\s=\s'"['"]) against the content and returns only the matched handler-name strings, plus confirmation that the file exists and is readable (an existence/readability oracle on its own, independent of the regex match). If the targeted file happens to contain text matching that HTML-attribute-like pattern (plausible for config files, logs, or any text file with key="value"-shaped content), those substrings are returned in full — so the read is not purely blind, but it is not a general-purpose arbitrary-file-read primitive either.

Proof of Concept / Reproduction Traced via direct source read; no live Django project was exercised for this report (would require a full djust project scaffold, which was out of scope for this pass — see limitation below).

1. MCP client calls: findhandlersfortemplate(templatepath="/etc/passwd") 2. server.py ~line 393: if os.path.isabs(templatepath) and os.path.isfile(templatepath): resolvedpath = templatepath resolvedpath == "/etc/passwd", unchanged, unconfined 3. server.py ~line 430: with open(resolvedpath, "r", encoding="utf-8", errors="replace") as f: source = f.read() /etc/passwd is now fully read into source 4. The regex djeventattrre.finditer(source) is applied to the real /etc/passwd content. /etc/passwd will not match this specific pattern, so the response would report an empty handler list plus "file exists and was read" - which already confirms the path-confinement bypass (compare with passing a nonexistent absolute path, which correctly returns "Template file not found"). 5. On a target where the absolute path points to a file that DOES contain text shaped like dj-click="..." (e.g. a copied HTML fragment, a stray config value, or a crafted file placed by the attacker via another vector), the matched substrings are returned verbatim in the JSON response - full-content disclosure of just those substrings, not the whole file.

Suggested Fix Apply the same confinement loaddata already uses, before the os.path.isfile check: if os.path.isabs(templatepath): candidate = os.path.realpath(templatepath) if basedir and not candidate.startswith(os.path.realpath(str(basedir))): return json.dumps({ "error": f"template path '{templatepath}' resolves outside the project directory", "resolved": candidate, "projectdir": str(basedir), }) if os.path.isfile(candidate): resolvedpath = candidate Limitations Not exercised against a live Django/djust project — this is a source-level trace of the exact executed branch (os.path.isabs + os.path.isfile + open()), not an end-to-end runtime capture. Did not verify whether any MCP-host-level confirmation/approval step (some MCP clients prompt the user before a tool call with a suspicious-looking path argument) would intercept this in practice - the vulnerability is in the server's own missing server-side guard regardless of client-side UX.

Disclosure Timeline 2026-09-24: found during audit, confirmed via source trace to the open() sink; classified Medium due to the response-content limitation (matched substrings only, not raw file content). Please check the log file, and apply CVE once the fix is made.

log: 10 14:53:20.185935 openat(ATFDCWD, "/case/project/templates/nonexistentinsideproject.html", ORDONLY|OCLOEXEC) = -1 ENOENT (No such file or directory) 10 14:53:20.187205 write(1, "{\"error\": \"Could not resolve tem"..., 204) = 204 ... 10 14:53:20.191831 write(1, "=== ATTACK: absolute path OUTSID"..., 57) = 57 10 14:53:20.194864 write(1, "target path:", 12) = 12 10 14:53:20.198521 write(1, "/case/outside/evilcontent.html", 31) = 31 10 14:53:20.202290 newfstatat(ATFDCWD, "/case/outside/evilcontent.html", {stmode=SIFREG|0777, stsize=185, ...}, 0) = 0 10 14:53:20.203762 write(1, "exists on disk (from the contain"..., 42) = 42 10 14:53:20.207224 write(1, "True", 4) = 4 10 14:53:20.210392 newfstatat(ATFDCWD, "/case/outside/evilcontent.html", {stmode=SIFREG|0777, stsize=185, ...}, 0) = 0 10 14:53:20.211721 newfstatat(ATFDCWD, "/case/outside/evilcontent.html", {stmode=SIFREG|0777, stsize=185, ...}, 0) = 0 10 14:53:20.213284 openat(ATFDCWD, "/case/outside/evilcontent.html", ORDONLY|OCLOEXEC) = 3 10 14:53:20.215202 newfstatat(3, "", {stmode=SIFREG|0777, stsize=185, ...}, ATEMPTYPATH) = 0 10 14:53:20.216994 ioctl(3, TCGETS, 0x7ffd68e26aa0) = -1 ENOTTY (Inappropriate ioctl for device) 10 14:53:20.221797 read(3, "<html><body>\n<!-- This file live"..., 186) = 185 10 14:53:20.223610 read(3, "", 1) = 0 10 14:53:20.224913 close(3) = 0 Maintainer assessment The djust MCP server (manage.py djustmcp, python -m djust.mcp) only serves stdio to the MCP client that launched it, as the same OS user, on the developer's machine; there is no network listener. The realistic caller is a local AI agent acting on untrusted input. The tool returns only dj--attribute-shaped substrings and an existence answer, not file contents. We therefore rate this Low (CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:L/I:N/A:N, 3.3).

Affected: every release with the tool, 0.9.0 – 1.0.9, 1.1.0 – 1.1.6, 1.2.0 – 1.2.3, 1.3.0rc4 – 1.3.0rc7.

Patches Fixed in 1.0.10, 1.1.7, 1.2.4 and 1.3.0rc8. findhandlersfortemplate reads an absolute path only when it resolves (symlinks and .. followed) inside a directory the template loaders serve or inside BASEDIR, and answers any other absolute path like a missing template. The seedfixtures guard compared paths with a string prefix (so /app-data passed for a BASEDIR of /app) and skipped the check when BASEDIR was unset; it now compares whole path components and refuses without BASEDIR. Both use one containment helper.

Workarounds Run the MCP server only in projects whose agent you trust with file access, or pass logical template names only.

Affected Software

4 affected componentsFixes available
pip/djust>=1.3.0.dev0<=1.3.0rc7
1.3.0rc8
pip/djust>=1.2.0.dev0<=1.2.3
1.2.4
pip/djust>=1.1.0<=1.1.6
1.1.7
pip/djust>=0.9.0<=1.0.9
1.0.10

Remediation

Recommended actions to resolve this vulnerability, in priority order.

  1. Upgrade

    Upgrade pip/djust to a version that resolves this vulnerability.

    Fixed in 1.3.0rc8
  2. Upgrade

    Upgrade pip/djust to a version that resolves this vulnerability.

    Fixed in 1.2.4
  3. Upgrade

    Upgrade pip/djust to a version that resolves this vulnerability.

    Fixed in 1.1.7
  4. Upgrade

    Upgrade pip/djust to a version that resolves this vulnerability.

    Fixed in 1.0.10
  5. Upgrade

    Upgrade djust to a version that resolves this vulnerability.

    Fixed in 1.0.10
  6. Upgrade

    Upgrade djust to a version that resolves this vulnerability.

    Fixed in 1.1.7
  7. Upgrade

    Upgrade djust to a version that resolves this vulnerability.

    Fixed in 1.2.4
  8. Upgrade

    Upgrade djust to a version that resolves this vulnerability.

    Fixed in 1.3.0rc8
  9. Compensating control

    Until fixed, run the MCP server only in projects whose agent you trust with file access, or pass logical template names only instead of absolute filesystem paths.

Event History

Oct 21, 2026
Advisory Published
via GitHub·05:21 PM
Data Sourced
via GitHub·05:21 PM
DescriptionSeverityWeaknessAffected Software

Frequently Asked Questions

1

Who is exposed to this issue?

Deployments are exposed when the MCP tool find_handlers_for_template can be invoked with attacker-controlled input. Exploitation requires local, low-privilege access according to the supplied vector.

2

What input is required to trigger the vulnerable behavior?

An attacker must provide an absolute path to an existing file. The tool accepts that path directly instead of restricting it to the project or template directories.

3

Are logical template names affected in the same way?

No. The described unrestricted path handling applies when template_path is an absolute path to an existing file; other inputs use Django template-loader resolution.

4

What files could be accessed?

The tool can open existing files specified by absolute path, without a realpath-and-prefix check or allowlist. Access is still constrained by the permissions of the process running the tool.

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203