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:
-
Anchor to real path:
SHAPES_DIR = os.path.realpath('/shapes')resolves any symlinks or relative components in the base directory at startup. -
Controlled joining:
os.path.join(SHAPES_DIR, (path or '').strip('/'))prevents absolute path injection by stripping leading slashes, then joins safely. -
Containment verification:
os.path.realpath()fully resolves the candidate path, collapsing all../sequences. The checkcandidate.startswith(SHAPES_DIR + os.sep)ensures the final path lies strictly within the shapes directory (or equals it). Theos.sepsuffix prevents prefix attacks where/shapes-maliciousmight 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/passwdas the path parameter, which would otherwise bypassos.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
pathHTTP query parameter fromrequest.args.get('path')in thegraph3d()andgraph2d()Flask route handlers. -
Sink: The
shape_parser.parse_shapes_from_dir()call, which internally usesos.listdir()to enumerate the filesystem directory constructed from the unsanitized input. -
Missing control: No validation, sanitization, or normalization of the
pathparameter 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 usingos.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.