security: add path validation for user-configured sound files
Some checks failed
CI / Test Linux app (push) Has been cancelled

Adds validate_sound_path() that checks file type, allowed directories, and known audio extensions. Updates play() to use validation before passing paths to audio players.
This commit is contained in:
Hermes Agent 2026-06-15 07:38:05 +00:00
parent 8f056e8cf0
commit f678d7d20f

94
linux/blitztext/sound.py Normal file → Executable file
View File

@ -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