Skip to content

Commit 8312732

Browse files
authored
Merge pull request #3186 from RachHavoc/rcm/cwe-22-fix
Fix CWE 22 in Payload API
2 parents b24f6e7 + 14c3562 commit 8312732

File tree

1 file changed

+63
-14
lines changed

1 file changed

+63
-14
lines changed

‎app/api/v2/handlers/payload_api.py‎

Lines changed: 63 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import itertools
33
import os
44
import pathlib
5+
import re
56
from io import IOBase
67

78
import 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

Comments
 (0)