Moderate severityOSV Advisory· Published Jan 19, 2026· Updated Jan 20, 2026
Directory Traversal & Filesystem can be accessed by a non-admin user
CVE-2026-23877
Description
Swing Music is a self-hosted music player for local audio files. Prior to version 2.1.4, Swing Music's list_folders() function in the /folder/dir-browser endpoint is vulnerable to directory traversal attacks. Any authenticated user (including non-admin) can browse arbitrary directories on the server filesystem. Version 2.1.4 fixes the issue.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
swingmusicPyPI | < 2.1.4 | 2.1.4 |
Affected products
1- Range: linux-beta, v1, v1.1.0, …
Patches
19a915ca62af1fix: directory traversal as reported by @d-virtuosa
1 file changed · +80 −29
src/swingmusic/api/folder.py+80 −29 modified@@ -1,30 +1,53 @@ """ Contains all the folder routes. """ -import pathlib -from datetime import datetime + import os +import pathlib from pathlib import Path +from datetime import datetime import psutil -from pydantic import BaseModel, Field from flask_openapi3 import Tag +from pydantic import BaseModel, Field from flask_openapi3 import APIBlueprint from showinfm import show_in_file_manager from swingmusic import settings from swingmusic.config import UserConfig from swingmusic.db.libdata import TrackTable +from swingmusic.api.auth import admin_required +from swingmusic.store.tracks import TrackStore +from swingmusic.utils.wintools import is_windows from swingmusic.db.userdata import FavoritesTable, PlaylistTable from swingmusic.lib.folderslib import get_files_and_dirs, get_folders from swingmusic.serializers.track import serialize_track, serialize_tracks -from swingmusic.store.tracks import TrackStore -from swingmusic.utils.wintools import is_windows tag = Tag(name="Folders", description="Get folders and tracks in a directory") api = APIBlueprint("folder", __name__, url_prefix="/folder", abp_tags=[tag]) +def is_path_within_root_dirs(filepath: str) -> bool: + """ + Check if a filepath is within one of the configured root directories. + Prevents directory traversal attacks. + """ + config = UserConfig() + resolved_path = Path(filepath).resolve() + + for root_dir in config.rootDirs: + if root_dir == "$home": + root_path = Path.home().resolve() + else: + root_path = Path(root_dir).resolve() + + # Check if resolved_path is the root or a child of root + if resolved_path == root_path or root_path in resolved_path.parents: + return True + + return False + + class FolderTree(BaseModel): folder: str = Field("$home", description="The folder to things from") sorttracksby: str = Field( @@ -87,12 +110,12 @@ def get_folder_tree(body: FolderTree): req_dir = settings.Paths().USER_HOME_DIR.as_posix() if req_dir == "$home": - folders = get_folders(root_dirs) + folders = get_folders(root_dirs) - return { - "folders": folders, - "tracks": [], - } + return { + "folders": folders, + "tracks": [], + } if req_dir.startswith("$playlist"): splits = req_dir.split("/") @@ -142,14 +165,26 @@ def get_folder_tree(body: FolderTree): "path": req_dir, } - # TODO: currently only fixed on unix. Windows/Mac still pending. - # note + # Resolve path to prevent directory traversal attacks + resolved_path = pathlib.Path(req_dir).resolve() + + # Validate path is within configured root directories + if not is_path_within_root_dirs(str(resolved_path)): + return { + "folders": [], + "tracks": [], + "error": "Path not within allowed directories", + }, 403 - if not pathlib.Path(req_dir).exists(): - req_dir = "/" + req_dir + if not resolved_path.exists() or not resolved_path.is_dir(): + return { + "folders": [], + "tracks": [], + "error": "Invalid directory", + }, 400 results = get_files_and_dirs( - pathlib.Path(req_dir), + resolved_path, start=body.start, limit=body.limit, tracks_only=tracks_only, @@ -213,12 +248,13 @@ class DirBrowserBody(BaseModel): @api.post("/dir-browser") +@admin_required() def list_folders(body: DirBrowserBody): """ List folders Returns a list of all the folders in the given folder. - Used when selecting root dirs. + Used when selecting root dirs. Admin only. """ req_dir = body.folder is_win = is_windows() @@ -228,11 +264,11 @@ def list_folders(body: DirBrowserBody): "folders": [{"name": d, "path": d} for d in get_all_drives(is_win=is_win)] } + # Resolve path to prevent directory traversal attacks + req_dir = pathlib.Path(req_dir).resolve() - req_dir = pathlib.Path(req_dir) - - if not req_dir.exists(): - req_dir = "/" / req_dir + if not req_dir.exists() or not req_dir.is_dir(): + return {"folders": [], "error": "Invalid directory"}, 400 try: entries = os.scandir(req_dir) @@ -245,17 +281,14 @@ def list_folders(body: DirBrowserBody): entry = pathlib.Path(entry) name = entry.name - if name.startswith("$"): # ignore windows system folder + if name.startswith("$"): continue - if name.startswith("."): # ignore unix hidden folder + if name.startswith("."): continue - if entry.is_dir(): # lastly, check if is dir - dirs.append({ - "name": name, - "path": entry.as_posix() - }) + if entry.is_dir(): + dirs.append({"name": name, "path": entry.resolve().as_posix()}) return { "folders": sorted(dirs, key=lambda i: i["name"]), @@ -274,8 +307,19 @@ def open_in_file_manager(query: FolderOpenInFileManagerQuery): Open in file manager Opens the given path in the file manager on the host machine. + Path must be within configured root directories. """ - show_in_file_manager(query.path) + # Resolve path to prevent directory traversal + resolved_path = Path(query.path).resolve() + + # Validate path is within root directories + if not is_path_within_root_dirs(query.path): + return {"success": False, "error": "Path not within allowed directories"}, 403 + + if not resolved_path.exists(): + return {"success": False, "error": "Path does not exist"}, 404 + + show_in_file_manager(str(resolved_path)) return {"success": True} @@ -295,7 +339,14 @@ def get_tracks_in_path(query: GetTracksInPathQuery): Used when adding tracks to the queue. """ - tracks = TrackTable.get_tracks_in_path(query.path) + # Resolve path to prevent directory traversal + resolved_path = Path(query.path).resolve() + + # Validate path is within root directories + if not is_path_within_root_dirs(str(resolved_path)): + return {"tracks": [], "error": "Path not within allowed directories"}, 403 + + tracks = TrackTable.get_tracks_in_path(str(resolved_path)) tracks = (serialize_track(t) for t in tracks if Path(t.filepath).exists()) return {
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4- github.com/advisories/GHSA-pj88-9xww-gxmhghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-23877ghsaADVISORY
- github.com/swingmx/swingmusic/commit/9a915ca62af1502b9550722df82f5d432cb73de3ghsax_refsource_MISCWEB
- github.com/swingmx/swingmusic/security/advisories/GHSA-pj88-9xww-gxmhghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.