Maintenance for security related fixes (#2983)
authorMarvin Schenkel <marvinschenkel@gmail.com>
Sat, 17 Jan 2026 16:04:21 +0000 (17:04 +0100)
committerGitHub <noreply@github.com>
Sat, 17 Jan 2026 16:04:21 +0000 (17:04 +0100)
.github/workflows/release.yml
.github/workflows/test.yml
music_assistant/controllers/media/playlists.py
music_assistant/controllers/metadata.py
music_assistant/helpers/audio.py
music_assistant/helpers/images.py
music_assistant/helpers/security.py [new file with mode: 0644]

index 530adbc1dc2ba29a91520ef7bdfa579c2fc2bdc3..31f69dd069377cf9ce14b387e0e62058bb13fdc7 100644 (file)
@@ -44,6 +44,9 @@ env:
   BASE_IMAGE_VERSION_BETA: "1.4.11"
   BASE_IMAGE_VERSION_NIGHTLY: "1.4.11"
 
+permissions:
+  contents: read
+
 jobs:
   determine-branch:
     name: Determine release branch
index 0a074fd1b664309c47262f16bd5502eb8045eda3..11c8fc0cca9d7fb4fb45bed4c32a422f157bdcc4 100644 (file)
@@ -20,6 +20,9 @@ on:
         required: false
         type: string
 
+permissions:
+  contents: read
+
 jobs:
   lint:
     runs-on: ubuntu-latest
index 528fe032029a545536e6a6412700906d571165ea..ce34c0b16380bbf2352d85c61c72644d3f447c65 100644 (file)
@@ -18,6 +18,7 @@ from music_assistant.constants import DB_TABLE_PLAYLISTS
 from music_assistant.helpers.compare import create_safe_string
 from music_assistant.helpers.database import UNSET
 from music_assistant.helpers.json import serialize_to_json
+from music_assistant.helpers.security import is_safe_name
 from music_assistant.helpers.uri import create_uri, parse_uri
 from music_assistant.helpers.util import guard_single_request
 from music_assistant.models.music_provider import MusicProvider
@@ -116,7 +117,7 @@ class PlaylistController(MediaControllerBase[Playlist]):
         # grab all existing track ids in the playlist so we can check for duplicates
         provider = cast("MusicProvider", provider)
 
-        if "/" in name or "\\" in name or ".." in name:
+        if not is_safe_name(name):
             msg = f"{name} is not a valid Playlist name"
             raise InvalidDataError(msg)
         # create playlist on the provider
index 89c863bab18c6dd2ad8d8456b4624d5ccbd3a9ec..e50b4db84403ad9ef4b058ea33728f509629ab3f 100644 (file)
@@ -52,6 +52,7 @@ from music_assistant.constants import (
 from music_assistant.helpers.api import api_command
 from music_assistant.helpers.compare import compare_strings
 from music_assistant.helpers.images import create_collage, get_image_thumb
+from music_assistant.helpers.security import is_safe_path
 from music_assistant.helpers.throttle_retry import Throttler
 from music_assistant.models.core_controller import CoreController
 from music_assistant.models.music_provider import MusicProvider
@@ -428,7 +429,10 @@ class MetaDataController(CoreController):
             image_format = "png" if path.lower().endswith(".png") else "jpg"
         if provider == "builtin" and path.startswith("/collage/"):
             # special case for collage images
-            path = os.path.join(self._collage_images_dir, path.split("/collage/")[-1])
+            collage_rel = path.split("/collage/")[-1]
+            if not is_safe_path(collage_rel):
+                raise FileNotFoundError("Invalid collage path")
+            path = os.path.join(self._collage_images_dir, collage_rel)
         thumbnail_bytes = await get_image_thumb(
             self.mass, path, size=size, provider=provider, image_format=image_format
         )
index d9824ac018eb339ec94de14373c8e1d0a0efce80..d8879481e9f9de172df06fd3866f2ace5f721974 100644 (file)
@@ -1202,6 +1202,12 @@ async def get_preview_stream(
         raise ProviderUnavailableError
     if TYPE_CHECKING:  # avoid circular import
         assert isinstance(music_prov, MusicProvider)
+
+    # Validate that item_id corresponds to a valid item in the provider for security
+    if not await music_prov.get_item(media_type, item_id):
+        msg = f"Item {item_id} not found in provider {provider_instance_id_or_domain}"
+        raise MediaNotFoundError(msg)
+
     streamdetails = await music_prov.get_stream_details(item_id, media_type)
     pcm_format = AudioFormat(
         content_type=ContentType.from_bit_depth(streamdetails.audio_format.bit_depth),
index 97639ffa44e28c6279ae7af0d15367ad5e69a83a..dca001a5c265d0c3130b7e32fb70f48cdec7446d 100644 (file)
@@ -15,6 +15,7 @@ import aiofiles
 from aiohttp.client_exceptions import ClientError
 from PIL import Image, UnidentifiedImageError
 
+from music_assistant.helpers.security import is_safe_path
 from music_assistant.helpers.tags import get_embedded_image
 from music_assistant.models.metadata_provider import MetadataProvider
 from music_assistant.models.music_provider import MusicProvider
@@ -48,12 +49,12 @@ async def get_image_data(mass: MusicAssistant, path_or_url: str, provider: str)
     if path_or_url.startswith("data:image"):
         return b64decode(path_or_url.split(",")[-1])
     # handle FILE location (of type image)
-    if path_or_url.endswith(("jpg", "JPG", "png", "PNG", "jpeg")):
+    if path_or_url.endswith(("jpg", "JPG", "png", "PNG", "jpeg")) and is_safe_path(path_or_url):
         if await asyncio.to_thread(os.path.isfile, path_or_url):
             async with aiofiles.open(path_or_url, "rb") as _file:
                 return cast("bytes", await _file.read())
     # use ffmpeg for embedded images
-    if img_data := await get_embedded_image(path_or_url):
+    if is_safe_path(path_or_url) and (img_data := await get_embedded_image(path_or_url)):
         return img_data
     msg = f"Image not found: {path_or_url}"
     raise FileNotFoundError(msg)
diff --git a/music_assistant/helpers/security.py b/music_assistant/helpers/security.py
new file mode 100644 (file)
index 0000000..cbfb0ff
--- /dev/null
@@ -0,0 +1,16 @@
+"""Security utilities for input validation."""
+
+from __future__ import annotations
+
+import os
+
+
+def is_safe_path(path: str) -> bool:
+    """Check if path is free from path traversal components."""
+    norm_path = os.path.normpath(path)
+    return not (norm_path.startswith("..") or "/../" in norm_path or "\\..\\" in norm_path)
+
+
+def is_safe_name(name: str) -> bool:
+    """Check if name is safe for use (no path separators or traversal components)."""
+    return not ("/" in name or "\\" in name or ".." in name)