22import itertools
33import os
44import pathlib
5+ import re
56from io import IOBase
67
78import aiohttp_apispec
@@ -47,7 +48,7 @@ async def get_payloads(self, request: web.Request):
4748 if add_path
4849 else self .file_svc .remove_xored_extension (p .name )
4950 for p in itertools .chain .from_iterable (p_dir .glob ('[!.]*' ) for p_dir in payload_dirs )
50- if p .is_file ()
51+ if p .is_file () and not p . is_symlink () and not p . name . startswith ( '.' )
5152 }
5253
5354 payloads = list (payloads )
@@ -69,12 +70,24 @@ async def post_payloads(self, request: web.Request):
6970 # accessing the file using the prefilled request["form"] dictionary.
7071 file_field : web .FileField = request ["form" ]["file" ]
7172
72- file_name , file_path = await self .__generate_file_name_and_path (file_field )
73+ # Sanitize the file name to prevent directory traversal
74+ sanitized_filename = self .sanitize_filename (file_field .filename )
7375
74- # The file_field.file is of type IOBase: It uses blocking methods.
75- # Putting blocking code into a dedicated method and thread...
76+ # Generate the file name and path
77+ file_name , file_path = await self .__generate_file_name_and_path (sanitized_filename )
78+
79+ # Save the file to a temporary location first
80+ temp_file_path = pathlib .Path (file_path ).parent / f"temp_{ file_name } "
7681 loop : asyncio .AbstractEventLoop = asyncio .get_event_loop ()
77- await loop .run_in_executor (None , self .__save_file , file_path , file_field .file )
82+ await loop .run_in_executor (None , self .__save_file , str (temp_file_path ), file_field .file )
83+
84+ # Validate the saved file to ensure it is not a symbolic link
85+ if temp_file_path .is_symlink ():
86+ temp_file_path .unlink ()
87+ raise web .HTTPBadRequest (reason = "Uploaded file is a symbolic link and is not allowed." )
88+
89+ # Move the validated file to the final destination
90+ temp_file_path .rename (file_path )
7891
7992 body : dict [list [str ]] = {"payloads" : [file_name ]}
8093 return web .json_response (body )
@@ -90,34 +103,38 @@ async def post_payloads(self, request: web.Request):
90103 @aiohttp_apispec .match_info_schema (PayloadDeleteRequestSchema )
91104 async def delete_payloads (self , request : web .Request ):
92105 file_name : str = request .match_info .get ("name" )
93- file_path : str = os .path .join ('data/payloads/' , file_name )
94-
95- response : web .HTTPException = None
96106 try :
97- os .remove (file_path )
107+ safe_path = self .validate_and_canonicalize_path (file_name )
108+ if pathlib .Path (safe_path ).is_symlink ():
109+ raise ValueError (f"Invalid path: { file_name } is a symbolic link." )
110+ os .remove (safe_path )
98111 response = web .HTTPNoContent ()
112+ except ValueError as e :
113+ response = web .HTTPNotFound (reason = str (e ))
99114 except FileNotFoundError :
100115 response = web .HTTPNotFound ()
116+ except PermissionError :
117+ response = web .HTTPForbidden (reason = "Permission denied." )
101118 return response
102119
103120 @classmethod
104- async def __generate_file_name_and_path (cls , file_field : web . FileField ) -> [str , str ]:
121+ async def __generate_file_name_and_path (cls , sanitized_filename : str ) -> [str , str ]:
105122 """
106123 Finds whether an uploaded file already exists in the payload directory.
107124 In the case, generates a new file name with an incremental suffix to avoid overriding the existing one.
108125 Otherwise, the original file name is used.
109126
110- :param file_field : The upload payload object .
127+ :param sanitized_filename : The sanitized file name .
111128 :return: A tuple containing the generated file name and path for future storage.
112129 """
113- file_name_candidate : str = file_field . filename
130+ file_name_candidate : str = sanitized_filename
114131 file_path : str = os .path .join ('data/payloads/' , file_name_candidate )
115132 suffix : int = 1
116133
117134 # Generating a file suffix in the case it already exists.
118135 while os .path .exists (file_path ):
119- file_name_candidate = f"{ pathlib .Path (file_field . filename ).stem } _" \
120- f"{ suffix } { pathlib .Path (file_field . filename ).suffix } "
136+ file_name_candidate = f"{ pathlib .Path (sanitized_filename ).stem } _" \
137+ f"{ suffix } { pathlib .Path (sanitized_filename ).suffix } "
121138 file_path = os .path .join ('data/payloads/' , file_name_candidate )
122139 suffix += 1
123140 file_name : str = file_name_candidate
@@ -142,3 +159,35 @@ def __save_file(target_file_path: str, io_base_src: IOBase):
142159 buffered_io_base_dest .write (chunk )
143160 else :
144161 read_chunk = False
162+
163+ @staticmethod
164+ def validate_and_canonicalize_path (input_path : str , base_directory : str = "data/payloads/" ) -> str :
165+ """
166+ Validates and canonicalizes a file path to ensure it is within the designated directory.
167+
168+ :param input_path: The input file path to validate.
169+ :param base_directory: The base directory to constrain paths to.
170+ :return: The canonicalized absolute path if valid.
171+ :raises ValueError: If the path resolves outside the base directory.
172+ """
173+ base_dir = pathlib .Path (base_directory ).resolve ()
174+
175+ try :
176+ resolved_path = (base_dir / pathlib .Path (input_path ).name ).resolve ()
177+ resolved_path .relative_to (base_dir )
178+ except ValueError :
179+ raise ValueError (f"Invalid path: { input_path } resolves outside the designated directory { base_directory } " )
180+ except Exception as e :
181+ raise ValueError (f"Invalid path: { input_path } . Error: { e } " )
182+
183+ return str (resolved_path )
184+
185+ @staticmethod
186+ def sanitize_filename (filename : str ) -> str :
187+ """
188+ Sanitizes a file name to remove potentially dangerous characters.
189+
190+ :param filename: The original file name.
191+ :return: A sanitized file name.
192+ """
193+ return re .sub (r'[^\w\.-]' , '_' , filename )
0 commit comments