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
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
|
import stat
|
||||||
import subprocess
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
_FREEDESKTOP = "/usr/share/sounds/freedesktop/stereo/{name}.oga"
|
_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"}
|
_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":
|
def play(path: str = "", *, fallback: str | None = None) -> "subprocess.Popen | None":
|
||||||
"""Play `path` (WAV/MP3/OGG/FLAC/…); fallback to a freedesktop system sound.
|
"""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.
|
Returns the Popen object so callers can terminate a preview, or None.
|
||||||
"""
|
"""
|
||||||
target = ""
|
target = ""
|
||||||
if path:
|
if path:
|
||||||
expanded = os.path.expanduser(path)
|
safe = validate_sound_path(path)
|
||||||
if os.path.exists(expanded):
|
if safe is not None:
|
||||||
target = expanded
|
target = safe
|
||||||
if not target and fallback:
|
if not target and fallback:
|
||||||
fd = _FREEDESKTOP.format(name=fallback)
|
fd = _FREEDESKTOP.format(name=fallback)
|
||||||
if os.path.exists(fd):
|
if os.path.exists(fd):
|
||||||
@ -53,4 +137,4 @@ def play(path: str = "", *, fallback: str | None = None) -> "subprocess.Popen |
|
|||||||
return proc
|
return proc
|
||||||
except OSError:
|
except OSError:
|
||||||
continue
|
continue
|
||||||
return None
|
return None
|
||||||
Loading…
Reference in New Issue
Block a user