Skip to content

Commit bfb2faf

Browse files
authored
security: quote untrusted values in command construction across connectors, operations, and util (#1664)
1 parent b0fe38f commit bfb2faf

9 files changed

Lines changed: 202 additions & 50 deletions

File tree

src/pyinfra/connectors/chroot.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import os
2+
import shlex
23
from tempfile import mkstemp
34
from typing import TYPE_CHECKING, Optional
45

@@ -64,7 +65,7 @@ def connect(self) -> None:
6465
try:
6566
with progress_spinner({"chroot run"}):
6667
local.shell(
67-
"chroot {0} ls".format(chroot_directory),
68+
"chroot {0} ls".format(shlex.quote(chroot_directory)),
6869
splitlines=True,
6970
)
7071
except PyinfraError as e:
@@ -91,7 +92,7 @@ def run_shell_command(
9192

9293
chroot_command = StringCommand(
9394
"chroot",
94-
chroot_directory,
95+
QuoteString(chroot_directory),
9596
"sh",
9697
"-c",
9798
command,
@@ -130,8 +131,8 @@ def put_file(
130131
chroot_directory = self.host.connector_data["chroot_directory"]
131132
chroot_command = StringCommand(
132133
"cp",
133-
temp_filename,
134-
f"{chroot_directory}/{remote_filename}",
134+
QuoteString(temp_filename),
135+
QuoteString(f"{chroot_directory}/{remote_filename}"),
135136
)
136137

137138
status, output = self.local.run_shell_command(
@@ -172,8 +173,8 @@ def get_file(
172173
chroot_directory = self.host.connector_data["chroot_directory"]
173174
chroot_command = StringCommand(
174175
"cp",
175-
f"{chroot_directory}/{remote_filename}",
176-
temp_filename,
176+
QuoteString(f"{chroot_directory}/{remote_filename}"),
177+
QuoteString(temp_filename),
177178
)
178179

179180
status, output = self.local.run_shell_command(

src/pyinfra/connectors/docker.py

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import json
44
import os
5+
import shlex
56
from tempfile import mkstemp
67
from typing import TYPE_CHECKING
78

@@ -106,12 +107,13 @@ def make_names_data(name=None):
106107

107108
# 2 helper functions
108109
def _find_start_docker_container(self, container_id) -> tuple[str, bool]:
109-
docker_info = local.shell(f"{self.docker_cmd} container inspect {container_id}")
110+
quoted_container_id = shlex.quote(container_id)
111+
docker_info = local.shell(f"{self.docker_cmd} container inspect {quoted_container_id}")
110112
assert isinstance(docker_info, str)
111113
docker_info = json.loads(docker_info)[0]
112114
if docker_info["State"]["Running"] is False:
113115
logger.info(f"Starting stopped container: {container_id}")
114-
local.shell(f"{self.docker_cmd} container start {container_id}")
116+
local.shell(f"{self.docker_cmd} container start {quoted_container_id}")
115117
return container_id, False
116118
return container_id, True
117119

@@ -123,13 +125,13 @@ def _start_docker_image(self, image_name):
123125
]
124126

125127
if self.data.get("docker_platform"):
126-
docker_cmd_parts.extend(["--platform", self.data["docker_platform"]])
128+
docker_cmd_parts.extend(["--platform", shlex.quote(self.data["docker_platform"])])
127129
if self.data.get("docker_architecture"):
128-
docker_cmd_parts.extend(["--arch", self.data["docker_architecture"]])
130+
docker_cmd_parts.extend(["--arch", shlex.quote(self.data["docker_architecture"])])
129131

130132
docker_cmd_parts.extend(
131133
[
132-
image_name,
134+
shlex.quote(image_name),
133135
"tail",
134136
"-f",
135137
"/dev/null",
@@ -173,14 +175,16 @@ def disconnect(self) -> None:
173175
)
174176
return
175177

178+
quoted_container_id = shlex.quote(container_id)
179+
176180
with progress_spinner({f"{self.docker_cmd} commit"}):
177-
image_id = local.shell(f"{self.docker_cmd} commit {container_id}", splitlines=True)[-1][
178-
7:19
179-
] # last line is the image ID, get sha256:[XXXXXXXXXX]...
181+
image_id = local.shell(
182+
f"{self.docker_cmd} commit {quoted_container_id}", splitlines=True
183+
)[-1][7:19] # last line is the image ID, get sha256:[XXXXXXXXXX]...
180184

181185
with progress_spinner({f"{self.docker_cmd} rm"}):
182186
local.shell(
183-
f"{self.docker_cmd} rm -f {container_id}",
187+
f"{self.docker_cmd} rm -f {quoted_container_id}",
184188
)
185189

186190
logger.info(
@@ -255,8 +259,8 @@ def put_file(
255259
docker_command = StringCommand(
256260
self.docker_cmd,
257261
"cp",
258-
temp_filename,
259-
f"{self.container_id}:{remote_filename}",
262+
QuoteString(temp_filename),
263+
QuoteString(f"{self.container_id}:{remote_filename}"),
260264
)
261265

262266
status, output = self.local.run_shell_command(
@@ -303,8 +307,8 @@ def get_file(
303307
docker_command = StringCommand(
304308
self.docker_cmd,
305309
"cp",
306-
f"{self.container_id}:{remote_filename}",
307-
temp_filename,
310+
QuoteString(f"{self.container_id}:{remote_filename}"),
311+
QuoteString(temp_filename),
308312
)
309313

310314
status, output = self.local.run_shell_command(

src/pyinfra/connectors/dockerssh.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ def connect(self) -> None:
9090
self.docker_cmd,
9191
"run",
9292
"-d",
93-
self.host.data.docker_image,
93+
QuoteString(self.host.data.docker_image),
9494
"tail",
9595
"-f",
9696
"/dev/null",
@@ -111,15 +111,15 @@ def disconnect(self) -> None:
111111

112112
with progress_spinner({f"{self.docker_cmd} commit"}):
113113
_, output = self.ssh.run_shell_command(
114-
StringCommand(self.docker_cmd, "commit", container_id)
114+
StringCommand(self.docker_cmd, "commit", QuoteString(container_id))
115115
)
116116

117117
# Last line is the image ID, get sha256:[XXXXXXXXXX]...
118118
image_id = output.stdout_lines[-1][7:19]
119119

120120
with progress_spinner({f"{self.docker_cmd} rm"}):
121121
self.ssh.run_shell_command(
122-
StringCommand(self.docker_cmd, "rm", "-f", container_id),
122+
StringCommand(self.docker_cmd, "rm", "-f", QuoteString(container_id)),
123123
)
124124

125125
logger.info(
@@ -150,7 +150,7 @@ def run_shell_command(
150150
self.docker_cmd,
151151
"exec",
152152
docker_flags,
153-
container_id,
153+
QuoteString(container_id),
154154
"sh",
155155
"-c",
156156
command,
@@ -203,8 +203,8 @@ def put_file(
203203
docker_command = StringCommand(
204204
self.docker_cmd,
205205
"cp",
206-
remote_temp_filename,
207-
f"{docker_id}:{remote_filename}",
206+
QuoteString(remote_temp_filename),
207+
QuoteString(f"{docker_id}:{remote_filename}"),
208208
)
209209

210210
status, output = self.ssh.run_shell_command(
@@ -257,8 +257,8 @@ def get_file(
257257
docker_command = StringCommand(
258258
self.docker_cmd,
259259
"cp",
260-
f"{docker_id}:{remote_filename}",
261-
remote_temp_filename,
260+
QuoteString(f"{docker_id}:{remote_filename}"),
261+
QuoteString(remote_temp_filename),
262262
)
263263

264264
status, output = self.ssh.run_shell_command(

src/pyinfra/connectors/util.py

Lines changed: 27 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -235,12 +235,12 @@ def write_stdin(stdin, buffer):
235235
def remove_any_sudo_askpass_file(host) -> None:
236236
sudo_askpass_path = host.connector_data.get("sudo_askpass_path")
237237
if sudo_askpass_path:
238-
host.run_shell_command("rm -f {0}".format(sudo_askpass_path))
238+
host.run_shell_command(StringCommand("rm", "-f", QuoteString(sudo_askpass_path)))
239239
host.connector_data["sudo_askpass_path"] = None
240240

241241
su_askpass_path = host.connector_data.get("su_askpass_path")
242242
if su_askpass_path:
243-
host.run_shell_command("rm -f {0}".format(su_askpass_path))
243+
host.run_shell_command(StringCommand("rm", "-f", QuoteString(su_askpass_path)))
244244
host.connector_data["su_askpass_path"] = None
245245

246246

@@ -293,7 +293,7 @@ def _ensure_askpass_set_for_host(host: "Host", key: str, env_var: str):
293293
)
294294
)
295295

296-
host.connector_data[key] = StringCommand(QuoteString(output.stdout_lines[0])).get_raw_value()
296+
host.connector_data[key] = output.stdout_lines[0]
297297

298298

299299
def make_unix_command_for_host(
@@ -367,31 +367,38 @@ def make_unix_command(
367367
_shell_executable = "sh"
368368

369369
if _env:
370-
env_string = " ".join(['"{0}={1}"'.format(key, value) for key, value in _env.items()])
371-
command = StringCommand("export", env_string, "&&", command)
370+
env_bits: list[Union[str, StringCommand, QuoteString]] = ["export"]
371+
for key, value in _env.items():
372+
# Quote the whole `key=value` pair so arbitrary values cannot break
373+
# out into additional shell tokens. Invalid identifiers in `key` will
374+
# fail safely when the shell rejects the resulting `export` statement.
375+
env_bits.append(QuoteString("{0}={1}".format(key, value)))
376+
env_bits.append("&&")
377+
env_bits.append(command)
378+
command = StringCommand(*env_bits)
372379

373380
if _chdir:
374-
command = StringCommand("cd", _chdir, "&&", command)
381+
command = StringCommand("cd", QuoteString(_chdir), "&&", command)
375382

376383
command_bits: list[Union[str, StringCommand, QuoteString]] = []
377384

378385
if _doas:
379386
command_bits.extend(["doas", "-n"])
380387

381388
if _doas_user:
382-
command_bits.extend(["-u", _doas_user])
389+
command_bits.extend(["-u", QuoteString(_doas_user)])
383390

384391
if _dzdo:
385392
command_bits.extend(["dzdo", "-H", "-n"])
386393

387394
if _dzdo_user:
388-
command_bits.extend(["-u", _dzdo_user])
395+
command_bits.extend(["-u", QuoteString(_dzdo_user)])
389396

390397
if _sudo_password and _sudo_askpass_path:
391398
command_bits.extend(
392399
[
393400
"env",
394-
"SUDO_ASKPASS={0}".format(_sudo_askpass_path),
401+
StringCommand("SUDO_ASKPASS=", QuoteString(_sudo_askpass_path), _separator=""),
395402
MaskString(
396403
"{0}={1}".format(
397404
SUDO_ASKPASS_ENV_VAR,
@@ -416,7 +423,7 @@ def make_unix_command(
416423
command_bits.append("-E")
417424

418425
if _sudo_user:
419-
command_bits.extend(("-u", _sudo_user))
426+
command_bits.extend(("-u", QuoteString(_sudo_user)))
420427

421428
if _su_user:
422429
if _su_password and _su_askpass_path:
@@ -429,7 +436,7 @@ def make_unix_command(
429436
StringCommand(QuoteString(_su_password)).get_raw_value(),
430437
)
431438
),
432-
_su_askpass_path,
439+
QuoteString(_su_askpass_path),
433440
"|",
434441
],
435442
)
@@ -444,9 +451,16 @@ def make_unix_command(
444451
command_bits.append("-m")
445452

446453
if _su_shell:
447-
command_bits.extend(["-s", "`which {0}`".format(_su_shell)])
454+
# Resolve the shell via `command -v`, with the user-supplied shell
455+
# name safely quoted so it cannot inject extra shell syntax.
456+
command_bits.extend(
457+
[
458+
"-s",
459+
StringCommand("$(command -v ", QuoteString(_su_shell), ")", _separator=""),
460+
]
461+
)
448462

449-
command_bits.extend([_su_user, "-c"])
463+
command_bits.extend([QuoteString(_su_user), "-c"])
450464

451465
if _shell_executable is not None:
452466
# Quote the whole shell -c 'command' as BSD `su` does not have a shell option

src/pyinfra/operations/selinux.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,12 @@ def boolean(bool_name: str, value: Boolean, persistent=False):
6060
raise OperationValueError(f"Invalid value '{value}' for boolean operation")
6161

6262
if host.get_fact(SEBoolean, boolean=bool_name) != value_str:
63-
persist = "-P " if persistent else ""
64-
yield StringCommand("setsebool", f"{persist}{bool_name}", value_str)
63+
command_bits: list = ["setsebool"]
64+
if persistent:
65+
command_bits.append("-P")
66+
command_bits.append(QuoteString(bool_name))
67+
command_bits.append(value_str)
68+
yield StringCommand(*command_bits)
6569
else:
6670
host.noop(f"boolean '{bool_name}' already had the value '{value_str}'")
6771

src/pyinfra/operations/util/files.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from enum import Enum
77
from typing import Callable, Generator
88

9-
from pyinfra.api import QuoteString, StringCommand
9+
from pyinfra.api import OperationError, QuoteString, StringCommand
1010
from pyinfra.api.output import format_text
1111

1212

@@ -146,7 +146,7 @@ def chown(
146146
dereference=True,
147147
) -> StringCommand:
148148
command = "chown"
149-
user_group = None
149+
user_group: str | None = None
150150

151151
if user and group:
152152
user_group = "{0}:{1}".format(user, group)
@@ -158,14 +158,17 @@ def chown(
158158
command = "chgrp"
159159
user_group = group
160160

161+
if user_group is None:
162+
raise OperationError("chown() requires at least one of user or group")
163+
161164
args = [command]
162165
if recursive:
163166
args.append("-R")
164167

165168
if not dereference:
166169
args.append("-h")
167170

168-
return StringCommand(" ".join(args), user_group, QuoteString(target))
171+
return StringCommand(" ".join(args), QuoteString(user_group), QuoteString(target))
169172

170173

171174
# like the touch command, but only supports setting one field at a time, and expects any
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"args": ["testfile"],
3+
"kwargs": {
4+
"user": "root; rm -rf /",
5+
"group": "wheel$(id)",
6+
"mode": "777"
7+
},
8+
"facts": {
9+
"files.File": {
10+
"path=testfile": null
11+
}
12+
},
13+
"commands": [
14+
"touch testfile",
15+
"chmod 777 testfile",
16+
"chown 'root; rm -rf /:wheel$(id)' testfile"
17+
]
18+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"args": ["httpd`id`", "on"],
3+
"kwargs": {
4+
"persistent": true
5+
},
6+
"facts": {
7+
"selinux.SEBoolean": {
8+
"boolean=httpd`id`": "off"
9+
}
10+
},
11+
"commands": [
12+
"setsebool -P 'httpd`id`' on"
13+
]
14+
}

0 commit comments

Comments
 (0)