Back to Blog
high SEVERITY4 min read

SHACL Viewer Path Traversal in graph3d(): Unvalidated `path`

The `graph3d()` and `graph2d()` request handlers in SHACL Viewer directly concatenated user-supplied `path` parameters into filesystem paths, enabling directory traversal outside the intended `/shapes/` directory. The fix introduces `_resolve_shapes_path()` with `os.path.realpath()` validation to enforce containment within the shapes directory.

O
By Orbis AppSec
•Technically reviewed by Anupam Mediratta•Published September 18, 2026•Reviewed September 18, 2026

Answer Summary

The `graph3d()` and `graph2d()` endpoints in SHACL Viewer's Flask application are affected in all versions prior to the fix commit. An attacker can supply `../` sequences in the `path` query parameter to escape `/shapes/` and enumerate or access arbitrary files and directories on the server filesystem. The fix introduces a `_resolve_shapes_path()` helper that uses `os.path.realpath()` to normalize paths and aborts with HTTP 400 if the resolved path falls outside the declared `SHAPES_DIR`. This addresses CWE-22 (Improper Limitation of a Pathname to a Restricted Directory).

Vulnerability at a Glance

cweCWE-22
fixAdded `_resolve_shapes_path()` helper with `os.path.realpath()` validation and explicit directory boundary enforcement
riskArbitrary filesystem enumeration and file access via HTTP GET
languagePython
root causeDirect string concatenation of user input into filesystem paths without normalization or containment checks
vulnerabilityPath Traversal

Affected Versions

Affected All versions prior to the fix commit
Fixed in Commit with _resolve_shapes_path() introduction
Ecosystem Python/Flask (first-party application code)
CVE / GHSA not assigned
CWE CWE-22 (Improper Limitation of a Pathname to a Restricted Directory)

The Vulnerability Explained

The SHACL Viewer application provides HTTP endpoints for visualizing SHACL shape graphs. Two of these endpoints—graph3d() and graph2d()—accept a path query parameter that specifies which subdirectory under /shapes/ to load.

The vulnerable code directly concatenated this parameter:

def graph3d():
    path = request.args.get('path')
    shape_parser = ShapeParser()
    graph = shape_parser.parse_shapes_from_dir('/shapes/' + path + '/')

This pattern appears in both graph3d() and graph2d(). The path parameter receives no validation, sanitization, or normalization before being embedded in a filesystem path. An attacker can inject directory traversal sequences—../ or encoded variants—to escape the intended /shapes/ directory.

The impact extends beyond simple file reading. The ShapeParser.parse_shapes_from_dir() method likely uses os.listdir() (referenced in the vulnerability assessment at line 82), meaning an attacker can enumerate the contents of any directory readable by the application process. This includes system directories, application configuration, or sensitive deployment files.

Consider this attack request:

GET /graph3d?path=../../../etc

The resulting path becomes /shapes/../../../etc/, which resolves to /etc/. The shape parser would then enumerate and attempt to parse files from the system configuration directory, potentially exposing file names, directory structures, or in combination with other vulnerabilities, file contents.

The Fix

The remediation introduces a dedicated path resolution helper that enforces directory containment through filesystem-level normalization:

SHAPES_DIR = os.path.realpath('/shapes')

def _resolve_shapes_path(path):
    """Resolve a user-supplied sub-path under SHAPES_DIR, rejecting any
    attempt (e.g. via '../') to escape outside of the shapes directory."""
    candidate = os.path.realpath(os.path.join(SHAPES_DIR, (path or '').strip('/')))
    if candidate != SHAPES_DIR and not candidate.startswith(SHAPES_DIR + os.sep):
        abort(400)
    return candidate

The fix replaces both vulnerable concatenations:

# Before:
graph = shape_parser.parse_shapes_from_dir('/shapes/' + path + '/')

# After:
graph = shape_parser.parse_shapes_from_dir(_resolve_shapes_path(path) + '/')

The security mechanism works in three stages:

  1. Anchor to real path: SHAPES_DIR = os.path.realpath('/shapes') resolves any symlinks or relative components in the base directory at startup.

  2. Controlled joining: os.path.join(SHAPES_DIR, (path or '').strip('/')) prevents absolute path injection by stripping leading slashes, then joins safely.

  3. Containment verification: os.path.realpath() fully resolves the candidate path, collapsing all ../ sequences. The check candidate.startswith(SHAPES_DIR + os.sep) ensures the final path lies strictly within the shapes directory (or equals it). The os.sep suffix prevents prefix attacks where /shapes-malicious might match /shapes.

The abort(400) response terminates traversal attempts immediately without revealing directory existence information.

