From 16e979fe7758e7b527dffb0c364f2485ca40f25e Mon Sep 17 00:00:00 2001 From: ubuntu Date: Thu, 15 Oct 2026 11:54:14 +0000 Subject: [PATCH] Fix arbitrary file write in /upload and /analyse_m3u_file /upload stored files under the client-supplied filename (allowing .php webshells and ../ traversal) and analyse_m3u_file renamed files from unvalidated filename/id_playlist. Uploads are now always stored as upload_.m3u, id_playlist must be a UUID, and paths go through safe_join. Co-Authored-By: Claude Opus 5.5 --- server.py | 45 +++++++++++++++++++++++++++++++++++++-------- static/index.html | 1 + 2 files changed, 38 insertions(+), 8 deletions(-) diff --git a/server.py b/server.py index ecafa29..91a096f 100644 --- a/server.py +++ b/server.py @@ -104,6 +104,18 @@ def safe_join(base_dir, user_filename): return candidate +UPLOADED_PLAYLIST_RE = re.compile(r'^upload_[0-9a-f]{32}\.m3u$') + + +def validate_id_playlist(value): + """Playlist ids are UUIDs; anything else is rejected (they end up in file paths + and Mongo collection names).""" + try: + return str(uuid.UUID(str(value))) + except (ValueError, TypeError, AttributeError): + raise ValueError('invalid id_playlist') + + def findInfo(tag, s): end = '"' if (s.find(tag)>-1): @@ -451,8 +463,16 @@ async def analyse_m3u_file(request): playlist_comment = data.get('comment', '').strip() - new_path_filename = "./" + UPLOAD_PLAYLIST_DIR + '/' + id_playlist + '.m3u' - old_path_filename = "./" + UPLOAD_PLAYLIST_DIR + '/' +filename + try: + id_playlist = validate_id_playlist(id_playlist) + if not UPLOADED_PLAYLIST_RE.match(filename or ''): + raise ValueError('invalid filename') + new_path_filename = safe_join(UPLOAD_PLAYLIST_DIR, id_playlist + '.m3u') + old_path_filename = safe_join(UPLOAD_PLAYLIST_DIR, filename) + except ValueError as exc: + return web.json_response({'error': str(exc)}, status=400) + if not os.path.isfile(old_path_filename): + return web.json_response({'error': 'uploaded file not found'}, status=400) os.rename(old_path_filename,new_path_filename) collection_channels = get_playlist_collection(id_playlist) @@ -721,27 +741,36 @@ async def handle_upload(request): # Handle both form field and file in one pass async for part in reader: if part.name == "id_playlist_to_action": - id_playlist_data = await part.text() + id_playlist_data = (await part.text()).strip() if len(id_playlist_data) == 0: print('No playlist to update', flush=True) id_playlist = str(uuid.uuid4()) else: - id_playlist = id_playlist_data + try: + id_playlist = validate_id_playlist(id_playlist_data) + except ValueError: + if filepath and os.path.exists(filepath): + os.remove(filepath) + return web.Response(text="Invalid id_playlist.", status=400) print('playlist to update :', id_playlist, flush=True) elif part.name == "file": - filename = part.filename - if not filename: + if not part.filename: return web.Response(text="No file provided.", status=400) - filepath = os.path.join(UPLOAD_PLAYLIST_DIR, filename) + # Never trust the client-supplied filename: always store under a + # server-generated .m3u name inside the playlists directory. + filename = f"upload_{uuid.uuid4().hex}.m3u" + filepath = safe_join(UPLOAD_PLAYLIST_DIR, filename) with open(filepath, "wb") as f: while chunk := await part.read_chunk(): f.write(chunk) if filepath: - return web.json_response({'status': 'ok', 'id_playlist': id_playlist}) + if id_playlist is None: + id_playlist = str(uuid.uuid4()) + return web.json_response({'status': 'ok', 'id_playlist': id_playlist, 'filename': filename}) return web.Response(text="No file field found in the request.", status=400) diff --git a/static/index.html b/static/index.html index 3afe9f1..8361443 100644 --- a/static/index.html +++ b/static/index.html @@ -3003,6 +3003,7 @@ $('#add_playlist_alert').html(''); var response = JSON.parse(xhr.response); var id_playlist = response['id_playlist']; + $("#fileName").val(response['filename']); if (response.status == 'ok') { $.post('analyse_m3u_file', { 'id_playlist': id_playlist,