Skip to content

Commit 9f46c24

Browse files
Merge pull request #16 from musher-dev/fix/security-integrity-audit
fix: resolve path traversal, cache integrity, and HTTP parsing vulnerabilities
2 parents 67e55b4 + 31c6c0c commit 9f46c24

12 files changed

Lines changed: 149 additions & 16 deletions

File tree

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -157,8 +157,8 @@ Cache management functions are available at the module level and on the `Client`
157157
import musher
158158

159159
info = musher.cache_info() # cache statistics
160-
musher.cache_remove("myorg/my-bundle:1.0.0") # remove a specific bundle
161-
musher.cache_clean() # remove expired entries
160+
musher.cache_remove("myorg/my-bundle:1.0.0") # remove cached metadata for a bundle version
161+
musher.cache_clean() # reclaim expired entries and unreferenced blobs
162162
musher.cache_clear() # remove all cached data
163163
path = musher.cache_path() # cache directory path
164164
```

docs/configuration.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@ The SDK uses platform-aware directory resolution with the following precedence:
1616
| config | `MUSHER_CONFIG_HOME` | `~/.config/musher` | `~/Library/Application Support/musher` | `%LOCALAPPDATA%\musher\config` |
1717
| data | `MUSHER_DATA_HOME` | `~/.local/share/musher` | `~/Library/Application Support/musher` | `%LOCALAPPDATA%\musher\data` |
1818
| state | `MUSHER_STATE_HOME` | `~/.local/state/musher` | `~/Library/Application Support/musher` | `%LOCALAPPDATA%\musher\state` |
19-
| runtime | `MUSHER_RUNTIME_DIR` | `$XDG_RUNTIME_DIR/musher` | `~/Library/Caches/TemporaryItems/musher` | `%LOCALAPPDATA%\musher\runtime` |
19+
| runtime | `MUSHER_RUNTIME_DIR` | `$XDG_RUNTIME_DIR/musher` | `<tempdir>/musher/run`* | `<tempdir>\musher\run`* |
20+
21+
\* `<tempdir>` is the system temporary directory (e.g. `/tmp` on macOS, `C:\Users\<user>\AppData\Local\Temp` on Windows).
2022

2123
On Windows, the SDK uses a flat layout under `%LOCALAPPDATA%\musher\` with category subdirectories rather than relying on `platformdirs`, which maps some categories to the same physical path.
2224

src/musher/__init__.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@
2323
MusherError,
2424
RateLimitError,
2525
RegistryError,
26-
VersionNotFoundError,
2726
)
2827
from musher._export import ClaudePluginExport, OpenAIInlineSkill, OpenAILocalSkill
2928
from musher._handles import (
@@ -81,7 +80,6 @@
8180
"ResolveResult",
8281
"SkillHandle",
8382
"ToolsetHandle",
84-
"VersionNotFoundError",
8583
"__version__",
8684
"cache_clean",
8785
"cache_clear",

src/musher/_bundle.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,8 @@ def _place_skill_file(
138138
candidate_root = str(PurePosixPath(*parts[:i]))
139139
if f"{candidate_root}/SKILL.md" in assets:
140140
rel = str(PurePosixPath(*parts[i:]))
141+
if ".." in PurePosixPath(rel).parts:
142+
return
141143
skill_roots.setdefault(candidate_root, {})[rel] = file_handles[asset.logical_path]
142144
return
143145
# Top-level skill file without nested directory

src/musher/_cache.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,7 @@ def clear(self) -> None:
365365
"""Remove all cached data."""
366366
if self._cache_dir.is_dir():
367367
shutil.rmtree(self._cache_dir)
368+
self._tag_written = False
368369

369370
# ── Internal ───────────────────────────────────────────────────
370371

src/musher/_client.py

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,10 @@ async def pull(self, ref: str) -> Bundle:
189189
for layer in result.manifest.layers:
190190
cached_blob = self._cache.get_blob(layer.content_sha256)
191191
if cached_blob is not None:
192+
if self._config.verify_checksums:
193+
actual_sha = hashlib.sha256(cached_blob).hexdigest()
194+
if actual_sha != layer.content_sha256:
195+
raise IntegrityError(expected=layer.content_sha256, actual=actual_sha)
192196
assets[layer.logical_path] = Asset(
193197
asset_id=layer.asset_id,
194198
logical_path=layer.logical_path,
@@ -233,25 +237,33 @@ def _build_assets_from_pull(
233237
content = str(item.get("contentText", "")).encode()
234238
layer = layer_map.get(logical_path)
235239

236-
# Verify checksum against the resolve manifest
237-
if layer and self._config.verify_checksums:
238-
actual_sha = hashlib.sha256(content).hexdigest()
239-
if actual_sha != layer.content_sha256:
240-
raise IntegrityError(expected=layer.content_sha256, actual=actual_sha)
240+
# Always compute actual hash — never cache under an unverified claimed hash
241+
actual_sha = hashlib.sha256(content).hexdigest()
241242

242-
content_sha256 = layer.content_sha256 if layer else hashlib.sha256(content).hexdigest()
243-
self._cache.put_blob(content_sha256, content)
243+
if layer and self._config.verify_checksums and actual_sha != layer.content_sha256:
244+
raise IntegrityError(expected=layer.content_sha256, actual=actual_sha)
245+
246+
self._cache.put_blob(actual_sha, content)
244247

245248
media_type = str(item.get("mediaType") or "") or (layer.media_type if layer else None)
246249
assets[logical_path] = Asset(
247250
asset_id=layer.asset_id if layer else logical_path,
248251
logical_path=logical_path,
249252
asset_type=AssetType(str(item["assetType"])),
250253
content=content,
251-
content_sha256=content_sha256,
254+
content_sha256=actual_sha,
252255
size_bytes=layer.size_bytes if layer else len(content),
253256
media_type=media_type or None,
254257
)
258+
259+
# Enforce manifest completeness — all expected layers must be present
260+
missing = set(layer_map.keys()) - set(assets.keys())
261+
if missing:
262+
raise IntegrityError(
263+
expected=f"all {len(layer_map)} manifest layers",
264+
actual=f"missing {len(missing)} layers: {', '.join(sorted(missing))}",
265+
)
266+
255267
return assets
256268

257269
async def _pull_version(self, namespace: str, slug: str, version: str) -> dict[str, object]:

src/musher/_handles.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,20 @@
88
import tempfile
99
import zipfile
1010
from dataclasses import dataclass, field
11-
from pathlib import Path
11+
from pathlib import Path, PurePosixPath
1212
from typing import cast
1313

1414
from musher._export import ClaudePluginExport, OpenAIInlineSkill, OpenAILocalSkill
1515

1616

17+
def _validate_relative_path(relative_path: str) -> None:
18+
"""Reject paths that could escape the target directory."""
19+
p = PurePosixPath(relative_path)
20+
if p.is_absolute() or ".." in p.parts:
21+
msg = f"Unsafe relative path in skill: {relative_path}"
22+
raise ValueError(msg)
23+
24+
1725
@dataclass(frozen=True, slots=True)
1826
class FileHandle:
1927
"""Typed handle to a single file within a bundle."""
@@ -66,6 +74,7 @@ def export_openai_inline_skill(self) -> OpenAIInlineSkill:
6674
buf = io.BytesIO()
6775
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
6876
for relative_path, fh in self._files.items():
77+
_validate_relative_path(relative_path)
6978
zf.writestr(f"{self.name}/{relative_path}", fh.bytes())
7079
content_b64 = base64.b64encode(buf.getvalue()).decode("ascii")
7180
return OpenAIInlineSkill(
@@ -81,6 +90,7 @@ def export_path(self, dest: Path | None = None) -> Path:
8190

8291
skill_dir = dest / self.name
8392
for relative_path, fh in self._files.items():
93+
_validate_relative_path(relative_path)
8494
out = skill_dir / relative_path
8595
out.parent.mkdir(parents=True, exist_ok=True)
8696
_ = out.write_bytes(fh.bytes())
@@ -95,6 +105,7 @@ def export_zip(self, dest: Path | None = None) -> Path:
95105
zip_path = dest / f"{self.name}.zip"
96106
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
97107
for relative_path, fh in self._files.items():
108+
_validate_relative_path(relative_path)
98109
zf.writestr(f"{self.name}/{relative_path}", fh.bytes())
99110
return zip_path
100111

src/musher/_http.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
from __future__ import annotations
44

5+
from datetime import UTC, datetime
6+
from email.utils import parsedate_to_datetime
57
from typing import TYPE_CHECKING
68

79
import httpx
@@ -63,6 +65,19 @@ async def close(self) -> None:
6365
self._client = None
6466

6567

68+
def _parse_retry_after(header: str) -> float | None:
69+
"""Parse Retry-After as delay-seconds or HTTP-date (RFC 9110)."""
70+
try:
71+
return float(header)
72+
except ValueError:
73+
pass
74+
try:
75+
dt = parsedate_to_datetime(header)
76+
return max(0.0, (dt - datetime.now(tz=UTC)).total_seconds())
77+
except (ValueError, TypeError):
78+
return None
79+
80+
6681
def _raise_for_status(response: httpx.Response) -> None:
6782
"""Map HTTP error responses to SDK exceptions."""
6883
if response.is_success:
@@ -78,7 +93,9 @@ def _raise_for_status(response: httpx.Response) -> None:
7893

7994
if status == 429: # noqa: PLR2004
8095
retry_after_header: str | None = response.headers.get("Retry-After") # pyright: ignore[reportAny]
81-
raise RateLimitError(retry_after=float(retry_after_header) if retry_after_header else None)
96+
raise RateLimitError(
97+
retry_after=_parse_retry_after(retry_after_header) if retry_after_header else None
98+
)
8299

83100
# Try RFC 9457 Problem Details
84101
try:

tests/test_cache.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,18 @@ def test_clear_noop_if_missing(self, tmp_path: Path):
238238
cache = BundleCache(cache_dir=tmp_path / "nonexistent")
239239
cache.clear() # should not raise
240240

241+
def test_clear_then_write_recreates_cachedir_tag(self, tmp_path: Path):
242+
cache = BundleCache(cache_dir=tmp_path)
243+
cache.put_blob("ab" * 32, b"data")
244+
tag = tmp_path / "CACHEDIR.TAG"
245+
assert tag.is_file()
246+
cache.clear()
247+
assert not tag.exists()
248+
# Write again on the same instance — tag must be recreated
249+
cache.put_blob("cd" * 32, b"data2")
250+
assert tag.is_file()
251+
assert tag.read_text().startswith("Signature: 8a477f597d28d172789f06886806bc55")
252+
241253

242254
class TestBlobOverwrite:
243255
def test_blob_overwrite_succeeds(self, tmp_path: Path):

tests/test_errors.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@
99
MusherError,
1010
RateLimitError,
1111
RegistryError,
12-
VersionNotFoundError,
1312
)
13+
from musher._errors import VersionNotFoundError
1414

1515

1616
class TestHierarchy:

0 commit comments

Comments
 (0)