Affected Component
- Function:
add_to_template_storage()
- File:
include/inja/parser.hpp (lines 91-127)
- Also affects:
include/inja/parser.hpp lines 556-560 (include statement handler) and lines 565-570 (extends statement handler), both of which call add_to_template_storage() without prior path validation
- Also present in:
single_include/inja/inja.hpp (amalgamated single-header distribution, which is the recommended installation method per the project README)
Root Cause Analysis
The vulnerability is an incomplete input sanitization bug in the template include path resolution logic. The flow is as follows:
-
When the parser encounters an {% include "FILENAME" %} token, parse_statement() (line 556) extracts the filename via parse_filename() (line 559), which simply strips the surrounding quotes from the token text.
-
The filename is passed to add_to_template_storage(path, template_name) (line 560), where path is the current template directory (e.g., /var/www/templates/) and template_name is the raw user-supplied filename.
-
Inside add_to_template_storage(), when config.search_included_templates_in_files is true (the default), the path is constructed on line 100:
template_name = (path / original_name).string();
std::filesystem::path::operator/() joins the two path components. If original_name contains .. sequences (e.g., ../secret/credentials.txt), the filesystem resolves them as parent-directory references.
-
The only sanitization attempt follows on lines 101-103:
if (template_name.compare(0, 2, "./") == 0) {
template_name.erase(0, 2);
}
This strips a leading ./ prefix but performs no validation against .. sequences. An input like ../secret/credentials.txt bypasses this check entirely.
-
On line 108, the unsanitized path is opened:
file.open(template_name);
-
The file contents are read (line 110), parsed as a template (line 114 via parse_into_template()), stored in the template storage, and rendered into the final output -- exposing the file contents to whoever initiated the template rendering.
The same vulnerability also affects the {% extends "FILENAME" %} directive (lines 565-570), which calls add_to_template_storage() identically.
Additionally, when a template includes another template, parse_into_template() (line 675-678) creates a sub-parser with filename.parent_path() as its base directory. This means that with each nested include, the effective base directory shifts, making it easier to reach files outside the intended template root with fewer .. components.
Impact Assessment
CVSS 3.1 Vector and Score
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N = 7.5 HIGH
| Metric |
Value |
Rationale |
| Attack Vector |
Network |
The vulnerability can be triggered remotely when inja is used in a web-facing application |
| Attack Complexity |
Low |
No special conditions or prerequisites beyond supplying a crafted template |
| Privileges Required |
None |
No authentication needed to supply a template in affected scenarios |
| User Interaction |
None |
The attack is fully automated; no end-user action required |
| Scope |
Unchanged |
The impact is confined to the same security scope |
| Confidentiality |
High |
Full file read access on the host filesystem (subject to process permissions) |
| Integrity |
None |
The vulnerability only reads files; it does not modify them |
| Availability |
None |
The vulnerability does not affect system availability |
What an Attacker Can Achieve
- Read arbitrary files on the filesystem accessible to the process running inja, including but not limited to:
- Configuration files containing API keys, database credentials, or secrets
- Source code of the application
- Environment variable files (
.env)
- System files (
/etc/passwd, /etc/shadow if running as root)
- SSH private keys
- Cloud instance metadata files
- Chain with other vulnerabilities for escalated impact:
- If the application writes rendered output to a user-controllable location, path traversal in
include combined with extends could enable template injection
- If combined with an SSRF or another write primitive, could lead to remote code execution
Affected Configurations
The vulnerability is active when:
search_included_templates_in_files is enabled (default: true)
- Templates are parsed from files (not in-memory strings) or contain include/extends directives
- Template content is derived from, influenced by, or directly supplied by untrusted users
The include_callback configuration option (line 116-118) provides an alternative code path, but the default behavior (when no callback is set) is vulnerable.
Reproduction Steps
A working proof of concept has been developed and confirmed against inja v3.5.0 on Linux (g++ 13.3.0, C++17).
Prerequisites
git clone https://github.com/pantor/inja.git
cd inja
git checkout v3.5.0
Option 1: Python Wrapper (Recommended)
python3 targets/inja/poc_path_traversal.py
The Python script compiles the C++ PoC and executes it, producing:
=== CVE-CANDIDATE: pantor/inja path traversal ===
[*] Target version: 3.5.0
[*] Setup complete:
Template dir: /tmp/inja_poc_<timestamp>/templates
Secret file: /tmp/inja_poc_<timestamp>/secret/credentials.txt
Template: /tmp/inja_poc_<timestamp>/templates/main.txt
[*] Triggering path traversal...
[*] Render output: Output: SECRET_API_KEY=sk-test-123456789
[*] Verifying impact...
[+] VULNERABILITY CONFIRMED
[+] Impact: Arbitrary file read via path traversal in template include
Option 2: Manual Compilation
cd targets/inja/repo/
g++ -std=c++17 -I. -Ithird_party/include ../poc_path_traversal.cpp -o ../poc_path_traversal
../poc_path_traversal
PoC Description
The PoC creates a temporary directory with:
- A
templates/ subdirectory containing a malicious template with {% include "../secret/credentials.txt" %}
- A
secret/ subdirectory (one level above templates/) containing a file with dummy credentials
An inja::Environment is pointed at the templates/ directory. When the template is rendered, the include directive traverses up one directory and reads the credentials file. The rendered output contains the secret content, confirming the file was read.
PoC Files
| File |
Description |
targets/inja/poc_path_traversal.cpp |
Standalone C++ proof of concept |
targets/inja/poc_path_traversal.py |
Python wrapper that builds and runs the C++ PoC |
targets/inja/poc_path_traversal_README.md |
Additional PoC documentation |
Remediation Suggestions
The following fix approaches are recommended, listed from most to least robust:
Recommended Fix: Strict Path Validation
In add_to_template_storage(), validate the resolved path before opening the file:
void add_to_template_storage(const std::filesystem::path& path, std::string& template_name) {
if (template_storage.find(template_name) != template_storage.end()) {
return;
}
const std::string original_name = template_name;
if (config.search_included_templates_in_files) {
// Reject absolute paths
std::filesystem::path inc_path(original_name);
if (inc_path.is_absolute()) {
INJA_THROW(FileError("absolute paths are not allowed in includes"));
}
// Reject path traversal components
for (const auto& component : inc_path) {
if (component == "..") {
INJA_THROW(FileError("'..' components are not allowed in include paths"));
}
}
// Build the relative path
template_name = (path / original_name).string();
// Canonicalize and verify the resolved path is within the expected base directory
std::filesystem::path resolved = std::filesystem::weakly_canonical(template_name);
std::filesystem::path base = std::filesystem::weakly_canonical(path);
if (resolved.string().rfind(base.string(), 0) != 0) {
INJA_THROW(FileError("include path escapes the template directory"));
}
if (template_name.compare(0, 2, "./") == 0) {
template_name.erase(0, 2);
}
// ... rest of existing file-loading logic unchanged ...
}
// ...
}
Key checks:
- Reject absolute paths -- prevent
include "/etc/passwd"
- Reject
.. components -- prevent include "../secret/file.txt"
- Canonicalize and verify containment -- ensure the resolved path stays within the configured template directory, handling edge cases like symlinks
Alternative: Opt-in Strict Mode
For backward compatibility, introduce a new configuration flag:
struct ParserConfig {
// ... existing fields ...
bool restrict_include_to_template_dir {false}; // New flag
};
When enabled, the strict path validation above is applied. The default can be changed to true in a future major version.
Additional Recommendation: Recursion Depth Limit
While investigating this issue, a related finding was identified: the parser has no recursion depth limit for nested includes, which can lead to stack exhaustion via circular includes. Consider adding a configurable maximum include depth:
struct ParserConfig {
// ...
size_t max_include_depth {64}; // New flag
};
Affected Component
add_to_template_storage()include/inja/parser.hpp(lines 91-127)include/inja/parser.hpplines 556-560 (includestatement handler) and lines 565-570 (extendsstatement handler), both of which calladd_to_template_storage()without prior path validationsingle_include/inja/inja.hpp(amalgamated single-header distribution, which is the recommended installation method per the project README)Root Cause Analysis
The vulnerability is an incomplete input sanitization bug in the template include path resolution logic. The flow is as follows:
When the parser encounters an
{% include "FILENAME" %}token,parse_statement()(line 556) extracts the filename viaparse_filename()(line 559), which simply strips the surrounding quotes from the token text.The filename is passed to
add_to_template_storage(path, template_name)(line 560), wherepathis the current template directory (e.g.,/var/www/templates/) andtemplate_nameis the raw user-supplied filename.Inside
add_to_template_storage(), whenconfig.search_included_templates_in_filesis true (the default), the path is constructed on line 100:std::filesystem::path::operator/()joins the two path components. Iforiginal_namecontains..sequences (e.g.,../secret/credentials.txt), the filesystem resolves them as parent-directory references.The only sanitization attempt follows on lines 101-103:
This strips a leading
./prefix but performs no validation against..sequences. An input like../secret/credentials.txtbypasses this check entirely.On line 108, the unsanitized path is opened:
The file contents are read (line 110), parsed as a template (line 114 via
parse_into_template()), stored in the template storage, and rendered into the final output -- exposing the file contents to whoever initiated the template rendering.The same vulnerability also affects the
{% extends "FILENAME" %}directive (lines 565-570), which callsadd_to_template_storage()identically.Additionally, when a template includes another template,
parse_into_template()(line 675-678) creates a sub-parser withfilename.parent_path()as its base directory. This means that with each nested include, the effective base directory shifts, making it easier to reach files outside the intended template root with fewer..components.Impact Assessment
CVSS 3.1 Vector and Score
What an Attacker Can Achieve
.env)/etc/passwd,/etc/shadowif running as root)includecombined withextendscould enable template injectionAffected Configurations
The vulnerability is active when:
search_included_templates_in_filesis enabled (default:true)The
include_callbackconfiguration option (line 116-118) provides an alternative code path, but the default behavior (when no callback is set) is vulnerable.Reproduction Steps
A working proof of concept has been developed and confirmed against inja v3.5.0 on Linux (g++ 13.3.0, C++17).
Prerequisites
git clone https://github.com/pantor/inja.git cd inja git checkout v3.5.0Option 1: Python Wrapper (Recommended)
The Python script compiles the C++ PoC and executes it, producing:
Option 2: Manual Compilation
cd targets/inja/repo/ g++ -std=c++17 -I. -Ithird_party/include ../poc_path_traversal.cpp -o ../poc_path_traversal ../poc_path_traversalPoC Description
The PoC creates a temporary directory with:
templates/subdirectory containing a malicious template with{% include "../secret/credentials.txt" %}secret/subdirectory (one level abovetemplates/) containing a file with dummy credentialsAn
inja::Environmentis pointed at thetemplates/directory. When the template is rendered, the include directive traverses up one directory and reads the credentials file. The rendered output contains the secret content, confirming the file was read.PoC Files
targets/inja/poc_path_traversal.cpptargets/inja/poc_path_traversal.pytargets/inja/poc_path_traversal_README.mdRemediation Suggestions
The following fix approaches are recommended, listed from most to least robust:
Recommended Fix: Strict Path Validation
In
add_to_template_storage(), validate the resolved path before opening the file:Key checks:
include "/etc/passwd"..components -- preventinclude "../secret/file.txt"Alternative: Opt-in Strict Mode
For backward compatibility, introduce a new configuration flag:
When enabled, the strict path validation above is applied. The default can be changed to
truein a future major version.Additional Recommendation: Recursion Depth Limit
While investigating this issue, a related finding was identified: the parser has no recursion depth limit for nested includes, which can lead to stack exhaustion via circular includes. Consider adding a configurable maximum include depth: