diff --git a/linux/blitztext/sound.py b/linux/blitztext/sound.py old mode 100644 new mode 100755 index 7f2f1c1..16d5f10 --- a/linux/blitztext/sound.py +++ b/linux/blitztext/sound.py @@ -1,10 +1,16 @@ -"""Play short audio cues (a user WAV, or a built-in system sound) without blocking.""" +"""Play short audio cues (a user WAV, or a built-in system sound) without blocking. + +Path safety: all user-configured sound file paths are validated before use +to prevent path-traversal, device-file access, and symlink attacks. +""" from __future__ import annotations import os import shutil +import stat import subprocess +from pathlib import Path _FREEDESKTOP = "/usr/share/sounds/freedesktop/stereo/{name}.oga" @@ -19,17 +25,95 @@ _PLAYERS: list[tuple[str, list[str], bool]] = [ ] _NATIVE_EXTS = {".wav", ".oga", ".ogg", ".flac"} +# Allowed audio file extensions for user-configured sound paths. +_ALLOWED_AUDIO_EXTS = {".wav", ".mp3", ".ogg", ".oga", ".flac", + ".m4a", ".aac", ".aif", ".aiff", ".opus"} + +# Directories that user-configured sound paths are allowed to resolve into. +_ALLOWED_PARENTS: tuple[str, ...] = ( + str(Path.home()), + "/usr/share/sounds", + "/opt/blitztext", +) + + +def validate_sound_path(path: str) -> str | None: + """Validate a user-configured sound file path. + + Returns the resolved absolute path if the file is safe to play, + or ``None`` if the path is empty, does not exist, or fails any + security check. + + Checks performed: + 1. Empty / whitespace-only paths are rejected. + 2. ``~user`` is expanded via ``expanduser``. + 3. The path is resolved to an absolute canonical path (``realpath``), + which eliminates symlinks and ``..`` components. + 4. The resolved path must be a regular file (not a device, FIFO, + directory, or socket). + 5. The resolved path must reside under one of the allowed parent + directories (user home, system sounds, or the Blitztext install + prefix). + 6. The file extension must be a known audio format. + """ + if not path or not path.strip(): + return None + + expanded = os.path.expanduser(path.strip()) + if not expanded: + return None + + try: + resolved = Path(expanded).resolve(strict=False) + except (OSError, RuntimeError, ValueError): + return None + + # Must exist and be a regular file. + if not resolved.exists(): + return None + try: + mode = resolved.stat().st_mode + except OSError: + return None + if not stat.S_ISREG(mode): + return None + + # Must be under an allowed parent directory. + resolved_str = str(resolved) + allowed = False + for parent in _ALLOWED_PARENTS: + try: + common = os.path.commonpath([resolved_str, parent]) + if common == parent: + allowed = True + break + except ValueError: + continue + if not allowed: + return None + + # Extension must be a known audio format. + ext = resolved.suffix.lower() + if ext not in _ALLOWED_AUDIO_EXTS: + return None + + return resolved_str + def play(path: str = "", *, fallback: str | None = None) -> "subprocess.Popen | None": """Play `path` (WAV/MP3/OGG/FLAC/…); fallback to a freedesktop system sound. + User-supplied paths are validated via :func:`validate_sound_path` before + being passed to any audio player. Invalid or unsafe paths are silently + ignored (the fallback sound is still attempted). + Returns the Popen object so callers can terminate a preview, or None. """ target = "" if path: - expanded = os.path.expanduser(path) - if os.path.exists(expanded): - target = expanded + safe = validate_sound_path(path) + if safe is not None: + target = safe if not target and fallback: fd = _FREEDESKTOP.format(name=fallback) if os.path.exists(fd): @@ -53,4 +137,4 @@ def play(path: str = "", *, fallback: str | None = None) -> "subprocess.Popen | return proc except OSError: continue - return None + return None \ No newline at end of file