diff --git a/src/chirp/audio_feedback.py b/src/chirp/audio_feedback.py index 45ca0a4..44dd6b8 100644 --- a/src/chirp/audio_feedback.py +++ b/src/chirp/audio_feedback.py @@ -25,6 +25,9 @@ winsound = None # type: ignore[assignment] +MAX_AUDIO_FILE_SIZE_BYTES = 5 * 1024 * 1024 # 5MB + + class AudioFeedback: def __init__( self, @@ -130,6 +133,20 @@ def _play_sound(self, asset_name: str, override_path: Optional[str]) -> None: self._logger.exception("Failed to play sound %s: %s", asset_name, exc) def _load_and_cache(self, path: Path, key: str) -> Any: + try: + file_size = path.stat().st_size + if file_size > MAX_AUDIO_FILE_SIZE_BYTES: + self._logger.warning( + "Audio file %s is too large (%d bytes). Max allowed: %d bytes.", + path, + file_size, + MAX_AUDIO_FILE_SIZE_BYTES, + ) + return None + except OSError as exc: + self._logger.warning("Could not stat audio file %s: %s", path, exc) + return None + if self._use_sounddevice: # Load as numpy array for volume-controlled playback via sounddevice with wave.open(str(path), "rb") as wf: diff --git a/tests/test_audio_feedback.py b/tests/test_audio_feedback.py index c656333..9d0fb15 100644 --- a/tests/test_audio_feedback.py +++ b/tests/test_audio_feedback.py @@ -43,13 +43,17 @@ def test_play_with_sounddevice_called(self, mock_sd): af._load_and_cache.assert_called_once() af._play_cached.assert_called_once_with("data") + @patch("pathlib.Path.stat") @patch("chirp.audio_feedback.sd") @patch("chirp.audio_feedback.np") @patch("chirp.audio_feedback.wave") - def test_load_and_cache_sounddevice(self, mock_wave, mock_np, mock_sd): + def test_load_and_cache_sounddevice(self, mock_wave, mock_np, mock_sd, mock_stat): """_load_and_cache should read WAV and cache data.""" af = AudioFeedback(logger=self.mock_logger, enabled=True) + # Mock valid file size + mock_stat.return_value.st_size = 1024 + # Setup wave mock mock_wf = MagicMock() mock_wave.open.return_value.__enter__.return_value = mock_wf @@ -81,14 +85,18 @@ def test_play_cached_sounddevice(self, mock_sd): af._play_cached(mock_data) mock_sd.play.assert_called_with(mock_data[0], 44100) + @patch("pathlib.Path.stat") @patch("chirp.audio_feedback.sd") @patch("chirp.audio_feedback.np") @patch("chirp.audio_feedback.wave") @patch("chirp.audio_feedback.winsound", None) - def test_load_and_cache_with_volume_scaling(self, mock_wave, mock_np, mock_sd): + def test_load_and_cache_with_volume_scaling(self, mock_wave, mock_np, mock_sd, mock_stat): """_load_and_cache should scale audio when volume < 1.0.""" import numpy as np + # Mock valid file size + mock_stat.return_value.st_size = 1024 + # Create real numpy array for testing mock_np.int16 = np.int16 mock_np.float32 = np.float32 diff --git a/tests/test_audio_security.py b/tests/test_audio_security.py new file mode 100644 index 0000000..59cd560 --- /dev/null +++ b/tests/test_audio_security.py @@ -0,0 +1,52 @@ +import logging +import unittest +from pathlib import Path +from unittest.mock import MagicMock, patch + +# Need to patch sys.modules before importing chirp.audio_feedback to mock imports if needed, +# but since the module handles ImportError, we can just patch the imported names. + +from chirp.audio_feedback import AudioFeedback + +class TestAudioFeedbackSecurity(unittest.TestCase): + def setUp(self): + self.mock_logger = MagicMock(spec=logging.Logger) + + @patch("chirp.audio_feedback.np") + @patch("chirp.audio_feedback.sd", new=MagicMock()) + @patch("chirp.audio_feedback.winsound", None) + @patch("pathlib.Path.stat") + def test_load_large_file_fails(self, mock_stat, mock_np): + """_load_and_cache should return None if file is too large (>5MB).""" + af = AudioFeedback(logger=self.mock_logger, enabled=True) + + # Mock file size to be slightly larger than 5MB (5 * 1024 * 1024 + 1) + mock_stat.return_value.st_size = 5 * 1024 * 1024 + 1 + + # We need to mock wave.open so it doesn't actually try to open a file + # if the check fails (or if it doesn't fail yet) + with patch("chirp.audio_feedback.wave") as mock_wave: + # Setup mock to behave "normally" if called + mock_wf = MagicMock() + mock_wave.open.return_value.__enter__.return_value = mock_wf + mock_wf.getframerate.return_value = 44100 + mock_wf.getnchannels.return_value = 1 + mock_wf.readframes.return_value = b"\x00" * 100 + mock_wf.getnframes.return_value = 50 + + # Mock numpy behavior to avoid errors if the code proceeds + mock_np.frombuffer.return_value = MagicMock() + + result = af._load_and_cache(Path("/fake/large_file.wav"), "key") + + # This assertion should fail until the fix is implemented + self.assertIsNone(result, "Should return None for files larger than 5MB") + + # Verify a warning was logged + self.mock_logger.warning.assert_called() + # Check if the warning message contains expected text + args, _ = self.mock_logger.warning.call_args + self.assertIn("too large", args[0]) + +if __name__ == "__main__": + unittest.main()