Skip to content

Commit bf510ea

Browse files
committed
🛡️ Sentinel: [CRITICAL] Fix Path Traversal in Kokoro Engine
Sanitized user-provided filenames across `resolve_voice_path`, `mix_voices`, `load_preset`, and `load_fx_preset` in `kokoro_engine.py` using `os.path.basename()` to prevent directory traversal attacks (e.g. `../../../etc/passwd`). Added `test_security.py` using mocked dependencies to verify the fix. Documented findings in `.jules/sentinel.md`.
1 parent 54776c0 commit bf510ea

3 files changed

Lines changed: 74 additions & 1 deletion

File tree

.jules/sentinel.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
## 2024-05-24 - [Path Traversal in Parsed Multi-speaker Syntax]
2+
**Vulnerability:** User-controlled filenames generated from parsed multi-speaker text (e.g. `[speaker_name]:`) or direct inputs to engine mix/load endpoints lacked sanitization in `kokoro_engine.py`, resulting in Path Traversal vulnerabilities when fetching presets, voices, and fx files using `os.path.join()`.
3+
**Learning:** Due to the complex text parsing mechanism, what initially seems like clean preset names parsed out of input strings might contain relative traversal directories, making the system unexpectedly vulnerable.
4+
**Prevention:** Always use `os.path.basename()` or equivalent file name sanitization before passing parsed components or user inputs to `os.path.join()`.

kokoro_engine.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,9 @@ def resolve_voice_path(self, voice_name):
106106
Returns the absolute path if it's a custom voice,
107107
otherwise returns the name as-is (for standard voices).
108108
"""
109+
# Sanitize to prevent path traversal
110+
voice_name = os.path.basename(voice_name)
111+
109112
# Check if it's a custom voice file
110113
custom_path = os.path.join(CUSTOM_VOICES_DIR, f"{voice_name}.pt")
111114
if os.path.exists(custom_path):
@@ -318,7 +321,8 @@ def _mix():
318321
mixed = t1 * (1.0 - ratio) + t2 * ratio
319322

320323
# Save
321-
out_path = os.path.join(CUSTOM_VOICES_DIR, f"{new_name}.pt")
324+
new_name_clean = os.path.basename(new_name)
325+
out_path = os.path.join(CUSTOM_VOICES_DIR, f"{new_name_clean}.pt")
322326
torch.save(mixed, out_path)
323327
return True, out_path, mixed
324328
except Exception as e:
@@ -479,6 +483,8 @@ def parse_multispeaker_text(self, text):
479483

480484
def load_preset(self, name):
481485
"""Loads a preset from the presets directory."""
486+
# Sanitize name to prevent path traversal
487+
name = os.path.basename(name)
482488
preset_path = os.path.join("presets", f"{name}.json")
483489
if os.path.exists(preset_path):
484490
try:
@@ -490,6 +496,8 @@ def load_preset(self, name):
490496

491497
def load_fx_preset(self, name):
492498
"""Loads an FX preset from the presets/fx directory."""
499+
# Sanitize name to prevent path traversal
500+
name = os.path.basename(name)
493501
fx_path = os.path.join("presets", "fx", f"{name}.json")
494502
if os.path.exists(fx_path):
495503
try:

test_security.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import sys
2+
import unittest
3+
from unittest.mock import MagicMock
4+
import os
5+
6+
# Mock dependencies
7+
sys.modules['soundfile'] = MagicMock()
8+
sys.modules['torch'] = MagicMock()
9+
sys.modules['numpy'] = MagicMock()
10+
sys.modules['scipy'] = MagicMock()
11+
sys.modules['scipy.signal'] = MagicMock()
12+
sys.modules['pedalboard'] = MagicMock()
13+
sys.modules['pedalboard.io'] = MagicMock()
14+
sys.modules['pypdf'] = MagicMock()
15+
sys.modules['ebooklib'] = MagicMock()
16+
sys.modules['ebooklib.epub'] = MagicMock()
17+
sys.modules['bs4'] = MagicMock()
18+
sys.modules['winsound'] = MagicMock()
19+
sys.modules['kokoro'] = MagicMock()
20+
21+
# Now we can import the engine
22+
from kokoro_engine import KokoroEngine
23+
24+
class TestSecurity(unittest.TestCase):
25+
def setUp(self):
26+
self.engine = KokoroEngine()
27+
28+
def test_path_traversal_resolve_voice_path(self):
29+
payload = "../../../../../etc/passwd"
30+
path = self.engine.resolve_voice_path(payload)
31+
self.assertNotIn("etc", path)
32+
self.assertNotIn("..", path)
33+
self.assertFalse("passwd" in path and "/" in path)
34+
35+
def test_path_traversal_load_preset(self):
36+
payload = "../../../../../etc/passwd"
37+
# If it doesn't fail parsing or trying to open /etc/passwd, we expect it to look in presets/
38+
# Just checking if the constructed path in load_preset would have been bad is tricky
39+
# since load_preset catches exception and returns None.
40+
# We can mock os.path.exists and open if needed, or rely on the fix in the engine.
41+
# Let's mock open to see what it tries to open
42+
with unittest.mock.patch('builtins.open', unittest.mock.mock_open(read_data='{}')) as m:
43+
with unittest.mock.patch('os.path.exists', return_value=True):
44+
self.engine.load_preset(payload)
45+
m.assert_called_once()
46+
args, kwargs = m.call_args
47+
self.assertNotIn("..", args[0])
48+
self.assertNotIn("etc", args[0])
49+
50+
def test_path_traversal_load_fx_preset(self):
51+
payload = "../../../../../etc/passwd"
52+
with unittest.mock.patch('builtins.open', unittest.mock.mock_open(read_data='{}')) as m:
53+
with unittest.mock.patch('os.path.exists', return_value=True):
54+
self.engine.load_fx_preset(payload)
55+
m.assert_called_once()
56+
args, kwargs = m.call_args
57+
self.assertNotIn("..", args[0])
58+
self.assertNotIn("etc", args[0])
59+
60+
if __name__ == '__main__':
61+
unittest.main()

0 commit comments

Comments
 (0)