Skip to content

Commit 7519c26

Browse files
authored
Merge pull request #4417 from pipecat-ai/mb/resolve-runner-filepath
2 parents b2b7e9e + e864d57 commit 7519c26

5 files changed

Lines changed: 103 additions & 5 deletions

File tree

.github/workflows/coverage.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ jobs:
4242
--extra langchain \
4343
--extra livekit \
4444
--extra piper \
45+
--extra runner \
4546
--extra sagemaker \
4647
--extra tracing \
4748
--extra websocket

.github/workflows/tests.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ jobs:
4646
--extra langchain \
4747
--extra livekit \
4848
--extra piper \
49+
--extra runner \
4950
--extra sagemaker \
5051
--extra tracing \
5152
--extra websocket

changelog/4417.security.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
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.

src/pipecat/runner/run.py

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,17 @@ def _configure_server_app(args: argparse.Namespace):
209209
logger.warning(f"Unknown transport type: {args.transport}")
210210

211211

212+
def _resolve_download_path(folder: str, filename: str) -> Path:
213+
"""Resolve a download path and ensure it stays within the downloads folder."""
214+
allowed_base = Path(folder).resolve()
215+
file_path = (allowed_base / filename).resolve()
216+
217+
if not file_path.is_relative_to(allowed_base):
218+
raise HTTPException(status_code=403, detail="Access denied")
219+
220+
return file_path
221+
222+
212223
def _setup_webrtc_routes(app: FastAPI, args: argparse.Namespace):
213224
"""Set up WebRTC-specific routes."""
214225
try:
@@ -250,16 +261,16 @@ async def root_redirect():
250261
async def download_file(filename: str):
251262
"""Handle file downloads."""
252263
if not args.folder:
253-
logger.warning(f"Attempting to dowload {filename}, but downloads folder not setup.")
254-
return
264+
logger.warning(f"Attempting to download {filename}, but downloads folder not setup.")
265+
raise HTTPException(404)
255266

256-
file_path = Path(args.folder) / filename
257-
if not os.path.exists(file_path):
267+
file_path = _resolve_download_path(args.folder, filename)
268+
if not file_path.exists():
258269
raise HTTPException(404)
259270

260271
media_type, _ = mimetypes.guess_type(file_path)
261272

262-
return FileResponse(path=file_path, media_type=media_type, filename=filename)
273+
return FileResponse(path=file_path, media_type=media_type, filename=file_path.name)
263274

264275
# Initialize the SmallWebRTC request handler
265276
small_webrtc_handler: SmallWebRTCRequestHandler = SmallWebRTCRequestHandler(

tests/test_runner_downloads.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
#
2+
# Copyright (c) 2024-2026, Daily
3+
#
4+
# SPDX-License-Identifier: BSD 2-Clause License
5+
#
6+
7+
import os
8+
import tempfile
9+
import unittest
10+
from pathlib import Path
11+
12+
from fastapi import HTTPException
13+
14+
from pipecat.runner.run import _resolve_download_path
15+
16+
17+
class TestRunnerDownloads(unittest.TestCase):
18+
def test_resolve_download_path_allows_files_inside_folder(self):
19+
with tempfile.TemporaryDirectory() as tmpdir:
20+
downloads = Path(tmpdir) / "downloads"
21+
nested = downloads / "nested"
22+
nested.mkdir(parents=True)
23+
file_path = nested / "recording.txt"
24+
file_path.write_text("session transcript")
25+
26+
resolved = _resolve_download_path(str(downloads), "nested/recording.txt")
27+
28+
self.assertEqual(resolved, file_path.resolve())
29+
30+
def test_resolve_download_path_blocks_parent_traversal(self):
31+
with tempfile.TemporaryDirectory() as tmpdir:
32+
root = Path(tmpdir)
33+
downloads = root / "downloads"
34+
downloads.mkdir()
35+
(root / "secret.txt").write_text("secret")
36+
37+
with self.assertRaises(HTTPException) as context:
38+
_resolve_download_path(str(downloads), "../secret.txt")
39+
40+
self.assertEqual(context.exception.status_code, 403)
41+
42+
def test_resolve_download_path_blocks_decoded_encoded_slashes(self):
43+
with tempfile.TemporaryDirectory() as tmpdir:
44+
root = Path(tmpdir)
45+
downloads = root / "media"
46+
downloads.mkdir()
47+
outside = root / "outside"
48+
outside.mkdir()
49+
(outside / "secret.txt").write_text("secret")
50+
51+
with self.assertRaises(HTTPException) as context:
52+
_resolve_download_path(str(downloads), "../outside/secret.txt")
53+
54+
self.assertEqual(context.exception.status_code, 403)
55+
56+
def test_resolve_download_path_blocks_absolute_paths(self):
57+
with tempfile.TemporaryDirectory() as tmpdir:
58+
downloads = Path(tmpdir) / "downloads"
59+
downloads.mkdir()
60+
61+
with self.assertRaises(HTTPException) as context:
62+
_resolve_download_path(str(downloads), "/etc/passwd")
63+
64+
self.assertEqual(context.exception.status_code, 403)
65+
66+
@unittest.skipUnless(hasattr(os, "symlink"), "os.symlink is not available")
67+
def test_resolve_download_path_blocks_symlink_escape(self):
68+
with tempfile.TemporaryDirectory() as tmpdir:
69+
root = Path(tmpdir)
70+
downloads = root / "downloads"
71+
outside = root / "outside"
72+
downloads.mkdir()
73+
outside.mkdir()
74+
(outside / "secret.txt").write_text("secret")
75+
76+
try:
77+
os.symlink(outside, downloads / "linked")
78+
except OSError as e:
79+
self.skipTest(f"Unable to create symlink: {e}")
80+
81+
with self.assertRaises(HTTPException) as context:
82+
_resolve_download_path(str(downloads), "linked/secret.txt")
83+
84+
self.assertEqual(context.exception.status_code, 403)

0 commit comments

Comments
 (0)