Skip to content

Commit fdac97a

Browse files
committed
Harden plugin installation
Fixes #3455 by preventing command injection and unsafe ZIP writes during plugin installation.
1 parent d5fa99b commit fdac97a

1 file changed

Lines changed: 97 additions & 24 deletions

File tree

cogs/plugins.py

Lines changed: 97 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@
88
import zipfile
99
from difflib import get_close_matches
1010
from importlib import invalidate_caches
11-
from pathlib import Path, PurePath
12-
from re import match
11+
from pathlib import Path, PurePath, PurePosixPath, PureWindowsPath
12+
from re import fullmatch
1313
from site import USER_SITE
1414
from subprocess import PIPE
1515

@@ -24,6 +24,8 @@
2424

2525
logger = getLogger(__name__)
2626

27+
PLUGIN_COMPONENT_PATTERN = r"[A-Za-z0-9._-]+"
28+
2729

2830
class InvalidPluginError(commands.BadArgument):
2931
pass
@@ -32,6 +34,7 @@ class InvalidPluginError(commands.BadArgument):
3234
class Plugin:
3335
def __init__(self, user, repo=None, name=None, branch=None):
3436
if repo is None:
37+
self._validate_component(user, "name")
3538
self.user = "@local"
3639
self.repo = "@local"
3740
self.name = user
@@ -40,14 +43,31 @@ def __init__(self, user, repo=None, name=None, branch=None):
4043
self.url = f"@local/{user}"
4144
self.link = f"@local/{user}"
4245
else:
46+
branch = branch if branch is not None else "master"
47+
self._validate_component(user, "user")
48+
self._validate_component(repo, "repository")
49+
self._validate_component(name, "name")
50+
self._validate_component(branch, "branch")
4351
self.user = user
4452
self.repo = repo
4553
self.name = name
4654
self.local = False
47-
self.branch = branch if branch is not None else "master"
55+
self.branch = branch
4856
self.url = f"https://github.com/{user}/{repo}/archive/{self.branch}.zip"
4957
self.link = f"https://github.com/{user}/{repo}/tree/{self.branch}/{name}"
5058

59+
@staticmethod
60+
def _validate_component(value, component):
61+
if (
62+
not isinstance(value, str)
63+
or fullmatch(PLUGIN_COMPONENT_PATTERN, value) is None
64+
or value in {".", ".."}
65+
):
66+
raise InvalidPluginError(
67+
f"Invalid plugin {component}. Plugin identifiers may only contain ASCII letters, "
68+
"numbers, dots, underscores, and hyphens."
69+
)
70+
5171
@property
5272
def path(self):
5373
if self.local:
@@ -85,16 +105,24 @@ def __lt__(self, other):
85105

86106
@classmethod
87107
def from_string(cls, s, strict=False):
88-
m = match(r"^@?local/(.+)$", s)
108+
m = fullmatch(rf"@?local/({PLUGIN_COMPONENT_PATTERN})", s)
89109
if m is None:
90110
if not strict:
91-
m = match(r"^(.+?)/(.+?)/(.+?)(?:@(.+?))?$", s)
111+
m = fullmatch(
112+
rf"({PLUGIN_COMPONENT_PATTERN})/({PLUGIN_COMPONENT_PATTERN})/"
113+
rf"({PLUGIN_COMPONENT_PATTERN})(?:@({PLUGIN_COMPONENT_PATTERN}))?",
114+
s,
115+
)
92116
else:
93-
m = match(r"^(.+?)/(.+?)/(.+?)@(.+?)$", s)
117+
m = fullmatch(
118+
rf"({PLUGIN_COMPONENT_PATTERN})/({PLUGIN_COMPONENT_PATTERN})/"
119+
rf"({PLUGIN_COMPONENT_PATTERN})@({PLUGIN_COMPONENT_PATTERN})",
120+
s,
121+
)
94122

95123
if m is not None:
96124
return Plugin(*m.groups())
97-
raise InvalidPluginError("Cannot decipher %s.", s) # pylint: disable=raising-format-tuple
125+
raise InvalidPluginError(f"Cannot decipher {s}.")
98126

99127
def __hash__(self):
100128
return hash((self.user, self.repo, self.name, self.branch))
@@ -187,8 +215,6 @@ async def download_plugin(self, plugin, force=False):
187215
if plugin.local:
188216
raise InvalidPluginError(f"Local plugin {plugin} not found!")
189217

190-
plugin.abs_path.mkdir(parents=True, exist_ok=True)
191-
192218
if plugin.cache_path.exists() and not force:
193219
plugin_io = plugin.cache_path.open("rb")
194220
logger.debug("Loading cached %s.", plugin.cache_path)
@@ -219,19 +245,58 @@ async def download_plugin(self, plugin, force=False):
219245
with plugin.cache_path.open("wb") as f:
220246
f.write(raw)
221247

