Skip to content

Path Traversal in Template Include Mechanism Allows Arbitrary File Read #331

Description

@jiancanxuepiao

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:

  1. 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.

  2. 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.

  3. 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.

  4. 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.

  5. On line 108, the unsanitized path is opened:

    file.open(template_name);
  6. 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:

  1. Reject absolute paths -- prevent include "/etc/passwd"
  2. Reject .. components -- prevent include "../secret/file.txt"
  3. 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
};

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions