T05 · Unauthorized Access and Privilege Escalation
- Location
scripts/run.py:33- Finding
Repository Symlink Traversal Allows Out-of-Scope Local File Reads
- Content
View full analysis
Vulnerability Details
File Location:
scripts/run.py, lines 33–40 and 66–75
Vulnerability Type: Symlink traversal and insufficient repository-boundary validation
Risk Level: MediumVulnerable Code
python def list_text_files(root: Path, limit: int = 50): results = [] for path in root.rglob("*"): if len(results) >= limit: break if path.is_file(): if path.suffix.lower() in {".md",".txt",".json",".yaml",".yml",".py",".js",".ts",".csv",".tsv",".sh"}: results.append(path) return resultspython def directory_report(spec: dict, root: Path, limit: int) -> str: files = list_text_files(root, limit=limit) ext_counter = Counter(p.suffix.lower() or "<none>" for p in files) headings = [] for p in files[: min(10, len(files))]: if p.suffix.lower() == ".md": text = read_text(p) for line in text.splitlines(): if line.startswith("#"): headings.append((p.name, line.strip()))Technical Analysis
The directory scanner recursively enumerates paths under a user-supplied repository with
Path.rglob(). It then callsPath.is_file(), which follows symbolic links, and accepts each candidate based only on its apparent filename extension.Before
read_text()opens an accepted Markdown path, the implementation does not:- reject symbolic links;
- resolve the candidate to its canonical target;
- verify that the resolved target remains under the resolved repository root; or
- enforce a maximum file size before reading the entire file into memory.
Consequently, a repository-controlled symbolic link with a supported extension can point to an arbitrary file that is readable by the process. When such a link has a
.mdsuffix and is among the first ten sampled files, the target is opened and read in full. Lines beginning with#are subsequently included in the generated report.This violates t ...[truncated 1932 chars]
- Remediation
View remediation
Remediation Suggestions
- Reject symbolic links before classifying or reading candidate files:
python if path.is_symlink(): continue- Resolve both the repository root and every candidate, then enforce canonical containment:
python root_resolved = root.resolve() for path in root.rglob("*"): if path.is_symlink(): continue try: candidate = path.resolve(strict=True) candidate.relative_to(root_resolved) except (FileNotFoundError, RuntimeError, ValueError): continue if not candidate.is_file(): continue-
Open and read the validated canonical path rather than reusing an unchecked path. Where practical, use descriptor-based checks or compare file metadata before and after opening to reduce time-of-check/time-of-use risks.
-
Enforce a conservative maximum file size before reading:
python MAX_TEXT_FILE_SIZE = 1_000_000 if candidate.stat().st_size > MAX_TEXT_FILE_SIZE: continue-
Read only the bounded prefix or number of lines needed for heading extraction instead of calling
read_text()on the entire file. -
Apply the same containment, symlink, and size validation to every mode that accepts files or recursively enumerates directories, including pattern scanning.
-
Add automated tests covering:
- a supported-extension symlink to a file outside the repository;
- a symlink chain that resolves outside the repository;
- a broken symlink;
- an oversized linked or regular file;
- a valid regular file inside the repository;
- confirmation that no out-of-root content appears in generated reports.
