T05 · Unauthorized Access and Privilege Escalation
- Location
scripts/obsidian_utils.py:20- Finding
Wikilink Path Traversal Allows Markdown Files Outside the Vault to Be Read
- Content
View full analysis
Optional[Path]: """Resolve a wikilink target to a markdown file path.""" target = normalize_wikilink_target(link_name) if not target: return None exact = zettel_dir / f"{target}.md" if exact.exists(): return exact ``` The returned path is subsequently read during recursive traversal: ```python try: content = current_path.read_text(encoding="utf-8") except Exception as exc: print(f"Warning: could not read {current_path}: {exc}", file=sys.stderr) continue ``` ### Technical Analysis The normalized wikilink target is appended directly to the configured `zettel_dir`. The implementation does not reject: - Absolute path targets. - Parent-directory components such as `..`. - Paths that resolve outside the configured vault. - Symlinks whose resolved targets are outside the vault. For example, a vault note containing a link such as: ```markdown [[../../private/secret]] ``` causes the code to construct a path equivalent to: ```text /../../private/secret.md ``` If that file exists, `find_note_path()` returns it. `extract_links_recursive()` then reads its contents. Because retrieved note content is intended for later preprocessing and drafting subagents, the out-of-vault content may also be placed into model context or reproduced in generated output. The fallback `rglob("*.md")` search remains scoped to the vault, but it does not mitigate the unsafe direct-path branch. ### Attack Path 1. An attacker creates, imports, or modifies a Markdown note within the configured vault. 2. The note includes a crafted wikilink containing parent-directory traversal or an absolute path. 3. Th ...[truncated 1119 chars]- Remediation
View remediation
Optional[Path]: target = normalize_wikilink_target(link_name) if not target: return None target_path = Path(target) if target_path.is_absolute() or ".." in target_path.parts: return None root = zettel_dir.expanduser().resolve() candidate = (root / f"{target}.md").resolve() try: candidate.relative_to(root) except ValueError: return None if candidate.is_file(): return candidate for note in root.rglob("*.md"): resolved_note = note.resolve() try: resolved_note.relative_to(root) except ValueError: continue if resolved_note.stem.lower() == target.lower(): return resolved_note return None ``` Additional hardening should include: 1. Define and enforce a symlink policy. Reject symlinks or validate every resolved symlink target against the vault root. 2. Validate that the seed note is within the configured vault when the workflow is intended to be vault-only. 3. Recheck containment immediately before every file read, rather than relying only on the resolver. 4. Reject null bytes, absolute paths, and traversal components in wikilink targets. 5. Add regression tests for: - `[[../outside]]` - `[[../../outside]]` - Absolute-path wikilinks - Symlinks pointing outside the vault - Valid nested notes inside the vault 6. Avoid passing absolute local paths or unauthorized file content into model context. ]]>