222-
with zipfile.ZipFile(plugin_io) as zipf:
223-
for info in zipf.infolist():
224-
path = PurePath(info.filename)
225-
if len(path.parts) >= 3 and path.parts[1] == plugin.name:
226-
plugin_path = plugin.abs_path / Path(*path.parts[2:])
248+
try:
249+
with zipfile.ZipFile(plugin_io) as zipf:
250+
plugin_root = plugin.abs_path.resolve()
251+
extraction_plan = []
252+
253+
# ZIP member names always use forward slashes. Validate every selected path
254+
# before extracting anything so an unsafe path cannot leave a partial install.
255+
for info in zipf.infolist():
256+
filename = info.orig_filename
257+
candidate_path = PurePosixPath(filename.replace("\\", "/"))
258+
259+
if len(candidate_path.parts) < 3 or candidate_path.parts[1] != plugin.name:
260+
continue
261+
262+
archive_path = PurePosixPath(filename)
263+
relative_parts = candidate_path.parts[2:]
264+
windows_archive_path = PureWindowsPath(filename)
265+
windows_relative_path = PureWindowsPath(*relative_parts)
266+
267+
if (
268+
"\x00" in filename
269+
or "\\" in filename
270+
or archive_path.is_absolute()
271+
or windows_archive_path.is_absolute()
272+
or windows_archive_path.drive
273+
or windows_relative_path.is_absolute()
274+
or windows_relative_path.drive
275+
or any(part in {".", ".."} for part in filename.split("/"))
276+
or any(":" in part for part in relative_parts)
277+
or any(part.endswith((" ", ".")) for part in relative_parts)
278+
):
279+
raise InvalidPluginError("Plugin archive contains an unsafe path.")
280+
281+
relative_path = Path(*relative_parts)
282+
plugin_path = (plugin_root / relative_path).resolve()
283+
try:
284+
plugin_path.relative_to(plugin_root)
285+
except ValueError as exc:
286+
raise InvalidPluginError("Plugin archive contains an unsafe path.") from exc
287+
288+
extraction_plan.append((info, plugin_path))
289+
290+
plugin_root.mkdir(parents=True, exist_ok=True)
291+
for info, plugin_path in extraction_plan:
227292
if info.is_dir():
228293
plugin_path.mkdir(parents=True, exist_ok=True)
229294
else:
230295
plugin_path.parent.mkdir(parents=True, exist_ok=True)
231296
with zipf.open(info) as src, plugin_path.open("wb") as dst:
232297
shutil.copyfileobj(src, dst)
233-
234-
plugin_io.close()
298+
finally:
299+
plugin_io.close()
235300

236301
async def load_plugin(self, plugin):
237302
if not (plugin.abs_path / f"{plugin.name}.py").exists():
@@ -242,10 +307,14 @@ async def load_plugin(self, plugin):
242307
if req_txt.exists():
243308
# Install PIP requirements
244309

245-
venv = hasattr(sys, "real_prefix") or hasattr(sys, "base_prefix") # in a virtual env
246-
user_install = " --user" if not venv else ""
247-
proc = await asyncio.create_subprocess_shell(
248-
f'"{sys.executable}" -m pip install --upgrade{user_install} -r {req_txt} -q -q',
310+
venv = hasattr(sys, "real_prefix") or sys.prefix != getattr(sys, "base_prefix", sys.prefix)
311+
pip_args = [sys.executable, "-m", "pip", "install", "--upgrade"]
312+
if not venv:
313+
pip_args.append("--user")
314+
pip_args.extend(("-r", os.fspath(req_txt), "-q", "-q"))
315+
316+
proc = await asyncio.create_subprocess_exec(
317+
*pip_args,
249318
stderr=PIPE,
250319
stdout=PIPE,
251320
)
@@ -255,16 +324,20 @@ async def load_plugin(self, plugin):
255324
stdout, stderr = await proc.communicate()
256325

257326
if stdout:
258-
logger.debug("[stdout]\n%s.", stdout.decode())
327+
logger.debug("[stdout]\n%s.", stdout.decode(errors="replace"))
259328

260329
if stderr:
261-
logger.debug("[stderr]\n%s.", stderr.decode())
330+
logger.debug("[stderr]\n%s.", stderr.decode(errors="replace"))
331+
332+
if proc.returncode:
333+
error_message = (stderr or stdout).decode(errors="replace")
334+
if not error_message:
335+
error_message = f"pip exited with status {proc.returncode}"
262336
logger.error(
263337
"Failed to download requirements for %s.",
264338
plugin.ext_string,
265-
exc_info=True,
266339
)
267-
raise InvalidPluginError(f"Unable to download requirements: ```\n{stderr.decode()}\n```")
340+
raise InvalidPluginError(f"Unable to download requirements: ```\n{error_message}\n```")
268341

269342
if os.path.exists(USER_SITE):
270343
sys.path.insert(0, USER_SITE)

0 commit comments

Comments
 (0)