Skip to main content

Free 30-min security demo Book Now

Offensive360 Offensive360
ZeroDays CVE-2026-65702
High CVE-2026-65702 CVSS 8.6 Vanna Python

Vanna Path Traversal

CVE-2026-65702: A path traversal flaw in Vanna's FileSystemConversationStore lets unauthenticated attackers write and read arbitrary files on the server.

Offensive360 Research Team
Affects: <= 2.0.2
Source Code

Overview

CVE-2026-65702 is a path traversal vulnerability in Vanna, an open-source Python framework for natural language querying of SQL databases. The flaw resides in the FileSystemConversationStore persistence integration, which is responsible for storing and retrieving conversation state as JSON files on the local filesystem. When Vanna’s chat API endpoints accept a conversation_id parameter from client-supplied input, that value is incorporated directly into filesystem path construction without adequate sanitization. An unauthenticated remote attacker can supply path traversal sequences such as ../../ within the conversation_id to escape the intended base directory, achieving both arbitrary file write with attacker-controlled JSON content and unauthorized file read of any path accessible to the server process.

The vulnerability affects all Vanna releases through version 2.0.2 and was identified by security researchers examining the unauthenticated surface area of the chat API. Because Vanna is frequently deployed as an internal analytics tool — often without additional authentication layers in front of it — the blast radius is significant. Any deployment that exposes the chat API to a network where an attacker has access is directly exploitable without credentials.

The severity rating of 8.6 (HIGH) reflects the combination of unauthenticated access, network exploitability, and the dual-primitive nature of the flaw: attackers gain both a write primitive (arbitrary file placement) and a read primitive (unauthorized file disclosure), either of which independently constitutes a critical capability on most server environments.

Technical Analysis

The root cause is straightforward but consequential: the FileSystemConversationStore constructs filesystem paths by joining a configurable base_dir with the caller-supplied conversation_id without first validating or canonicalizing the result. A simplified but representative version of the vulnerable pattern looks like this:

# VULNERABLE — do not use
import os
import json

class FileSystemConversationStore:
    def __init__(self, base_dir: str = "./conversations"):
        self.base_dir = base_dir
        os.makedirs(self.base_dir, exist_ok=True)

    def _get_path(self, conversation_id: str) -> str:
        # FLAW: conversation_id is joined directly with no validation
        return os.path.join(self.base_dir, f"{conversation_id}.json")

    def save(self, conversation_id: str, data: dict) -> None:
        path = self._get_path(conversation_id)
        with open(path, "w") as f:
            json.dump(data, f)

    def load(self, conversation_id: str) -> dict:
        path = self._get_path(conversation_id)
        with open(path, "r") as f:
            return json.load(f)

On a POSIX system, os.path.join("/app/conversations", "../../etc/cron.d/backdoor") resolves to /app/conversations/../../etc/cron.d/backdoor, which the OS kernel canonicalizes to /etc/cron.d/backdoor. Python’s open() will happily follow that resolved path for both reads and writes, bypassing any logical directory boundary the developer intended to enforce.

The chat API endpoints pass the conversation_id from the HTTP request body or query string directly into save() and load(), with no authentication check and no sanitization step in between. This means an attacker sending a crafted HTTP request is the sole actor required — no session token, no prior interaction, no elevated privilege.

For the write primitive, an attacker can write a JSON-structured file to any writable path on the server: cron directories, SSH authorized_keys files (if the process runs as a user with a home directory), web server document roots, or application configuration files. The content is attacker-controlled within the constraint that it must be valid JSON, but many sensitive file formats tolerate or ignore extra JSON-structured content, and crafted payloads can work around this constraint depending on the target file’s parser.

For the read primitive, an attacker can exfiltrate any file on the filesystem that is readable by the server process and happens to be valid JSON, including application secrets, cached credentials, or internal configuration files stored as JSON.

Impact

The real-world impact depends on the privilege level of the Vanna server process and what is co-located on the filesystem, but several high-consequence scenarios are immediately viable:

  • Remote Code Execution (indirect): Writing a malicious payload to a cron job directory, a Python .pth file, or a WSGI configuration file can translate the file write primitive into full command execution on the next scheduled or triggered event.
  • Credential Theft: Reading JSON-formatted secrets files (e.g., .aws/credentials in JSON format, service account key files for GCP or cloud providers) directly exfiltrates long-lived credentials.
  • Application Takeover: Overwriting application configuration files can redirect database connections, disable authentication, or inject malicious logic into the running application stack.
  • Data Exfiltration: Any conversation history, query logs, or cached query results stored as JSON files anywhere on the reachable filesystem can be read by an unauthenticated attacker.

The CVSS 8.6 score is consistent with a network-exploitable, unauthenticated, low-complexity attack requiring no user interaction, with high impact on both integrity (arbitrary file write) and confidentiality (arbitrary file read), and partial scope change given the potential to affect components beyond the Vanna process itself.

How to Fix It

The canonical fix for path traversal is canonicalization followed by prefix assertion. After joining the base directory with the user-supplied identifier, resolve the real absolute path and confirm it still begins with the intended base directory before proceeding with any I/O operation.

