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
Published September 18, 2026Reviewed 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

high

fs.readFileSync(process.argv[2]) Path Traversal in Zola Build

A build-time helper that extracts the expected SHA-256 for a downloaded Zola release passed `process.argv[2]` straight into `fs.readFileSync()` with no directory constraint, so any caller able to influence that argument could make the integrity check read an arbitrary file. The fix resolves the requested path and requires it to be a direct child of the tools directory, which is now passed in as an extra argument, and exits with an error otherwise. Because the bytes read become the "expected" che

high

write_page_jobs(): Unvalidated page_dir Escapes the Run Dir

The deck preparation runtime built per-page working directories by joining the `page_dir` string from a deck state document directly onto the run directory, with no containment check and no schema validation. A crafted or tampered deck record could therefore steer `page_request.json` writes anywhere on the filesystem the process could reach, including outside the run sandbox entirely. The fix routes both call sites through a single `page_dir_for(run_dir, page)` helper so the untrusted `page_dir`

high

modelExporter.js Path Traversal via Unsanitized Directory Concatenation

A path traversal vulnerability in `modelExporter.js` allowed attackers to read arbitrary files by injecting traversal sequences into directory and relative path parameters. The `readSourceFile` function concatenated these unsanitized inputs directly into file URLs passed to `fetch()`. The fix introduces strict path normalization that rejects attempts to escape the intended directory.

critical

How path traversal happens in PHP virtual filesystem adapters and how to fix it

A critical path traversal flaw in `VirtualAdapter.php`'s `resolveMount()` method allowed attackers to escape mounted directory boundaries using sequences like `../../../etc/passwd`. The fix introduces `PathPolicy::normalizeRelative()` to sanitize the remaining path segment before it ever reaches the underlying storage adapter.

high

How Path Traversal Happens in TensorFlow's Data Service and How to Fix It

TensorFlow's data service dispatcher validated dataset IDs against forward-slash traversal attacks but overlooked backslash characters on non-Windows platforms, allowing attackers to escape the root directory. A targeted fix adds explicit backslash validation across all platforms, closing a high-severity path traversal vulnerability in the snapshot management system.

high

package_abridge.js Command Injection via Unsanitized CLI Arguments

A high-severity command injection vulnerability in a build script allowed attackers who control CLI arguments to execute arbitrary shell commands by injecting metacharacters into an unvalidated parameter. The fix validates incoming CLI arguments and rejects those containing dangerous shell metacharacters before they reach command execution.