diff --git a/API/Classes/Base/security.py b/API/Classes/Base/security.py new file mode 100644 index 000000000..12a511504 --- /dev/null +++ b/API/Classes/Base/security.py @@ -0,0 +1,35 @@ +from pathlib import Path +from zipfile import ZipFile + +class Security: + @staticmethod + def safeCasePath(baseDir, casename): + """Resolve casename under baseDir and ensure it stays within baseDir""" + base = Path(baseDir).resolve() + target = (base / casename).resolve() + + if not str(target).startswith(str(base) + "\\") and target != base: + if not str(target).startswith(str(base) + "/") and target != base: + raise ValueError( + "Path traversal detected: '" + casename + "' escapes base directory" + ) + + return target + + @staticmethod + def safeExtractall(zf, targetDir): + """Extract ZIP only after verifying no entry escapes targetDir""" + target = Path(targetDir).resolve() + + for member in zf.infolist(): + memberPath = (target / member.filename).resolve() + + if not str(memberPath).startswith(str(target) + "\\") and \ + not str(memberPath).startswith(str(target) + "/") and \ + memberPath != target: + raise ValueError( + "ZIP slip detected: '" + member.filename + "' would escape target directory" + ) + + #all entries validated, safe to extract + zf.extractall(str(targetDir)) diff --git a/API/Routes/Case/CaseRoute.py b/API/Routes/Case/CaseRoute.py index 51e0ec29c..4c4090370 100644 --- a/API/Routes/Case/CaseRoute.py +++ b/API/Routes/Case/CaseRoute.py @@ -5,6 +5,7 @@ import pandas as pd from Classes.Base import Config from Classes.Base.FileClass import File +from Classes.Base.security import Security from Classes.Case.CaseClass import Case from Classes.Case.UpdateCaseClass import UpdateCase from Classes.Case.ImportTemplate import ImportTemplate @@ -43,6 +44,10 @@ def getResultCSV(): try: casename = request.json['casename'] caserunname = request.json['caserunname'] + try: + Security.safeCasePath(Config.DATA_STORAGE, casename) + except ValueError as e: + return jsonify({'message': str(e), 'status_code': 'error'}), 400 csvFolder = Path(Config.DATA_STORAGE,casename,"res", caserunname, "csv") if os.path.isdir(csvFolder): csvs = [ f.name for f in os.scandir(csvFolder) ] @@ -56,6 +61,10 @@ def getResultCSV(): def getDesc(): try: casename = request.json['casename'] + try: + Security.safeCasePath(Config.DATA_STORAGE, casename) + except ValueError as e: + return jsonify({'message': str(e), 'status_code': 'error'}), 400 genDataPath = Path(Config.DATA_STORAGE,casename,"genData.json") genData = File.readFile(genDataPath) response = { @@ -70,6 +79,10 @@ def getDesc(): def copy(): try: case = request.json['casename'] + try: + Security.safeCasePath(Config.DATA_STORAGE, case) + except ValueError as e: + return jsonify({'message': str(e), 'status_code': 'error'}), 400 case_copy = case + '_copy' casePath = Path(Config.DATA_STORAGE, case_copy, 'genData.json') @@ -101,6 +114,10 @@ def copy(): def deleteCase(): try: case = request.json['casename'] + try: + Security.safeCasePath(Config.DATA_STORAGE, case) + except ValueError as e: + return jsonify({'message': str(e), 'status_code': 'error'}), 400 casePath = Path(Config.DATA_STORAGE, case) shutil.rmtree(casePath) @@ -128,6 +145,10 @@ def getResultData(): casename = request.json['casename'] dataJson = request.json['dataJson'] if casename != None: + try: + Security.safeCasePath(Config.DATA_STORAGE, casename) + except ValueError as e: + return jsonify({'message': str(e), 'status_code': 'error'}), 400 dataPath = Path(Config.DATA_STORAGE,casename,'view',dataJson) data = File.readFile(dataPath) response = data @@ -142,6 +163,10 @@ def getResultData(): def getParamFile(): try: dataJson = request.json['dataJson'] + try: + Security.safeCasePath(Config.DATA_STORAGE, dataJson) + except ValueError as e: + return jsonify({'message': str(e), 'status_code': 'error'}), 400 configPath = Path(Config.DATA_STORAGE, dataJson) ConfigFile = File.readParamFile(configPath) response = ConfigFile @@ -154,6 +179,10 @@ def resultsExists(): try: casename = request.json['casename'] if casename != None: + try: + Security.safeCasePath(Config.DATA_STORAGE, casename) + except ValueError as e: + return jsonify({'message': str(e), 'status_code': 'error'}), 400 resPath = Path(Config.DATA_STORAGE, casename, 'view', 'RYT.json') dataPath = Path(Config.DATA_STORAGE,casename,'view','resData.json') data = File.readFile(dataPath) @@ -196,6 +225,10 @@ def saveScOrder(): try: data = request.json['data'] case = request.json['casename'] + try: + Security.safeCasePath(Config.DATA_STORAGE, case) + except ValueError as e: + return jsonify({'message': str(e), 'status_code': 'error'}), 400 genDataPath = Path(Config.DATA_STORAGE, case, 'genData.json') genData = File.readFile(genDataPath) genData['osy-scenarios'] = data @@ -235,6 +268,10 @@ def saveCase(): try: genData = request.json['data'] casename = genData['osy-casename'] + try: + Security.safeCasePath(Config.DATA_STORAGE, casename) + except ValueError as e: + return jsonify({'message': str(e), 'status_code': 'error'}), 400 case = session.get('osycase', None) configPath = Path(Config.DATA_STORAGE, 'Variables.json') @@ -390,6 +427,10 @@ def saveCase(): def prepareCSV(): try: casename = request.json['casename'] + try: + Security.safeCasePath(Config.DATA_STORAGE, casename) + except ValueError as e: + return jsonify({'message': str(e), 'status_code': 'error'}), 400 jsonData = request.json['jsonData'] Pd = pd.DataFrame(jsonData) diff --git a/API/Routes/Upload/UploadRoute.py b/API/Routes/Upload/UploadRoute.py index 88dde7d6a..13bcee1d6 100644 --- a/API/Routes/Upload/UploadRoute.py +++ b/API/Routes/Upload/UploadRoute.py @@ -9,6 +9,7 @@ from Classes.Base import Config from Classes.Base.FileClass import File +from Classes.Base.security import Security upload_api = Blueprint('UploadRoute', __name__) @@ -208,6 +209,11 @@ def backupCase(): #case = request.json['casename'] case = request.args.get('case') + try: + Security.safeCasePath(Config.DATA_STORAGE, case) + except ValueError as e: + return jsonify({'message': str(e), 'status_code': 'error'}), 400 + casePath = Path('WebAPP', 'DataStorage',case) zippedFile = Path('WebAPP', 'DataStorage', case+'.zip') @@ -278,7 +284,7 @@ def uploadCaseUnchunked_old(): name = data.get('osy-version', None) if name == '1.0' or name == '2.0': - zf.extractall(os.path.join(Config.EXTRACT_FOLDER)) + Security.safeExtractall(zf, os.path.join(Config.EXTRACT_FOLDER)) #add res view folders with json default files configPath = Path(Config.DATA_STORAGE, 'Variables.json') @@ -325,7 +331,7 @@ def uploadCaseUnchunked_old(): elif name == '3.0': #potrebno dodati tech groups #case = data.get('osy-casename', None) - zf.extractall(os.path.join(Config.EXTRACT_FOLDER)) + Security.safeExtractall(zf, os.path.join(Config.EXTRACT_FOLDER)) genDataPath = Path(Config.DATA_STORAGE, casename, 'genData.json') genData = File.readParamFile(genDataPath) genData["osy-techGroups"] = [] @@ -344,7 +350,7 @@ def uploadCaseUnchunked_old(): "casename": casename }) elif name == '4.0' or name == '4.5' or name == '4.9': - zf.extractall(os.path.join(Config.EXTRACT_FOLDER)) + Security.safeExtractall(zf, os.path.join(Config.EXTRACT_FOLDER)) # potrebno updatevoati YearSplit u verziji 5.0 su dinamicki #update for dynamic timeslicec updateTimeslices(casename) @@ -360,7 +366,7 @@ def uploadCaseUnchunked_old(): }) # elif name == '4.9': - # zf.extractall(os.path.join(Config.EXTRACT_FOLDER)) + # Security.safeExtractall(zf, os.path.join(Config.EXTRACT_FOLDER)) # # potrebno updatevoati YearSplit u verziji 5.0 su dinamicki # #update for dynamic timeslicec # updateTimeslices(casename) @@ -372,7 +378,7 @@ def uploadCaseUnchunked_old(): # }) elif name == '5.0': - zf.extractall(os.path.join(Config.EXTRACT_FOLDER)) + Security.safeExtractall(zf, os.path.join(Config.EXTRACT_FOLDER)) updateViewDefintions(casename) msg.append({ "message": "Model " + casename +" have been uploaded!", @@ -455,7 +461,7 @@ def handle_full_zip(file, filepath=None): # TVOJA ORIGINALNA LOGIKA # --------------------------- if name == '1.0' or name == '2.0': - zf.extractall(os.path.join(Config.EXTRACT_FOLDER)) + Security.safeExtractall(zf, os.path.join(Config.EXTRACT_FOLDER)) configPath = Path(Config.DATA_STORAGE, 'Variables.json') vars = File.readParamFile(configPath) viewDef = {} @@ -484,7 +490,7 @@ def handle_full_zip(file, filepath=None): "casename": casename }) elif name == '3.0': - zf.extractall(os.path.join(Config.EXTRACT_FOLDER)) + Security.safeExtractall(zf, os.path.join(Config.EXTRACT_FOLDER)) genDataPath = Path(Config.DATA_STORAGE, casename, 'genData.json') genData = File.readParamFile(genDataPath) genData["osy-techGroups"] = [] @@ -500,7 +506,7 @@ def handle_full_zip(file, filepath=None): "casename": casename }) elif name in ['4.0', '4.5', '4.9']: - zf.extractall(os.path.join(Config.EXTRACT_FOLDER)) + Security.safeExtractall(zf, os.path.join(Config.EXTRACT_FOLDER)) updateTimeslices(casename) updateStorageSet(casename) updateViewDefintions(casename) @@ -511,7 +517,7 @@ def handle_full_zip(file, filepath=None): "casename": casename }) elif name == '5.0': - zf.extractall(os.path.join(Config.EXTRACT_FOLDER)) + Security.safeExtractall(zf, os.path.join(Config.EXTRACT_FOLDER)) updateViewDefintions(casename) msg.append({ "message": "Model " + casename +" have been uploaded!", diff --git a/API/app.py b/API/app.py index f2fc8c476..9e21d85c2 100644 --- a/API/app.py +++ b/API/app.py @@ -43,7 +43,7 @@ app.permanent_session_lifetime = timedelta(days=5) app.config['SECRET_KEY'] = '12345' -app.config["MAX_CONTENT_LENGTH"] = None +app.config["MAX_CONTENT_LENGTH"] = 500 * 1024 * 1024 # 500 MB app.register_blueprint(upload_api) app.register_blueprint(case_api)