mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-07-22 21:01:40 +00:00
* fix(library): serve art/load songs mounted through a library junction A song library mounted through a directory JUNCTION/symlink subfolder (a library shared across app installs; the desktop app's own mounts) had broken album art and couldn't load: the scanner's rglob follows the junction and indexes the songs, but _resolve_dlc_path (via safe_join's .resolve()) followed the junction to its real target, saw it outside DLC_DIR, and rejected every song reached through it → 403 on /art, 404 on /art/candidates, broken covers. - _resolve_dlc_path now uses LEXICAL containment (os.path.normpath, no symlink following) so an in-library junction is allowed, while `..` traversal and absolute paths are still rejected (the traversal tests pin this). - safe_join is left STRICT (.resolve()-based) — it is the zip-slip / plugin- asset / avatar guard, where following a symlink out IS the defense — but gains an explicit NUL guard (on Python 3.13/Windows resolve() no longer raises on an embedded NUL, so the byte was leaking through; strictly-more- rejection, no effect on the zip-slip contract). Tests: test_dlc_junction (junction allowed; `..`/absolute/NUL rejected; the safe_join-stays-strict contrast). Existing traversal/safepath/art-candidates suites stay green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(library): reject Windows drive-letter paths in _resolve_dlc_path The new test_absolute_path_rejected pins 'C:/Windows/system32/x' → None, but on POSIX a drive-letter path isn't absolute, so Path(dlc)/'C:/…' becomes the contained relative dir '<dlc>/C:/…' and slipped through the lexical containment check (red on the Linux CI). Not an escape, but the traversal contract should hold cross-platform (a shared library is reached from either OS). Reject a path that is absolute or drive-qualified in either POSIX or Windows semantics before the containment check. Legitimate relative/junction paths are unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
45 lines
1.6 KiB
Python
45 lines
1.6 KiB
Python
"""Path-containment helper for code that joins attacker-controlled names
|
|
under a server-owned root.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
def safe_join(root: Path, name: str) -> Path | None:
|
|
"""Resolve ``name`` under ``root`` and return the resolved Path, or
|
|
``None`` if it would escape ``root`` or is unrepresentable.
|
|
|
|
Rejects:
|
|
* empty names
|
|
* paths that resolve outside ``root`` (``..`` traversal, absolute paths)
|
|
* paths the OS can't resolve (embedded NULs, OSError on stat)
|
|
|
|
Normalizes:
|
|
* backslash separators to forward slash so a Windows-style entry
|
|
name inside a user-supplied archive can't bypass containment on
|
|
POSIX hosts (``..\\foo`` would otherwise be treated as a literal
|
|
single filename on Linux and resolve inside ``root`` — but on
|
|
Windows the same string IS a traversal; normalising means both
|
|
platforms reject it identically).
|
|
"""
|
|
if not name:
|
|
return None
|
|
# Reject embedded NULs explicitly. This used to ride on `.resolve()`
|
|
# raising ValueError, but on Python 3.13 (Windows) resolve() no longer
|
|
# raises for an embedded NUL, so the byte would otherwise leak through
|
|
# containment. An explicit guard is strictly-more-rejection (no effect on
|
|
# the zip-slip / traversal contract).
|
|
if "\x00" in name:
|
|
return None
|
|
safe = name.replace("\\", "/")
|
|
try:
|
|
root_resolved = root.resolve()
|
|
candidate = (root_resolved / safe).resolve()
|
|
if not candidate.is_relative_to(root_resolved):
|
|
return None
|
|
except (ValueError, OSError):
|
|
return None
|
|
return candidate
|