Skip to content

Commit 82817b5

Browse files
authored
Guard against symlinks in training data paths (#2445)
* Guard against symlinks in training data paths * Add click (missing spacy subdependency * Fix failing test
1 parent feafc81 commit 82817b5

4 files changed

Lines changed: 86 additions & 4 deletions

File tree

chatterbot/trainers.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -618,13 +618,18 @@ def extract(self, file_path: str):
618618
if not self.disable_progress:
619619
print('Extracting {}'.format(file_path))
620620

621+
if os.path.islink(self.data_path):
622+
raise self.TrainerInitializationException(
623+
'Refusing to extract archive to a symbolic link: {}'.format(self.data_path)
624+
)
625+
621626
if not os.path.exists(self.data_path):
622627
os.makedirs(self.data_path)
623628

624629
def is_within_directory(directory, target):
625630

626-
abs_directory = os.path.abspath(directory)
627-
abs_target = os.path.abspath(target)
631+
abs_directory = os.path.realpath(directory)
632+
abs_target = os.path.realpath(target)
628633

629634
prefix = os.path.commonprefix([abs_directory, abs_target])
630635

@@ -633,6 +638,8 @@ def is_within_directory(directory, target):
633638
def safe_extract(tar, path='.', members=None, *, numeric_owner=False):
634639

635640
for member in tar.getmembers():
641+
if member.issym() or member.islnk():
642+
raise Exception('Symlinks and hard links are not permitted in the tar file')
636643
member_path = os.path.join(path, member.name)
637644
if not is_within_directory(path, member_path):
638645
raise Exception('Attempted Path Traversal in Tar File')

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,8 @@ test = [
7474
"sphinx>=5.3,<9.2",
7575
"sphinx-sitemap>=2.6.0",
7676
"huggingface_hub",
77-
"django>=4.1,<6.0"
77+
"django>=4.1,<6.0",
78+
"click>=8.4,<9.0"
7879
]
7980
dev = [
8081
"pint>=0.8.1",

tests/logic/test_mathematical_evaluation.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@ def test_negative_decimal_multiplication(self):
9696
def test_pi_constant(self):
9797
statement = Statement(text='What is pi plus one ?')
9898
response = self.adapter.process(statement)
99-
self.assertEqual(response.text, 'pi plus one = 4.141693')
99+
self.assertEqual(response.text, 'pi plus one = 4.141593')
100100
self.assertEqual(response.confidence, 1)
101101

102102
def test_e_constant(self):

tests/training/test_ubuntu_corpus_training.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,3 +213,77 @@ def test_is_not_extracted(self):
213213
extracted = self.trainer.is_extracted(self.trainer.data_path)
214214

215215
self.assertFalse(extracted)
216+
217+
def test_extract_raises_on_symlink_data_path(self):
218+
"""
219+
Test that extract() raises an exception when data_path is a symlink.
220+
221+
A local attacker could pre-plant a symlink at the predictable
222+
data_path location to redirect archive extraction to an arbitrary
223+
directory. The trainer must reject a symlink target before extracting.
224+
"""
225+
import tempfile
226+
import shutil
227+
228+
attacker_target = tempfile.mkdtemp(prefix='cb_symlink_attack_')
229+
try:
230+
file_object_path = self._create_test_corpus(self._get_data())
231+
232+
# Plant the symlink at data_path before extraction
233+
os.makedirs(self.trainer.data_directory, exist_ok=True)
234+
os.symlink(attacker_target, self.trainer.data_path)
235+
236+
with self.assertRaises(Exception):
237+
self.trainer.extract(file_object_path)
238+
239+
# Confirm nothing was written to the attacker's target directory
240+
self.assertEqual(
241+
os.listdir(attacker_target), [],
242+
'Files were written through the symlink to the attacker target directory'
243+
)
244+
finally:
245+
if os.path.islink(self.trainer.data_path):
246+
os.unlink(self.trainer.data_path)
247+
shutil.rmtree(attacker_target, ignore_errors=True)
248+
self._destroy_test_corpus()
249+
250+
def test_extract_does_not_follow_symlink_members(self):
251+
"""
252+
Test that safe_extract() rejects tar members whose resolved paths
253+
escape the extraction directory via a symlink.
254+
255+
Even if data_path itself is legitimate, a tar archive containing a
256+
symlink member pointing outside the extraction root must be rejected.
257+
"""
258+
import tempfile
259+
import shutil
260+
261+
attacker_target = tempfile.mkdtemp(prefix='cb_member_symlink_attack_')
262+
try:
263+
os.makedirs(self.trainer.data_path, exist_ok=True)
264+
265+
# Build a tar containing a directory entry that is a symlink
266+
# pointing outside the extraction root, plus a file routed through it.
267+
file_path = os.path.join(self.trainer.data_directory, 'malicious.tgz')
268+
with tarfile.TarFile(file_path, 'w') as tf:
269+
link_info = tarfile.TarInfo('escape_link')
270+
link_info.type = tarfile.SYMTYPE
271+
link_info.linkname = attacker_target
272+
tf.addfile(link_info)
273+
274+
payload = b'should not be written outside extraction root\n'
275+
file_info = tarfile.TarInfo('escape_link/pwned.txt')
276+
file_info.size = len(payload)
277+
tf.addfile(file_info, BytesIO(payload))
278+
279+
with self.assertRaises(Exception):
280+
self.trainer.extract(file_path)
281+
282+
self.assertEqual(
283+
os.listdir(attacker_target), [],
284+
'Files were written outside the extraction root via a symlink member'
285+
)
286+
finally:
287+
if os.path.exists(file_path):
288+
os.remove(file_path)
289+
shutil.rmtree(attacker_target, ignore_errors=True)

0 commit comments

Comments
 (0)