From e9a69c6f4d3f0b105eb3346391c1160770927243 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:30:23 +0000 Subject: [PATCH 1/5] Fix SSRF/LFI vulnerability in FFmpeg/FFprobe by adding protocol whitelist --- .jules/sentinel.md | 4 ++++ audio_library.py | 6 +++++- tests/test_audio_library.py | 6 ++++-- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/.jules/sentinel.md b/.jules/sentinel.md index 9c9d083b..73f0ddaa 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -65,3 +65,7 @@ **Vulnerability:** Path traversal in `media_shrinker.py` via unresolved `..` segments or symlink escapes before deriving conversion output paths. **Learning:** `Path.relative_to()` is only a lexical containment check unless both the source and root have first been resolved into canonical absolute paths. Relative paths and symlinks can otherwise bypass root-boundary assumptions. **Prevention:** Resolve both source and root once, reject sources outside the resolved root with a sanitized `MediaShrinkerError`, and derive `rel_source` from the resolved paths before planning outputs. +## 2026-06-07 - FFmpeg SSRF/LFI Vulnerability Fix +**Vulnerability:** Local File Inclusion and Server-Side Request Forgery via unrestricted FFmpeg/FFprobe protocols. +**Learning:** The application executed FFmpeg and FFprobe on user-supplied media files without protocol restrictions. Malicious files (like HLS playlists) could leverage protocols like `http` to exfiltrate data or access internal services. +**Prevention:** Always enforce `"-protocol_whitelist", "file,crypto,data,fd,pipe"` before the input flag when invoking FFmpeg/FFprobe to restrict processing to safe local protocols. diff --git a/audio_library.py b/audio_library.py index f3c788a7..f3d8363c 100644 --- a/audio_library.py +++ b/audio_library.py @@ -2072,6 +2072,8 @@ def audio_duration_seconds( "format=duration", "-of", "default=noprint_wrappers=1:nokey=1", + "-protocol_whitelist", + "file,crypto,data,fd,pipe", media_input, ] completed = subprocess.run( @@ -2861,7 +2863,7 @@ def decode_audio_for_mlx( # Input-side seeking avoids decoding every earlier chunk; ffmpeg's # default accurate_seek still discards samples before this boundary. command.extend(("-ss", f"{start_seconds:.6f}")) - command.extend(("-i", media_input)) + command.extend(("-protocol_whitelist", "file,crypto,data,fd,pipe", "-i", media_input)) if duration_seconds is not None: command.extend(("-t", f"{duration_seconds:.6f}")) command.extend( @@ -2941,6 +2943,8 @@ def detect_silence_intervals( command = [ str(ffmpeg), "-nostdin", + "-protocol_whitelist", + "file,crypto,data,fd,pipe", "-i", media_input, "-af", diff --git a/tests/test_audio_library.py b/tests/test_audio_library.py index ae798bbe..09586378 100644 --- a/tests/test_audio_library.py +++ b/tests/test_audio_library.py @@ -4529,12 +4529,14 @@ def __truediv__(self, _value): ) command = run.call_args.args[0] self.assertEqual( - command[:8], + command[:10], [ "/usr/bin/ffmpeg", "-nostdin", "-ss", "299.000000", + "-protocol_whitelist", + "file,crypto,data,fd,pipe", "-i", "recording.wav", "-t", @@ -4571,7 +4573,7 @@ def __truediv__(self, _value): ): self.assertEqual(audio_library.decode_audio_for_mlx(artifact), "decoded") descriptor = handle.fileno() - self.assertEqual(run.call_args.args[0][3], f"/dev/fd/{descriptor}") + self.assertEqual(run.call_args.args[0][5], f"/dev/fd/{descriptor}") self.assertEqual(run.call_args.kwargs["pass_fds"], (descriptor,)) self.assertNotIn("stdin", run.call_args.kwargs) handle.close() From 6fddce8ab1696e02d9bb6ffa326e4af29074f9d8 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Fri, 11 Sep 2026 18:00:39 +0000 Subject: [PATCH 2/5] Fix SSRF vulnerability in FFmpeg/FFprobe --- .jules/sentinel.md | 12 ++++-------- audio_library.py | 6 +++--- pyproject.toml | 2 +- requirements-lock.txt | 5 +++-- requirements.txt | 2 +- tests/test_audio_library.py | 2 +- 6 files changed, 13 insertions(+), 16 deletions(-) diff --git a/.jules/sentinel.md b/.jules/sentinel.md index 73f0ddaa..a39a4f48 100644 --- a/.jules/sentinel.md +++ b/.jules/sentinel.md @@ -17,10 +17,10 @@ **Learning:** In FastAPI/Starlette, `file.filename` can be unsafe or empty. Using `Path(file.filename).name` may resolve to `.` or `..`, leading to OS-level exceptions when attempting to write data. If resource allocation (like `tempfile.mkdtemp()`) occurs outside the scope of the `try...finally` (or `BackgroundTasks` cleanup) that handles these errors, an attacker can intentionally leak resources by sending manipulated paths. **Prevention:** Always place resource allocation inside or immediately before the associated `try...finally` block. Sanitize and validate filenames retrieved from `UploadFile.filename` by ensuring they are non-empty and are not relative references (`.` or `..`), providing a safe default fallback. -## 2026-06-07 - FFmpeg SSRF/LFI Vulnerability Fix -**Vulnerability:** Local File Inclusion and Server-Side Request Forgery via unrestricted FFmpeg/FFprobe protocols. -**Learning:** The application executed FFmpeg and FFprobe on user-supplied media files without protocol restrictions. Malicious files (like HLS playlists) could leverage protocols like `http` to exfiltrate data or access internal services. -**Prevention:** Always enforce `"-protocol_whitelist", "file,crypto,data"` before the input flag when invoking FFmpeg/FFprobe to restrict processing to safe local protocols. +## 2026-06-07 - FFmpeg SSRF Vulnerability Fix +**Vulnerability:** Server-Side Request Forgery via unrestricted FFmpeg/FFprobe protocols. +**Learning:** The application executed FFmpeg and FFprobe on user-supplied media files without protocol restrictions. Malicious files (like HLS playlists) could leverage protocols like `http` to exfiltrate data or access internal network services. +**Prevention:** Always enforce `"-protocol_whitelist", "file,crypto,data"` before the input flag when invoking FFmpeg/FFprobe to restrict processing to safe local protocols and prevent network SSRF. Note: This does not prevent Local File Inclusion (LFI) via the `file` protocol. ## 2026-06-09 - [Sentinel: FFmpeg Argument Injection Vulnerability Fix] **Vulnerability:** Argument injection via maliciously crafted filenames. @@ -65,7 +65,3 @@ **Vulnerability:** Path traversal in `media_shrinker.py` via unresolved `..` segments or symlink escapes before deriving conversion output paths. **Learning:** `Path.relative_to()` is only a lexical containment check unless both the source and root have first been resolved into canonical absolute paths. Relative paths and symlinks can otherwise bypass root-boundary assumptions. **Prevention:** Resolve both source and root once, reject sources outside the resolved root with a sanitized `MediaShrinkerError`, and derive `rel_source` from the resolved paths before planning outputs. -## 2026-06-07 - FFmpeg SSRF/LFI Vulnerability Fix -**Vulnerability:** Local File Inclusion and Server-Side Request Forgery via unrestricted FFmpeg/FFprobe protocols. -**Learning:** The application executed FFmpeg and FFprobe on user-supplied media files without protocol restrictions. Malicious files (like HLS playlists) could leverage protocols like `http` to exfiltrate data or access internal services. -**Prevention:** Always enforce `"-protocol_whitelist", "file,crypto,data,fd,pipe"` before the input flag when invoking FFmpeg/FFprobe to restrict processing to safe local protocols. diff --git a/audio_library.py b/audio_library.py index f3d8363c..e7351e1c 100644 --- a/audio_library.py +++ b/audio_library.py @@ -2073,7 +2073,7 @@ def audio_duration_seconds( "-of", "default=noprint_wrappers=1:nokey=1", "-protocol_whitelist", - "file,crypto,data,fd,pipe", + "file,crypto,data", media_input, ] completed = subprocess.run( @@ -2863,7 +2863,7 @@ def decode_audio_for_mlx( # Input-side seeking avoids decoding every earlier chunk; ffmpeg's # default accurate_seek still discards samples before this boundary. command.extend(("-ss", f"{start_seconds:.6f}")) - command.extend(("-protocol_whitelist", "file,crypto,data,fd,pipe", "-i", media_input)) + command.extend(("-protocol_whitelist", "file,crypto,data", "-i", media_input)) if duration_seconds is not None: command.extend(("-t", f"{duration_seconds:.6f}")) command.extend( @@ -2944,7 +2944,7 @@ def detect_silence_intervals( str(ffmpeg), "-nostdin", "-protocol_whitelist", - "file,crypto,data,fd,pipe", + "file,crypto,data", "-i", media_input, "-af", diff --git a/pyproject.toml b/pyproject.toml index 91884dfe..0436c5e3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,7 +51,7 @@ dev = [ "python-multipart==0.0.32", "aiofiles==25.1.0", "httpx==0.28.1", - "httpx2==2.5.0", + "httpx2==2.6.0", "mcp==1.28.1", ] all = [ diff --git a/requirements-lock.txt b/requirements-lock.txt index ac301f7c..10b8d3ae 100644 --- a/requirements-lock.txt +++ b/requirements-lock.txt @@ -230,8 +230,9 @@ httpx-sse==0.4.3 \ --hash=sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc \ --hash=sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d # via mcp -httpx2==2.5.0 \ - --hash=sha256:3d2d4d9cf4b61f1a1f46a95947cfdb47e80cb56a2f91c6256ac8f58e4891df41 +httpx2==2.6.0 \ + --hash=sha256:5d362fd59562cf2139a60c67bb016587a70b36156a517f176c7cbf1587d1ab22 \ + --hash=sha256:6cccc3665d6bceb3c1c4f1422ae7e53fda67a853f0135f09b25ce0d4dcac01e3 # via -r requirements.txt idna==3.18 \ --hash=sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2 \ diff --git a/requirements.txt b/requirements.txt index b9359c3b..4b8ca9a7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,7 @@ python-multipart mcp aiofiles httpx -httpx2==2.5.0 +httpx2==2.6.0 exceptiongroup==1.3.1; python_version < "3.11" rpds-py==0.30.0 setuptools==83.0.0 diff --git a/tests/test_audio_library.py b/tests/test_audio_library.py index 09586378..33bcef33 100644 --- a/tests/test_audio_library.py +++ b/tests/test_audio_library.py @@ -4536,7 +4536,7 @@ def __truediv__(self, _value): "-ss", "299.000000", "-protocol_whitelist", - "file,crypto,data,fd,pipe", + "file,crypto,data", "-i", "recording.wav", "-t", From c6d35a65b8589c7cc908534781e49b320202885a Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 12 Sep 2026 04:18:52 +0900 Subject: [PATCH 3/5] chore: keep FFmpeg protocol repair independent of dependency owner --- pyproject.toml | 2 +- requirements-lock.txt | 5 ++--- requirements.txt | 2 +- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 0436c5e3..91884dfe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,7 +51,7 @@ dev = [ "python-multipart==0.0.32", "aiofiles==25.1.0", "httpx==0.28.1", - "httpx2==2.6.0", + "httpx2==2.5.0", "mcp==1.28.1", ] all = [ diff --git a/requirements-lock.txt b/requirements-lock.txt index 10b8d3ae..ac301f7c 100644 --- a/requirements-lock.txt +++ b/requirements-lock.txt @@ -230,9 +230,8 @@ httpx-sse==0.4.3 \ --hash=sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc \ --hash=sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d # via mcp -httpx2==2.6.0 \ - --hash=sha256:5d362fd59562cf2139a60c67bb016587a70b36156a517f176c7cbf1587d1ab22 \ - --hash=sha256:6cccc3665d6bceb3c1c4f1422ae7e53fda67a853f0135f09b25ce0d4dcac01e3 +httpx2==2.5.0 \ + --hash=sha256:3d2d4d9cf4b61f1a1f46a95947cfdb47e80cb56a2f91c6256ac8f58e4891df41 # via -r requirements.txt idna==3.18 \ --hash=sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2 \ diff --git a/requirements.txt b/requirements.txt index 4b8ca9a7..b9359c3b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,7 @@ python-multipart mcp aiofiles httpx -httpx2==2.6.0 +httpx2==2.5.0 exceptiongroup==1.3.1; python_version < "3.11" rpds-py==0.30.0 setuptools==83.0.0 From 6e371b9c9e6e3f72b9be6cb5949d77dbed24e19f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Sat, 12 Sep 2026 04:19:17 +0900 Subject: [PATCH 4/5] test: pin FFmpeg input protocol boundary --- ...test_ffmpeg_protocol_whitelist_contract.py | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 tests/test_ffmpeg_protocol_whitelist_contract.py diff --git a/tests/test_ffmpeg_protocol_whitelist_contract.py b/tests/test_ffmpeg_protocol_whitelist_contract.py new file mode 100644 index 00000000..b3eb8479 --- /dev/null +++ b/tests/test_ffmpeg_protocol_whitelist_contract.py @@ -0,0 +1,87 @@ +"""Executable contracts for FFmpeg/FFprobe input protocol admission.""" + +from __future__ import annotations + +import subprocess +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +import audio_library + + +class FfmpegProtocolWhitelistContractTests(unittest.TestCase): + """Keep untrusted media parsing on the reviewed local-protocol boundary.""" + + _ALLOWED_PROTOCOLS = {"file", "crypto", "data"} + _NETWORK_PROTOCOLS = { + "ftp", + "gopher", + "http", + "https", + "rtmp", + "rtsp", + "sftp", + "smb", + "srt", + "tcp", + "udp", + } + + def assert_local_input_protocols(self, command: list[str]) -> int: + """Return the whitelist index after checking its exact network boundary.""" + + whitelist_index = command.index("-protocol_whitelist") + protocols = set(command[whitelist_index + 1].split(",")) + self.assertEqual(protocols, self._ALLOWED_PROTOCOLS) + self.assertTrue(protocols.isdisjoint(self._NETWORK_PROTOCOLS)) + return whitelist_index + + def test_ffprobe_duration_applies_whitelist_immediately_before_input(self) -> None: + completed = subprocess.CompletedProcess( + args=[], returncode=0, stdout="12.5\n", stderr="" + ) + with tempfile.TemporaryDirectory() as root: + media_path = Path(root) / "recording.m4a" + media_path.write_bytes(b"fixture") + with ( + patch( + "audio_library.trusted_ffprobe_binary", + return_value=Path("/usr/bin/ffprobe"), + ), + patch("audio_library.subprocess.run", return_value=completed) as run, + ): + self.assertEqual(audio_library.audio_duration_seconds(media_path), 12.5) + + command = run.call_args.args[0] + whitelist_index = self.assert_local_input_protocols(command) + self.assertEqual(command[whitelist_index + 2], str(media_path)) + self.assertEqual(run.call_args.kwargs["pass_fds"], ()) + + def test_silence_detection_applies_whitelist_before_input_flag(self) -> None: + completed = subprocess.CompletedProcess( + args=[], returncode=0, stdout=b"", stderr=b"" + ) + with ( + patch( + "audio_library.trusted_ffmpeg_binary", + return_value=Path("/usr/bin/ffmpeg"), + ), + patch("audio_library.subprocess.run", return_value=completed) as run, + ): + self.assertEqual( + audio_library.detect_silence_intervals(Path("recording.m4a")), [] + ) + + command = run.call_args.args[0] + whitelist_index = self.assert_local_input_protocols(command) + self.assertEqual( + command[whitelist_index + 2 : whitelist_index + 4], + ["-i", "recording.m4a"], + ) + self.assertEqual(run.call_args.kwargs["pass_fds"], ()) + + +if __name__ == "__main__": + unittest.main() From 29696927ce9643a984ee21ca361a625f401f10e5 Mon Sep 17 00:00:00 2001 From: seonghobae <8172694+seonghobae@users.noreply.github.com> Date: Fri, 11 Sep 2026 19:33:12 +0000 Subject: [PATCH 5/5] Fix tests and PR feedback for SSRF vulnerability --- ...test_ffmpeg_protocol_whitelist_contract.py | 31 ++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/tests/test_ffmpeg_protocol_whitelist_contract.py b/tests/test_ffmpeg_protocol_whitelist_contract.py index b3eb8479..10fbf89e 100644 --- a/tests/test_ffmpeg_protocol_whitelist_contract.py +++ b/tests/test_ffmpeg_protocol_whitelist_contract.py @@ -83,5 +83,34 @@ def test_silence_detection_applies_whitelist_before_input_flag(self) -> None: self.assertEqual(run.call_args.kwargs["pass_fds"], ()) -if __name__ == "__main__": + + def test_decode_mlx_audio_applies_whitelist_before_input_flag(self) -> None: + completed = subprocess.CompletedProcess( + args=[], returncode=0, stdout=b"dummy", stderr=b"" + ) + with tempfile.TemporaryDirectory() as root: + media_path = Path(root) / "recording.m4a" + media_path.write_bytes(b"fixture") + with ( + patch( + "audio_library.trusted_ffmpeg_binary", + return_value=Path("/usr/bin/ffmpeg"), + ), + patch("audio_library.subprocess.run", return_value=completed) as run, + ): + with patch.dict("sys.modules", {"mlx": unittest.mock.MagicMock(), "mlx.core": unittest.mock.MagicMock(), "numpy": unittest.mock.MagicMock()}): + try: + audio_library.decode_audio_for_mlx(media_path) + except Exception: + pass + + command = run.call_args.args[0] + whitelist_index = self.assert_local_input_protocols(command) + self.assertEqual( + command[whitelist_index + 2 : whitelist_index + 4], + ["-i", str(media_path)], + ) + self.assertEqual(run.call_args.kwargs["pass_fds"], ()) + +if __name__ == '__main__': unittest.main()