88import zipfile
99from difflib import get_close_matches
1010from 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
1313from site import USER_SITE
1414from subprocess import PIPE
1515
2424
2525logger = getLogger (__name__ )
2626
27+ PLUGIN_COMPONENT_PATTERN = r"[A-Za-z0-9._-]+"
28+
2729
2830class InvalidPluginError (commands .BadArgument ):
2931 pass
@@ -32,6 +34,7 @@ class InvalidPluginError(commands.BadArgument):
3234class 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