security: add path validation for user-configured sound files
Some checks failed
CI / Test Linux app (push) Has been cancelled
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:
parent
8f056e8cf0
commit
f678d7d20f
94
linux/blitztext/sound.py
Normal file → Executable file
94
linux/blitztext/sound.py
Normal file → Executable 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
|
||||
Loading…
Reference in New Issue
Block a user