Skip to content

Commit b586cf0

Browse files
authored
Builds: prevent code injection in cwd (#8198)
This isn't a vulnerability since this command is executed inside a container with a unprivileged user. I have replaced the usage of manual `cd` with using the `workdir` option from docker. This option doesn't support env vars expansion (which is safer), so I had to introduce a new setting.
1 parent 25ed88e commit b586cf0

File tree

4 files changed

+34
-22
lines changed

4 files changed

+34
-22
lines changed

readthedocs/doc_builder/environments.py

+20-15
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,8 @@ class BuildCommand(BuildCommandResultMixin):
7575
:py:class:`readthedocs.builds.models.BuildCommandResult` model.
7676
7777
:param command: string or array of command parameters
78-
:param cwd: current working path for the command
78+
:param cwd: Absolute path used as the current working path for the command.
79+
Defaults to ``RTD_DOCKER_WORKDIR``.
7980
:param shell: execute command in shell, default=False
8081
:param environment: environment variables to add to environment
8182
:type environment: dict
@@ -102,7 +103,7 @@ def __init__(
102103
):
103104
self.command = command
104105
self.shell = shell
105-
self.cwd = cwd or '$HOME'
106+
self.cwd = cwd or settings.RTD_DOCKER_WORKDIR
106107
self.user = user or settings.RTD_DOCKER_USER
107108
self.environment = environment.copy() if environment else {}
108109
if 'PATH' in self.environment:
@@ -152,9 +153,7 @@ def run(self):
152153
proc = subprocess.Popen(
153154
command,
154155
shell=self.shell,
155-
# This is done here for local builds, but not for docker,
156-
# as we want docker to expand inside the container
157-
cwd=os.path.expandvars(self.cwd),
156+
cwd=self.cwd,
158157
stdin=None,
159158
stdout=subprocess.PIPE,
160159
stderr=subprocess.STDOUT,
@@ -271,6 +270,11 @@ class DockerBuildCommand(BuildCommand):
271270
Build command to execute in docker container
272271
"""
273272

273+
bash_escape_re = re.compile(
274+
r"([\t\ \!\"\#\$\&\'\(\)\*\:\;\<\>\?\@"
275+
r'\[\\\]\^\`\{\|\}\~])'
276+
)
277+
274278
def __init__(self, *args, escape_command=True, **kwargs):
275279
"""
276280
Override default to extend behavior.
@@ -300,6 +304,7 @@ def run(self):
300304
cmd=self.get_wrapped_command(),
301305
environment=self.environment,
302306
user=self.user,
307+
workdir=self.cwd,
303308
stdout=True,
304309
stderr=True,
305310
)
@@ -345,28 +350,28 @@ def get_wrapped_command(self):
345350
``escape_command=True`` in the init method this escapes a good majority
346351
of those characters.
347352
"""
348-
bash_escape_re = re.compile(
349-
r"([\t\ \!\"\#\$\&\'\(\)\*\:\;\<\>\?\@"
350-
r'\[\\\]\^\`\{\|\}\~])',
351-
)
352353
prefix = ''
353354
if self.bin_path:
354-
prefix += 'PATH={}:$PATH '.format(self.bin_path)
355+
bin_path = self._escape_command(self.bin_path)
356+
prefix += f'PATH={bin_path}:$PATH '
355357

356358
command = (
357-
' '.join([
358-
bash_escape_re.sub(r'\\\1', part) if self.escape_command else part
359+
' '.join(
360+
self._escape_command(part) if self.escape_command else part
359361
for part in self.command
360-
])
362+
)
361363
)
362364
return (
363-
"/bin/sh -c 'cd {cwd} && {prefix}{cmd}'".format(
364-
cwd=self.cwd,
365+
"/bin/sh -c '{prefix}{cmd}'".format(
365366
prefix=prefix,
366367
cmd=command,
367368
)
368369
)
369370

371+
def _escape_command(self, cmd):
372+
r"""Escape the command by prefixing suspicious chars with `\`."""
373+
return self.bash_escape_re.sub(r'\\\1', cmd)
374+
370375

371376
class BaseEnvironment:
372377

readthedocs/doc_builder/python_environments.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -323,7 +323,7 @@ def setup_base(self):
323323
# Don't use virtualenv bin that doesn't exist yet
324324
bin_path=None,
325325
# Don't use the project's root, some config files can interfere
326-
cwd='$HOME',
326+
cwd=None,
327327
)
328328

329329
def install_core_requirements(self):

readthedocs/rtd_tests/tests/test_doc_building.py

+12-6
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from unittest.mock import Mock, PropertyMock, mock_open, patch
1414

1515
import pytest
16-
from django.test import TestCase
16+
from django.test import TestCase, override_settings
1717
from django_dynamic_fixture import get
1818
from docker.errors import APIError as DockerAPIError
1919
from docker.errors import DockerException
@@ -356,6 +356,7 @@ def test_failing_execution_with_unexpected_exception(self):
356356
})
357357

358358

359+
@override_settings(RTD_DOCKER_WORKDIR='/tmp/')
359360
class TestDockerBuildEnvironment(TestCase):
360361

361362
"""Test docker build environment."""
@@ -699,7 +700,8 @@ def test_command_execution(self):
699700

700701
self.mocks.docker_client.exec_create.assert_called_with(
701702
container='build-123-project-6-pip',
702-
cmd="/bin/sh -c 'cd /tmp && echo\\ test'",
703+
cmd="/bin/sh -c 'echo\\ test'",
704+
workdir='/tmp',
703705
environment=mock.ANY,
704706
user='docs:docs',
705707
stderr=True,
@@ -759,7 +761,8 @@ def test_command_not_recorded(self):
759761

760762
self.mocks.docker_client.exec_create.assert_called_with(
761763
container='build-123-project-6-pip',
762-
cmd="/bin/sh -c 'cd /tmp && echo\\ test'",
764+
cmd="/bin/sh -c 'echo\\ test'",
765+
workdir='/tmp',
763766
environment=mock.ANY,
764767
user='docs:docs',
765768
stderr=True,
@@ -806,7 +809,8 @@ def test_record_command_as_success(self):
806809

807810
self.mocks.docker_client.exec_create.assert_called_with(
808811
container='build-123-project-6-pip',
809-
cmd="/bin/sh -c 'cd /tmp && echo\\ test'",
812+
cmd="/bin/sh -c 'echo\\ test'",
813+
workdir='/tmp',
810814
environment=mock.ANY,
811815
user='docs:docs',
812816
stderr=True,
@@ -1010,6 +1014,7 @@ def test_container_timeout(self):
10101014
})
10111015

10121016

1017+
@override_settings(RTD_DOCKER_WORKDIR='/tmp/')
10131018
class TestBuildCommand(TestCase):
10141019

10151020
"""Test build command creation."""
@@ -1090,6 +1095,7 @@ def test_unicode_output(self, mock_subprocess):
10901095
)
10911096

10921097

1098+
@override_settings(RTD_DOCKER_WORKDIR='/tmp/')
10931099
class TestDockerBuildCommand(TestCase):
10941100

10951101
"""Test docker build commands."""
@@ -1109,7 +1115,7 @@ def test_wrapped_command(self):
11091115
)
11101116
self.assertEqual(
11111117
cmd.get_wrapped_command(),
1112-
"/bin/sh -c 'cd /tmp/foobar && pip install requests'",
1118+
"/bin/sh -c 'pip install requests'",
11131119
)
11141120
cmd = DockerBuildCommand(
11151121
['python', '/tmp/foo/pip', 'install', 'Django>1.7'],
@@ -1120,7 +1126,7 @@ def test_wrapped_command(self):
11201126
cmd.get_wrapped_command(),
11211127
(
11221128
'/bin/sh -c '
1123-
"'cd /tmp/foobar && PATH=/tmp/foo:$PATH "
1129+
"'PATH=/tmp/foo:$PATH "
11241130
r"python /tmp/foo/pip install Django\>1.7'"
11251131
),
11261132
)

readthedocs/settings/base.py

+1
Original file line numberDiff line numberDiff line change
@@ -441,6 +441,7 @@ def TEMPLATES(self):
441441
# https://docs.docker.com/engine/reference/run/#user
442442
RTD_DOCKER_USER = 'docs:docs'
443443
RTD_DOCKER_SUPER_USER = 'root:root'
444+
RTD_DOCKER_WORKDIR = '/home/docs/'
444445

445446
RTD_DOCKER_COMPOSE = False
446447

0 commit comments

Comments
 (0)