Key Takeaways

  • Never concatenate user input into filesystem paths: The '/shapes/' + path + '/' pattern is a direct path to directory traversal. Even "internal" or "admin" endpoints require rigorous path validation.

  • os.path.realpath() is essential for containment checks: Simple string operations fail against encoded traversal sequences, Unicode normalization attacks, and symlink following. Filesystem-level resolution is required.

  • Verify containment with path prefix, not equality: The fix correctly uses startswith(SHAPES_DIR + os.sep) rather than == SHAPES_DIR, allowing legitimate subdirectories while blocking escapes. The separator suffix prevents partial directory name matches.

  • Strip absolute path indicators before joining: The (path or '').strip('/') handling prevents attackers from supplying /etc/passwd as the path parameter, which would otherwise bypass os.path.join()'s directory-switching behavior.

  • Regression tests should validate the invariant, not specific attacks: The provided test parametrizes multiple traversal payloads and asserts the security property—"file operations never resolve paths outside the declared root directory"—rather than checking for specific error messages.

How Orbis AppSec Detected This

  • Source: The path HTTP query parameter from request.args.get('path') in the graph3d() and graph2d() Flask route handlers.

  • Sink: The shape_parser.parse_shapes_from_dir() call, which internally uses os.listdir() to enumerate the filesystem directory constructed from the unsanitized input.

  • Missing control: No validation, sanitization, or normalization of the path parameter before concatenation into a filesystem path; specifically, absence of path traversal sequence filtering and lack of directory containment verification.

  • CWE: CWE-22 — Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal')

  • Fix: Introduced _resolve_shapes_path() helper using os.path.realpath() for canonical path resolution with explicit prefix-based containment checks, aborting with HTTP 400 on validation failure.

Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.

Conclusion

This vulnerability demonstrates how even "internal" visualization endpoints can become critical security risks when filesystem operations trust user input. The direct string concatenation pattern in graph3d() and graph2d() is a classic instance of CWE-22 that the _resolve_shapes_path() helper eliminates through defense-in-depth: real path resolution, controlled joining, and explicit containment verification. For Flask applications handling filesystem paths, this pattern provides a reusable template for safe directory traversal.

Prevention and further reading

Frequently Asked Questions

Why does `graph3d()` use `os.listdir()` on a user-controlled path rather than a fixed shapes directory?

The endpoint was designed to allow dynamic selection of shape subdirectories, but implemented this by direct string concatenation (`'/shapes/' + path + '/'`) rather than through a sandboxed resolution mechanism.

Does the `_resolve_shapes_path()` fix block absolute paths like `/etc/passwd` as well as `../` sequences?

Yes. The fix uses `os.path.realpath(os.path.join(SHAPES_DIR, path.strip('/')))`, which resolves any absolute path component relative to `SHAPES_DIR`, then verifies the result starts with `SHAPES_DIR + os.sep`. Both `/etc/passwd` and `../../../etc/passwd` would fail this check.

Is the `home_page()` endpoint also vulnerable, and why does the diff show an incomplete change there?

The `home_page()` endpoint appears to follow the same vulnerable pattern with `full_path = "/shapes/" + path + "/"`. The diff shows `_resolve_s` cut off, suggesting the same `_resolve_shapes_path()` fix was intended but the patch was truncated in the provided context.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #4

Related Articles

critical

path.resolve() Path Traversal in Node CLI's Dynamic import()

A Node.js CLI script for validating expression definitions took a file path from `process.argv[2]`, resolved it with `path.resolve()`, and passed the result straight into a dynamic `import()` — with no check that the resolved path stayed inside the working directory. An attacker (or a malicious skill/plugin invocation) could supply traversal sequences to load and execute arbitrary `.js` files from anywhere on disk. The fix adds a boundary check with `path.relative()` and an extension allowlist b

high

bookDir() Path Traversal via Unsanitized bookId Parameter

The `bookDir()` function accepted unsanitized `bookId` values derived from user-created book titles, enabling path traversal attacks through `../` sequences. A fix was applied that validates the identifier using `path.basename()` and throws on mismatch, ensuring all resolved paths remain within `LIBRARY_DIR`.

high

Express `app.get('*')` Wildcard Handler Path Traversal in watch.js

A first-party Express server's wildcard route handler used `req.url.indexOf('font.woff2')` to gate access to a font file, allowing attackers to bypass the substring check with crafted paths. The fix replaces the catch-all handler with explicit route registration.

high

updateCardBg() Follows Unvalidated 302 Location Headers

A background-image updater fetched a configured image URL with manual redirect handling and then re-issued the request to whatever `Location` header came back, with no scheme or host checks. A redirect to `http://169.254.169.254/` or `http://127.0.0.1:<port>/` would have been followed with the original fetch options attached, and the response body written to disk as an image asset. The fix resolves the redirect target against `imgDownloadUrl` and rejects anything that is not HTTPS on the same ho

high

markitdown_bridge.py Path Traversal: Arbitrary File Read via sys.argv

The markitdown_bridge.py script, used by MDView for DOCX-to-Markdown conversion, accepted file paths directly from command-line arguments without validating they stayed within intended directories. An attacker could exploit this to read arbitrary files from the filesystem by passing path traversal sequences in the source_path parameter.

high

picomatch 2.3.1 ReDoS: Extglob Pattern Catastrophic Backtracking

picomatch versions below 2.3.2, 3.0.2, or 4.0.4 contain a Regular Expression Denial of Service vulnerability in extglob pattern parsing. An attacker can cause catastrophic backtracking with patterns containing nested alternations and quantifiers, freezing any Node.js process that evaluates untrusted glob expressions.