I Found a Path Traversal in InvenTree’s Report Engine — Here’s How It Works (CVE-2026–33531)
quality 9/10 · excellent
0 net
Tags
I Found a Path Traversal in InvenTree's Report Engine — Here's How It Works (CVE-2026–33531) | by Alon Akirav - Freedium
Milestone: 20GB Reached
We’ve reached 20GB of stored data — thank you for helping us grow!
Patreon
Ko-fi
Liberapay
Close
< Go to the original
I Found a Path Traversal in InvenTree's Report Engine — Here's How It Works (CVE-2026–33531)
How a missing two-line boundary check in a Django template tag turned report generation into arbitrary file read.
Alon Akirav
Follow
~5 min read
·
March 27, 2026 (Updated: March 27, 2026)
·
Free: Yes
Background
InvenTree is a popular open-source inventory management system built on Django. It's self-hosted, widely deployed in maker spaces, small businesses, and engineering teams — and it ships a powerful report template engine that lets staff users design PDF reports using Django template syntax.
That template engine is also where I found CVE-2026–33531.
The Vulnerability
InvenTree lets staff users upload HTML templates to generate PDF reports. Inside those templates, you can use custom Django template tags — small functions that get called at render time to embed data.
Three of those tags — encode_svg_image() , asset() , and uploaded_image() — accept a filename argument, resolve it to a full filesystem path, and read the file.
The problem : they never check whether the resolved path is still inside the media directory they're supposed to be scoped to.
Supply a filename that walks up the directory tree with ../ sequences, and Python's Path.resolve() will happily hand you a path pointing anywhere on the server filesystem.
CVE : CVE-2026–33531
Advisory : GHSA-rhc5–7c3r-c769
Affected : InvenTree < 1.2.6 Patched: 1.2.6, 1.3.0
The Vulnerable Code
All three functions follow the same pattern. Here's encode_svg_image() in src/backend/InvenTree/report/templatetags/report.py : def encode_svg_image(filename):
full_path = settings.MEDIA_ROOT.joinpath(filename).resolve()
# ← No boundary check here
with open(full_path, 'rb') as f:
data = f.read()
return 'data:image/svg+xml;charset=utf-8;base64,' + base64.b64encode(data).decode()
And asset() : def asset(filename):
full_path = settings.MEDIA_ROOT.joinpath('report', 'assets', filename).resolve()
# ← No boundary check here
return f'file://{full_path}'
The sequence is always:
Take user-supplied filename
Join it onto a base path ( MEDIA_ROOT or a subdirectory)
Call .resolve() — which collapses ../ traversal and follows symlinks
Open the file or return a file:// URI for WeasyPrint to fetch
Step 4 happens with no check that full_path is still a descendant of MEDIA_ROOT .
In the Docker deployment, MEDIA_ROOT is /home/inventree/data/media . That means a filename with enough ../ levels reaches the filesystem root, and from there — any file the application process can read.
Attack Flow
[Staff user]
│
▼
POST /api/report/template/
Upload a template containing encode_svg_image("")
│
▼
Template stored in database with a pk (e.g. pk=10)
│
▼
[Any authenticated user]
│
▼
POST /api/report/print/
{"template": 10, "items": []}
│
▼
WeasyPrint renders the template server-side
Path.resolve() escapes MEDIA_ROOT
Target file is read and base64-encoded into the HTML
│
▼
Generated PDF returned to user
│
▼
Decode the base64 string from the PDF → file contents
A key detail : template creation requires a staff account, but template triggering does not. Any authenticated user can POST to /api/report/print/ with an existing template pk. This means a single compromised staff account is enough to plant the malicious template — after that, the exfiltration step can be performed by any logged-in user.
Reproducing It (Conceptual)
The steps below describe the technique at a high level. The full PoC is in the GitHub repository .
Step 1 — Staff creates a template using a crafted tag: curl -X POST http://localhost/api/report/template/ \
-H "Authorization: Token " \
-F "name=Demo" \
-F "model_type=part" \
-F "template=@crafted_template.html" \
-F "enabled=true"
# Response: {"pk": 10, ...}
The template HTML uses one of the affected tags with a path that traverses outside MEDIA_ROOT . The exact traversal depth depends on how many directory levels separate MEDIA_ROOT from the target file.
Step 2 — Any authenticated user triggers a print: curl -X POST http://localhost/api/report/print/ \
-H "Authorization: Token " \
-H "Content-Type: application/json" \
-d '{"template": 10, "items": [1]}'
# Response: {"pk": 42, ...}
Step 3 — Download the generated PDF: curl http://localhost/media/data_output/output.pdf \
-H "Authorization: Token " \
-o output.pdf
# HTTP 200
Step 4 — Extract the exfiltrated data: pdftotext output.pdf - | grep -o 'base64,[A-Za-z0-9+/=]*' \
| cut -d, -f2 | base64 -d
The PDF body contains the base64-encoded contents of whatever file the tag resolved to. During testing, this successfully read /etc/passwd , the Django secret_key.txt , and config.yaml (database credentials).
Why the Django SECRET_KEY Read Matters
Most path traversal findings stop at "you can read /etc/passwd ." This one goes further.
InvenTree stores its Django SECRET_KEY in /home/inventree/src/backend/InvenTree/secret_key.txt . The Django SECRET_KEY is used to cryptographically sign:
Session cookies — an attacker who reads the key can forge a valid session for any user account, including superusers, without knowing any password
Password reset tokens — valid reset tokens can be generated for any account without email access
CSRF tokens
Reading a single file gives an attacker the ability to impersonate every user on the instance. The exploit doesn't just read files, it escapes the web application's security boundary into the OS-level filesystem and enables a full authentication bypass. The vendor scored it lower based on their threat model for staff users, which is a reasonable position given the staff-only prerequisite.
The Fix
The patch, applied in PR #11579 , adds a single boundary check after Path.resolve() in all three affected functions: full_path = settings.MEDIA_ROOT.joinpath(filename).resolve()
if not full_path.is_relative_to(settings.MEDIA_ROOT):
raise FileNotFoundError("File Not Found")
Path.is_relative_to() (available since Python 3.9) returns False if the resolved path has escaped the expected directory. That's it — two lines close the traversal for all three tags.
The lesson is a familiar one: joining user input onto a base path is not the same as scoping user input to that base path. Path.resolve() is doing exactly what it should — canonicalizing the path. The missing step was checking whether the canonical path was still in bounds.
CVSS
CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:C/C:H/I:N/A:N
Attack Vector: Network — Exploited via HTTP API
Attack Complexity: Low — No race condition or special timing required
Privileges Required: High — Staff account required for template creation
User Interaction: None — Print trigger requires no victim interaction
Scope: Changed — App escapes into OS filesystem (separate security authority)
Confidentiality: High — Arbitrary file read; confirmed SECRET_KEY exfiltration
Integrity: None — Read-only impact
Availability: None — No DoS impact
The official advisory was published with a lower score reflecting the vendor's threat model stance on staff-level trust. My proposed vector above reflects the demonstrated impact including the SECRET_KEY read.
Affected Versions and Remediation
1.2.6: Vulnerable
1.2.6: Patched
1.3.0: Patched
If you run InvenTree in a self-hosted environment, update to 1.2.6 or 1.3.0 immediately. There are no workarounds — the fix is in the application code.
Disclosure Timeline
2026–03–19: Vulnerability confirmed with live PoC in local Docker environment
2026–03–19: Private disclosure sent
2026–03–21: CVSS dispute submitted (Scope + Confidentiality)
2026–03–20: Advisory published: GHSA-rhc5–7c3r-c769
2026–03–20: CVE assigned: CVE-2026–33531
Responsible disclosure coordinated through InvenTree's SECURITY.md process.
Resources
GitHub Advisory : GHSA-rhc5–7c3r-c769
CVE : CVE-2026–33531
Fix PR : #11579
Research repo : github.com/alonaki/InvenTree-Path-Traversal-CVE-2026–33531
InvenTree Threat Model : docs.inventree.org
- **InvenTree Threat Model:** [docs.inventree.org]()
#cve-2026-33521 #path-traversal #cybersecurity #hacking #bug-bounty
Reporting a Problem
Sometimes we have problems displaying some Medium posts.
If you have a problem that some images aren't loading - try using VPN. Probably you have problem with
access to Medium CDN (or fucking Cloudflare's bot detection algorithms are blocking you).