T05 · Unauthorized Access and Privilege Escalation
- Location
scripts/index.py:151- Finding
Allowed-root validation can be bypassed through path-prefix confusion
- Content
View full analysis
list[str]: files = [] root = os.path.expanduser(root) if not os.path.isdir(root): print(f" Skipping {root} (not a directory)") return files # Security: validate path is under allowed roots root_real = os.path.realpath(root) allowed = any(root_real.startswith(os.path.realpath(ar)) for ar in ALLOWED_ROOTS) if not allowed: print(f" Skipping {root} (outside allowed directories)") return files ``` ### Technical Analysis The authorization check uses textual prefix matching rather than verifying a path-component relationship. For example, if `/home/user/Documenti` is an allowed root, the unrelated directory `/home/user/Documenti-private` also starts with the same string and is therefore accepted. Resolving the supplied path with `realpath()` prevents a direct root-directory symlink bypass, but it does not correct the missing path-separator boundary. The resulting behavior exceeds the documented filesystem scope and the minimum privileges required to index the configured document directories. ### Attack Path 1. An attacker or caller creates or identifies a readable directory whose path shares an allowed prefix, such as `~/Documenti-private`. 2. The caller invokes: ```bash index.py --paths ~/Documenti-private ``` 3. `os.path.realpath()` resolves the path. 4. `root_real.startswith(os.path.realpath("~/Documenti"))` evaluates to true despite the directory not being a descendant of `~/Documenti`. 5. Supported documents in the unintended directory are read and persisted in ChromaDB. 6. Their content can subsequently be retrieved through `query.py`. ### Impact Assessment The issue permits read access, hashing, text extraction, embedding, and persistent inde ...[truncated 367 chars]- Remediation
View remediation
bool: candidate = Path(path).expanduser().resolve(strict=True) for root in ALLOWED_ROOTS: allowed_root = Path(root).expanduser().resolve(strict=True) if candidate == allowed_root or candidate.is_relative_to(allowed_root): return True return False ``` For compatibility with older Python versions, use `os.path.commonpath()`: ```python def is_under_allowed_root(path: str) -> bool: candidate = os.path.realpath(os.path.expanduser(path)) for root in ALLOWED_ROOTS: allowed_root = os.path.realpath(os.path.expanduser(root)) try: if os.path.commonpath([candidate, allowed_root]) == allowed_root: return True except ValueError: continue return False ``` Apply the same canonical containment validation to every file immediately before opening it, not only to the initial crawl root. Add regression tests covering sibling prefix paths such as `Documenti-private`, relative paths, `..` components, and symbolic links. ]]>