# FIXED — canonicalize and assert containment before any I/O
import os
import json

class FileSystemConversationStore:
    def __init__(self, base_dir: str = "./conversations"):
        # Resolve base_dir to an absolute path at construction time
        self.base_dir = os.path.realpath(os.path.abspath(base_dir))
        os.makedirs(self.base_dir, exist_ok=True)

    def _get_safe_path(self, conversation_id: str) -> str:
        # Reject IDs containing path separators or null bytes early
        if not conversation_id or "/" in conversation_id or "\\" in conversation_id or "\x00" in conversation_id:
            raise ValueError(f"Invalid conversation_id: {conversation_id!r}")

        candidate = os.path.realpath(
            os.path.join(self.base_dir, f"{conversation_id}.json")
        )

        # Assert the resolved path is still inside the base directory
        if not candidate.startswith(self.base_dir + os.sep):
            raise ValueError(
                f"Path traversal detected for conversation_id: {conversation_id!r}"
            )

        return candidate

    def save(self, conversation_id: str, data: dict) -> None:
        path = self._get_safe_path(conversation_id)
        with open(path, "w") as f:
            json.dump(data, f)

    def load(self, conversation_id: str) -> dict:
        path = self._get_safe_path(conversation_id)
        with open(path, "r") as f:
            return json.load(f)

Key hardening points in the fixed version:

  1. Early rejection of separator characters in the conversation_id eliminates the most obvious traversal vectors before path construction.
  2. os.path.realpath() resolves symlinks and .. components, producing the true absolute path the OS will use.
  3. Prefix assertion with a trailing separator (self.base_dir + os.sep) prevents a base directory of /app/conv from incorrectly accepting /app/conversations-evil/file.json.
  4. Base directory resolved at construction time ensures the reference point itself cannot be manipulated.

To upgrade Vanna once a patched release is available, use:

pip install --upgrade vanna
# or pin to the minimum safe version once published
pip install "vanna>=2.0.3"

Regardless of the Vanna version in use, deployments should also enforce OS-level controls: run the Vanna server process under a dedicated low-privilege user account with filesystem access scoped only to what it legitimately requires.

Our Take

Path traversal vulnerabilities have appeared in software security advisories for over two decades, yet they continue to surface in modern frameworks precisely because the failure mode is subtle. Python’s os.path.join() behaves intuitively for most use cases, but its handling of untrusted input is not safe-by-default — it does not raise an error or strip traversal sequences. Developers who are not specifically thinking about adversarial input tend to assume the function “just works” safely because it works correctly in the normal case.

For enterprises running internal AI-assisted analytics tooling like Vanna, the risk profile is particularly concerning. These tools are often deployed rapidly on internal networks with the assumption that network perimeter controls provide sufficient protection. CVE-2026-65702 is a reminder that perimeter assumptions are fragile, and a single exposed endpoint — even one presumed to be internal-only — can become a full server compromise vector.

From a secure development lifecycle standpoint, any parameter that influences a filesystem path must be treated as untrusted input and subjected to allowlist validation and canonicalization before use. This is not a nuanced security engineering problem; it is a well-understood, well-documented requirement that belongs in developer security training and code review checklists at every organization.

Detection with SAST

SAST tools detect this vulnerability class by tracing data flow from untrusted sources (HTTP parameters, request bodies, headers) to sink functions that perform filesystem operations (open(), os.path.join(), pathlib.Path(), shutil operations). The absence of a canonicalization-plus-containment check between source and sink is the signal that flags the finding.

This vulnerability maps to CWE-22: Improper Limitation of a Pathname to a Restricted Directory (‘Path Traversal’) and its child CWE-23: Relative Path Traversal. Offensive360’s SAST engine applies taint-tracking rules in this category that specifically model Python’s os.path.join() as a non-sanitizing combinator, meaning any tainted string flowing into it without a prior realpath()-and-prefix-check is flagged as a potential traversal sink.

Rules to ensure your SAST configuration covers:

  • Source tagging: HTTP request parameters, form data, JSON body fields, and query string values must all be marked as tainted sources.
  • Sink coverage: open(), os.open(), pathlib.Path.open(), os.makedirs(), shutil.copy(), and any function accepting a path derived from os.path.join().
  • Sanitizer recognition: Only os.path.realpath() or pathlib.Path.resolve() followed by an explicit prefix assertion should be recognized as sanitizers for this CWE. os.path.basename() alone is not a sufficient sanitizer and should not be modeled as one.

DAST coverage of this class requires sending ../-prefixed payloads in all string-typed API parameters and observing whether error messages, response content, or side effects indicate successful traversal — a targeted probe that Offensive360’s dynamic scanning modules include by default for REST and chat-style API endpoints.

References

#path-traversal #file-write #unauthenticated #python

Detect this vulnerability class in your codebase

Offensive360 SAST scans your source code for CVE-2026-65702-class vulnerabilities and thousands of other patterns — across 60+ languages.