security in path list

This commit is contained in:
Mateusz Gruszczyński
2026-06-19 15:31:22 +02:00
parent f3bf67a641
commit 377e602bd3
3 changed files with 72 additions and 20 deletions
+14 -1
View File
@@ -1036,6 +1036,18 @@
}, },
"path": { "path": {
"type": "string" "type": "string"
},
"root": {
"type": "string"
},
"allowed_roots": {
"items": {
"type": "string"
},
"type": "array"
},
"fallback": {
"type": "boolean"
} }
}, },
"type": "object" "type": "object"
@@ -5685,7 +5697,8 @@
"sessionCookie": [] "sessionCookie": []
} }
], ],
"summary": "Browse server directories" "summary": "Browse allowed rTorrent directories",
"description": "Lists only the rTorrent home or default download directory tree. Requests outside these roots fall back to the default download directory."
} }
}, },
"/api/path/default": { "/api/path/default": {
+43 -5
View File
@@ -6,10 +6,45 @@ from .config import default_download_path
from ...utils import human_size from ...utils import human_size
def browse_path(profile: dict, path: str | None = None) -> dict:
"""List directories through rTorrent execute.capture to avoid pyTorrent FS permissions.""" def _remote_path_contains(base: str, candidate: str) -> bool:
clean_base = _remote_clean_path(str(base or "").rstrip("/") or "/")
clean_candidate = _remote_clean_path(str(candidate or "").rstrip("/") or "/")
return clean_candidate == clean_base or clean_candidate.startswith(clean_base.rstrip("/") + "/")
def _rtorrent_home_path(profile: dict) -> str:
# Note: This reads the remote rTorrent process home, not the pyTorrent server home.
try:
c = client_for(profile) c = client_for(profile)
base = _remote_clean_path(path or default_download_path(profile)) return _remote_clean_path(str(_rt_execute(c, "execute.capture", "sh", "-c", 'printf "%s" "${HOME:-}"') or "").strip())
except Exception:
return ""
def _path_browse_roots(profile: dict) -> list[str]:
roots: list[str] = []
for item in (default_download_path(profile), _rtorrent_home_path(profile)):
clean = _remote_clean_path(item or "")
if clean and clean.startswith("/") and clean != "/" and clean not in roots:
roots.append(clean)
return roots
def _safe_browse_base(profile: dict, requested_path: str | None) -> tuple[str, list[str], bool]:
roots = _path_browse_roots(profile)
if not roots:
raise RuntimeError("Cannot determine a safe rTorrent browse root")
fallback = roots[0]
requested = _remote_clean_path(requested_path or fallback)
allowed = bool(requested and any(_remote_path_contains(root, requested) for root in roots))
return (requested if allowed else fallback), roots, not allowed
def browse_path(profile: dict, path: str | None = None) -> dict:
"""List allowed rTorrent directories through execute.capture without exposing the full filesystem."""
c = client_for(profile)
base, roots, used_fallback = _safe_browse_base(profile, path)
script = ( script = (
'base=$1; ' 'base=$1; '
'[ -d "$base" ] || exit 2; ' '[ -d "$base" ] || exit 2; '
@@ -17,6 +52,7 @@ def browse_path(profile: dict, path: str | None = None) -> dict:
'dir_count=0; file_count=0; ' 'dir_count=0; file_count=0; '
'for p in "$base"/* "$base"/.[!.]* "$base"/..?*; do ' 'for p in "$base"/* "$base"/.[!.]* "$base"/..?*; do '
'[ -e "$p" ] || continue; ' '[ -e "$p" ] || continue; '
'[ -L "$p" ] && continue; '
'if [ -d "$p" ]; then ' 'if [ -d "$p" ]; then '
'dir_count=$((dir_count+1)); name=${p##*/}; empty=1; ' 'dir_count=$((dir_count+1)); name=${p##*/}; empty=1; '
'if find "$p" -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null | grep -q .; then empty=0; fi; ' 'if find "$p" -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null | grep -q .; then empty=0; fi; '
@@ -61,11 +97,14 @@ def browse_path(profile: dict, path: str | None = None) -> dict:
disk_total = disk_used = disk_free = disk_percent = 0 disk_total = disk_used = disk_free = disk_percent = 0
dirs.sort(key=lambda x: x["name"].lower()) dirs.sort(key=lambda x: x["name"].lower())
parent = posixpath.dirname(base.rstrip("/")) or "/" parent = posixpath.dirname(base.rstrip("/")) or "/"
if parent == base: if parent == base or not any(_remote_path_contains(root, parent) for root in roots):
parent = base parent = base
return { return {
"path": base, "path": base,
"parent": parent, "parent": parent,
"root": roots[0] if roots else base,
"allowed_roots": roots,
"fallback": used_fallback,
"dirs": dirs[:300], "dirs": dirs[:300],
"source": "rtorrent", "source": "rtorrent",
"dir_count": dir_count, "dir_count": dir_count,
@@ -80,7 +119,6 @@ def browse_path(profile: dict, path: str | None = None) -> dict:
} }
def _safe_directory_name(name: str) -> str: def _safe_directory_name(name: str) -> str:
value = str(name or "").strip() value = str(name or "").strip()
if not value or value in {".", ".."} or "/" in value or "\x00" in value: if not value or value in {".", ".."} or "/" in value or "\x00" in value:
+16 -15
View File
@@ -1073,6 +1073,21 @@ body.resizing-details {
overflow: auto; overflow: auto;
} }
.path-info-strip {
align-items: center;
background: var(--bs-tertiary-bg);
border-bottom: 1px solid var(--bs-border-color);
display: flex;
flex-wrap: wrap;
gap: 0.5rem 0.85rem;
padding: 0.65rem 0.75rem;
}
.path-info-strip span {
color: var(--bs-secondary-color);
font-size: 0.82rem;
}
.path-row { .path-row {
align-items: center; align-items: center;
border-bottom: 1px solid var(--bs-border-color); border-bottom: 1px solid var(--bs-border-color);
@@ -4117,7 +4132,7 @@ body,
} }
} }
/* API tokens and path picker improvements */ /* API token improvements */
.api-token-row { .api-token-row {
align-items: center; align-items: center;
border: 1px solid var(--bs-border-color); border: 1px solid var(--bs-border-color);
@@ -4138,20 +4153,6 @@ body,
margin-top: 0.15rem; margin-top: 0.15rem;
} }
.path-info-strip {
align-items: center;
background: var(--bs-tertiary-bg);
border-bottom: 1px solid var(--bs-border-color);
display: flex;
flex-wrap: wrap;
gap: 0.5rem 0.85rem;
padding: 0.65rem 0.75rem;
}
.path-info-strip span {
color: var(--bs-secondary-color);
font-size: 0.82rem;
}
@media (max-width: 576px) { @media (max-width: 576px) {
.api-token-row { .api-token-row {