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_<uuid>.m3u, id_playlist must be a UUID, and paths go through
safe_join.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ubuntu
2026-10-15 11:54:14 +00:00
co-authored by Claude Opus 5.5
parent 03ec3f0c68
commit 16e979fe77
2 changed files with 38 additions and 8 deletions
+37 -8
View File
@@ -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)
+1
View File
@@ -3003,6 +3003,7 @@
$('#add_playlist_alert').html('<div class="alert alert-success" role="alert">Upload successful!</div>');
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,