Skip to content

Commit 78fe3ab

Browse files
authored
Build storage: add additional checks for the source dir (#9890)
Extra checks for symlinks and making sure the directory is within the docroot directory. THIS DOESN'T PRESENT A SECURITY VULNERABILITY FOR RTD.ORG NOR RTD.COM, since while fixing GHSA-368m-86q9-m99w we added checks to make sure we didn't read files outside the docroot, this limiting the attack surface, and we run one build at a time, this means that only files from the current build are exposed in the docroot directory.
1 parent e1e7a18 commit 78fe3ab

File tree

2 files changed

+60
-2
lines changed

2 files changed

+60
-2
lines changed

readthedocs/builds/storage.py

+21-2
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ def copy_directory(self, source, destination):
8484
destination=destination,
8585
)
8686
source = Path(source)
87+
self._check_suspicious_path(source)
8788
for filepath in source.iterdir():
8889
sub_destination = self.join(destination, filepath.name)
8990

@@ -102,6 +103,21 @@ def copy_directory(self, source, destination):
102103
with safe_open(filepath, "rb") as fd:
103104
self.save(sub_destination, fd)
104105

106+
def _check_suspicious_path(self, path):
107+
"""Check that the given path isn't a symlink or outside the doc root."""
108+
path = Path(path)
109+
resolved_path = path.resolve()
110+
if path.is_symlink():
111+
msg = "Suspicious operation over a symbolic link."
112+
log.error(msg, path=str(path), resolved_path=str(resolved_path))
113+
raise SuspiciousFileOperation(msg)
114+
115+
docroot = Path(settings.DOCROOT).absolute()
116+
if not path.is_relative_to(docroot):
117+
msg = "Suspicious operation outside the docroot directory."
118+
log.error(msg, path=str(path), resolved_path=str(resolved_path))
119+
raise SuspiciousFileOperation(msg)
120+
105121
def sync_directory(self, source, destination):
106122
"""
107123
Sync a directory recursively to storage.
@@ -115,12 +131,15 @@ def sync_directory(self, source, destination):
115131
if destination in ('', '/'):
116132
raise SuspiciousFileOperation('Syncing all storage cannot be right')
117133

134+
source = Path(source)
135+
self._check_suspicious_path(source)
136+
118137
log.debug(
119138
'Syncing to media storage.',
120-
source=source,
139+
source=str(source),
121140
destination=destination,
122141
)
123-
source = Path(source)
142+
124143
copied_files = set()
125144
copied_dirs = set()
126145
for filepath in source.iterdir():

readthedocs/rtd_tests/tests/test_build_storage.py

+39
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import os
22
import shutil
33
import tempfile
4+
from pathlib import Path
45

6+
import pytest
7+
from django.core.exceptions import SuspiciousFileOperation
58
from django.test import TestCase, override_settings
69

710
from readthedocs.builds.storage import BuildMediaFileSystemStorage
@@ -84,6 +87,42 @@ def test_sync_directory(self):
8487
self.storage.sync_directory(tmp_files_dir, storage_dir)
8588
self.assertFileTree(storage_dir, tree)
8689

90+
def test_sync_directory_source_symlink(self):
91+
tmp_dir = Path(tempfile.mkdtemp())
92+
tmp_symlink_dir = Path(tempfile.mkdtemp()) / "files"
93+
tmp_symlink_dir.symlink_to(tmp_dir)
94+
95+
with override_settings(DOCROOT=tmp_dir):
96+
with pytest.raises(SuspiciousFileOperation, match="symbolic link"):
97+
self.storage.sync_directory(tmp_symlink_dir, "files")
98+
99+
def test_copy_directory_source_symlink(self):
100+
tmp_dir = Path(tempfile.mkdtemp())
101+
tmp_symlink_dir = Path(tempfile.mkdtemp()) / "files"
102+
tmp_symlink_dir.symlink_to(tmp_dir)
103+
104+
with override_settings(DOCROOT=tmp_dir):
105+
with pytest.raises(SuspiciousFileOperation, match="symbolic link"):
106+
self.storage.copy_directory(tmp_symlink_dir, "files")
107+
108+
def test_sync_directory_source_outside_docroot(self):
109+
tmp_dir = Path(tempfile.mkdtemp())
110+
tmp_docroot = Path(tempfile.mkdtemp()) / "docroot"
111+
tmp_docroot.mkdir()
112+
113+
with override_settings(DOCROOT=tmp_docroot):
114+
with pytest.raises(SuspiciousFileOperation, match="outside the docroot"):
115+
self.storage.sync_directory(tmp_dir, "files")
116+
117+
def test_copy_directory_source_outside_docroot(self):
118+
tmp_dir = Path(tempfile.mkdtemp())
119+
tmp_docroot = Path(tempfile.mkdtemp()) / "docroot"
120+
tmp_docroot.mkdir()
121+
122+
with override_settings(DOCROOT=tmp_docroot):
123+
with pytest.raises(SuspiciousFileOperation, match="outside the docroot"):
124+
self.storage.copy_directory(tmp_dir, "files")
125+
87126
def test_delete_directory(self):
88127
with override_settings(DOCROOT=files_dir):
89128
self.storage.copy_directory(files_dir, "files")

0 commit comments

Comments
 (0)