@@ -135,7 +135,7 @@ def logout():
135135
136136 @action ("tickets/search" )
137137 @action .uses (Logged (session ), "dbadmin.html" )
138- def dbadmin ():
138+ def tickets_search ():
139139 db = error_logger .database_logger .db
140140
141141 def make_grid ():
@@ -164,7 +164,13 @@ def make_grid():
164164 def dbadmin (app_name , db_name , table_name ):
165165 themes = get_available_themes ()
166166 module = Reloader .MODULES .get (app_name )
167- db = getattr (module , db_name )
167+ if module is None :
168+ raise HTTP (404 )
169+ db = getattr (module , db_name , None )
170+ # Reject any module attribute that is not a DAL instance to avoid
171+ # leaking arbitrary objects exposed by the app.
172+ if not isinstance (db , DAL ) or table_name not in db .tables :
173+ raise HTTP (404 )
168174
169175 def make_grid ():
170176 make_safe (db )
@@ -205,14 +211,13 @@ def make_grid():
205211 @catch_errors
206212 def info ():
207213 vars = [{"name" : "python" , "version" : sys .version }]
208- for module in sorted (sys .modules ):
209- if not "." in module :
210- try :
211- m = __import__ (module )
212- if "__version__" in dir (m ):
213- vars .append ({"name" : module , "version" : m .__version__ })
214- except ImportError :
215- pass
214+ for name in sorted (sys .modules ):
215+ if "." in name :
216+ continue
217+ module = sys .modules .get (name )
218+ version = getattr (module , "__version__" , None )
219+ if version is not None :
220+ vars .append ({"name" : name , "version" : str (version )})
216221 return {"status" : "success" , "payload" : vars }
217222
218223 @action ("routes" )
@@ -250,8 +255,13 @@ def apps():
250255 def delete_app (name ):
251256 """delete the app"""
252257 path = os .path .join (FOLDER , name )
253- timestamp = datetime .datetime .now ().strftime ("%Y-%m-%d" )
254- archive = os .path .join (FOLDER , "%s.%s.zip" % (name , timestamp ))
258+ # Keep backups out of the apps folder so they don't show up in
259+ # listings or get re-imported by py4web.
260+ backups_dir = os .path .join (FOLDER , "_backups" )
261+ if not os .path .exists (backups_dir ):
262+ os .makedirs (backups_dir )
263+ timestamp = datetime .datetime .now ().strftime ("%Y-%m-%dT%H%M%S" )
264+ archive = os .path .join (backups_dir , "%s.%s" % (name , timestamp ))
255265 if os .path .exists (path ) and os .path .isdir (path ):
256266 # zip the folder, just in case
257267 shutil .make_archive (archive , "zip" , path )
@@ -264,12 +274,13 @@ def delete_app(name):
264274 @session_secured
265275 def new_file (name , file_name ):
266276 """creates a new file"""
267- path = os .path .join (FOLDER , name )
268- form = request .json
269- if not os .path .exists (path ):
277+ app_path = os .path .join (FOLDER , name )
278+ if not os .path .exists (app_path ):
270279 return {"status" : "success" , "payload" : "App does not exist" }
271- full_path = os .path .join (path , file_name )
272- if not full_path .startswith (path + os .sep ):
280+ # safe_join normalises ``..`` segments and refuses anything that
281+ # escapes the app directory.
282+ full_path = safe_join (app_path , file_name )
283+ if not full_path :
273284 return {"status" : "success" , "payload" : "Invalid path" }
274285 if os .path .exists (full_path ):
275286 return {"status" : "success" , "payload" : "File already exists" }
@@ -288,7 +299,9 @@ def new_file(name, file_name):
288299 @catch_errors
289300 def walk (path ):
290301 """Returns a nested folder structure as a tree"""
291- top = os .path .join (FOLDER , path )
302+ top = safe_join (FOLDER , path )
303+ if not top or not os .path .isdir (top ):
304+ return {"status" : "error" , "message" : "folder does not exist" }
292305 filter = None
293306 filter_file = os .path .join (top , PY4WEB_IGNORE )
294307 if os .path .exists (filter_file ):
@@ -311,10 +324,10 @@ def visible(root, name, filter=filter):
311324 or os .path .basename (root ) == "uploads"
312325 )
313326
314- if not os .path .exists (top ) or not os .path .isdir (top ):
315- return {"status" : "error" , "message" : "folder does not exist" }
316327 store = {}
317- for root , dirs , files in os .walk (top , topdown = False , followlinks = True ):
328+ # followlinks=False avoids cycles when an app contains symlinks
329+ # back into its own tree.
330+ for root , dirs , files in os .walk (top , topdown = False , followlinks = False ):
318331 if visible (* os .path .split (root )):
319332 store [root ] = {
320333 "dirs" : list (
@@ -350,23 +363,32 @@ def load_bytes(path):
350363 @action ("packed/<path:path>" )
351364 @session_secured
352365 def packed (path ):
353- """Packs an app"""
354- appname = path .split ("." )[- 2 ]
355- # some security
366+ """Pack an app into a downloadable zip.
367+
368+ ``path`` is expected to be ``<appname>.zip`` — robustly take the
369+ portion before the final extension as the app name.
370+ """
371+ if "/" in path :
372+ raise HTTP (400 )
373+ appname = os .path .splitext (os .path .basename (path ))[0 ]
356374 app_dir = os .path .join (FOLDER , appname )
357- if "/" in path or appname .startswith ("." ) or not os .path .exists (app_dir ):
375+ if appname .startswith ("." ) or not os .path .isdir (app_dir ):
358376 raise HTTP (400 )
377+ skip_dir_names = {"__pycache__" , ".git" , ".hg" , ".svn" , "node_modules" }
359378 store = io .BytesIO ()
360379 zip = zipfile .ZipFile (store , mode = "w" , compression = zipfile .ZIP_DEFLATED )
361- for root , dirs , files in os .walk (app_dir , topdown = False ):
362- if not root .startswith ("." ):
363- for name in files :
364- if not (
365- name .endswith ("~" ) or name .endswith (".pyc" ) or name [:1 ] in "#."
366- ):
367- filename = os .path .join (root , name )
368- short = filename [len (app_dir + os .path .sep ) :]
369- zip .write (filename , short )
380+ for root , dirs , files in os .walk (app_dir , topdown = True ):
381+ # mutate `dirs` in place so os.walk skips them entirely
382+ dirs [:] = [
383+ d for d in dirs
384+ if d not in skip_dir_names and not d .startswith ("." )
385+ ]
386+ for name in files :
387+ if name .endswith ("~" ) or name .endswith (".pyc" ) or name [:1 ] in "#." :
388+ continue
389+ filename = os .path .join (root , name )
390+ short = filename [len (app_dir + os .path .sep ):]
391+ zip .write (filename , short )
370392 zip .close ()
371393 data = store .getvalue ()
372394 response .headers ["Content-Type" ] = "application/zip"
@@ -486,8 +508,10 @@ def save(path, reload_app=True):
486508 """Saves a file"""
487509 app_name = path .split ("/" )[0 ]
488510 path = safe_join (FOLDER , path ) or abort ()
511+ body = json .load (request .body )
512+ if not isinstance (body , str ):
513+ raise HTTP (400 , body = "save expects a JSON string body" )
489514 with open (path , "wb" ) as myfile :
490- body = json .load (request .body )
491515 myfile .write (body .encode ("utf8" ))
492516 if reload_app :
493517 Reloader .import_app (app_name )
@@ -577,14 +601,22 @@ def new_app():
577601 # Below here work in progress
578602 #
579603
604+ def _project_repo (project ):
605+ """Return the absolute path to a project's git repo, or 400."""
606+ repo = safe_join (FOLDER , project )
607+ if not repo or not is_git_repo (repo ):
608+ raise HTTP (400 )
609+ return repo
610+
580611 @action ("gitlog/<project>" )
581612 @action .uses (Logged (session ), "gitlog.html" )
582613 @catch_errors
583614 def gitlog (project ):
584- if not is_git_repo (os .path .join (FOLDER , project )):
615+ repo = safe_join (FOLDER , project )
616+ if not repo or not is_git_repo (repo ):
585617 return "Project is not a GIT repo"
586- branches = get_branches (cwd = os . path . join ( FOLDER , project ) )
587- commits = get_commits (cwd = os . path . join ( FOLDER , project ) )
618+ branches = get_branches (cwd = repo )
619+ commits = get_commits (cwd = repo )
588620 return dict (
589621 status = "success" ,
590622 commits = commits ,
@@ -595,35 +627,26 @@ def gitlog(project):
595627
596628 @authenticated .callback ()
597629 def checkout (project , commit ):
598- if not is_git_repo (project ):
599- raise HTTP (400 )
600- run ("git stash" , cwd = os .path .join (FOLDER , project ))
601- run ("git checkout " + commit , cwd = os .path .join (FOLDER , project ))
630+ repo = _project_repo (project )
631+ run ("git stash" , cwd = repo )
632+ run ("git checkout " + commit , cwd = repo )
602633 Reloader .import_app (project )
603634
604635 @action ("swapbranch/<project>" , method = "POST" )
605636 @action .uses (Logged (session ))
606637 def swapbranch (project ):
607- if not is_git_repo (project ):
608- raise HTTP (400 )
609-
610- branch = (
611- request .forms .get ("branches" ) if request .forms .get ("branches" ) else "master"
612- )
638+ _project_repo (project )
639+ branch = request .forms .get ("branches" ) or "master"
613640 # swap branches then go back to gitlog so new commits load
614641 checkout (project , branch )
615642 redirect (URL ("gitlog" , project ))
616643
617644 @action ("gitshow/<project>/<commit>" )
618645 @action .uses (Logged (session ), "gitshow.html" )
619646 def gitshow (project , commit ):
620- if not is_git_repo (project ):
621- raise HTTP (400 )
622- flag = request .params .get ("showfull" )
623- opt = ""
624- if flag == "true" :
625- opt = " -U9999"
626- patch = run ("git show " + commit + opt , cwd = os .path .join (FOLDER , project ))
647+ repo = _project_repo (project )
648+ opt = " -U9999" if request .params .get ("showfull" ) == "true" else ""
649+ patch = run ("git show " + commit + opt , cwd = repo )
627650 return diff2kryten (patch )
628651
629652
0 commit comments