Skip to content

Commit 26a4dc7

Browse files
committed
GUI security/perf: safe ZIP/TAR extraction + efficient log append + finalize refactor
- PyQt6: use QTextEdit.append + cap maximum block count to prevent unbounded growth - PyQt6: factor _finalize_proc for finish/error - PyQt6: safe ZIP and TAR extraction to avoid path traversal - Tkinter: append logs efficiently and scroll to end - Tkinter: safe ZIP and TAR extraction to avoid path traversal No behavior change to command assembly; tested locally for syntax.
1 parent 568c9fa commit 26a4dc7

2 files changed

Lines changed: 83 additions & 19 deletions

File tree

GUI_pyqt6_WINFF.py

Lines changed: 47 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import tempfile
1212
import zipfile
1313
import shlex
14+
import tarfile
1415
from io import BytesIO
1516
from functools import partial
1617

@@ -144,6 +145,11 @@ def _build_ui(self):
144145
# command display
145146
layout.addWidget(QLabel('Comando FFmpeg:'))
146147
self.command_display = QTextEdit(); self.command_display.setReadOnly(True)
148+
# cap log length to avoid unbounded growth (drop oldest lines automatically)
149+
try:
150+
self.command_display.document().setMaximumBlockCount(2000)
151+
except Exception:
152+
pass
147153
layout.addWidget(self.command_display)
148154

149155
# buttons
@@ -338,28 +344,26 @@ def _on_proc_output(self):
338344
text = str(data)
339345
self._append_log(text)
340346

341-
def _on_proc_finished(self, code, status):
342-
ok = (code == 0)
347+
def _finalize_proc(self):
343348
self.convert_btn.setEnabled(True)
344349
self.cancel_btn.setEnabled(False)
345350
self._proc = None
351+
352+
def _on_proc_finished(self, code, status):
353+
ok = (code == 0)
354+
self._finalize_proc()
346355
if ok:
347356
QtWidgets.QMessageBox.information(self, 'Sucesso', 'Vídeo convertido com sucesso!')
348357
else:
349358
QtWidgets.QMessageBox.critical(self, 'Erro', f'Falha ao converter vídeo (código {code}).')
350359

351360
def _on_proc_error(self, err):
352-
self.convert_btn.setEnabled(True)
353-
self.cancel_btn.setEnabled(False)
354-
self._proc = None
361+
self._finalize_proc()
355362
QtWidgets.QMessageBox.critical(self, 'Erro', f'Erro de processo: {err}')
356363

357364
def _append_log(self, msg):
358-
prev = self.command_display.toPlainText()
359-
if prev:
360-
self.command_display.setPlainText(prev + "\n" + msg)
361-
else:
362-
self.command_display.setPlainText(msg)
365+
# Efficient append without rewriting whole buffer
366+
self.command_display.append(msg)
363367

364368
def show_about(self):
365369
QtWidgets.QMessageBox.information(self, 'About', 'Mauricio Menon (+AI)\nhttps://github.com/mauriciomenon\nPyQt6 version of the GUI')
@@ -450,7 +454,7 @@ def _extract_ffmpeg_zip(self, zip_bytes):
450454
base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), 'bin'))
451455
os.makedirs(base_dir, exist_ok=True)
452456
with zipfile.ZipFile(BytesIO(zip_bytes)) as zf:
453-
zf.extractall(base_dir)
457+
self._safe_zip_extract(zf, base_dir)
454458
# try to find ffmpeg(.exe)
455459
ffmpeg_path = None
456460
for root, dirs, files in os.walk(base_dir):
@@ -493,9 +497,8 @@ def worker():
493497
# extrair para ./bin e apontar para bin/ffmpeg
494498
base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), 'bin'))
495499
os.makedirs(base_dir, exist_ok=True)
496-
import tarfile
497500
with tarfile.open(fileobj=BytesIO(content), mode='r:xz') as tf:
498-
tf.extractall(base_dir)
501+
self._safe_tar_extract(tf, base_dir)
499502
# procurar binário
500503
ffmpeg_path = None
501504
for root, dirs, files in os.walk(base_dir):
@@ -558,6 +561,37 @@ def show_info_msg(self, msg):
558561
def show_error_msg(self, msg):
559562
QtWidgets.QMessageBox.critical(self, 'Erro', msg)
560563

564+
# ---- security helpers ----
565+
def _safe_tar_extract(self, tf: tarfile.TarFile, base_dir: str):
566+
base = os.path.realpath(base_dir)
567+
for member in tf.getmembers():
568+
member_path = os.path.realpath(os.path.join(base, member.name))
569+
if not member_path.startswith(base + os.sep) and member_path != base:
570+
raise RuntimeError(f"Entrada insegura no tar: {member.name}")
571+
tf.extractall(base)
572+
573+
def _safe_zip_extract(self, zf: zipfile.ZipFile, base_dir: str):
574+
base = os.path.realpath(base_dir)
575+
for zi in zf.infolist():
576+
name = zi.filename
577+
# reject absolute paths or drive letters
578+
if os.path.isabs(name) or (
579+
len(name) > 1 and name[1] == ':'
580+
):
581+
raise RuntimeError(f"Entrada insegura no zip (absoluta): {name}")
582+
dest = os.path.realpath(os.path.join(base, name))
583+
if not dest.startswith(base + os.sep) and dest != base:
584+
raise RuntimeError(f"Entrada insegura no zip: {name}")
585+
# after validation, extract members
586+
for zi in zf.infolist():
587+
dest = os.path.realpath(os.path.join(base, zi.filename))
588+
if zi.is_dir() or zi.filename.endswith('/'):
589+
os.makedirs(dest, exist_ok=True)
590+
continue
591+
os.makedirs(os.path.dirname(dest), exist_ok=True)
592+
with zf.open(zi, 'r') as src, open(dest, 'wb') as out:
593+
out.write(src.read())
594+
561595
if __name__ == '__main__':
562596
app = QApplication(sys.argv)
563597
w = FFmpegGuiPyQt6()

GUI_tkinter_WINFF.py

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -284,10 +284,12 @@ def update_command_display(self):
284284
self.cmd_text.insert('1.0', ' '.join(shlex.quote(a) for a in args))
285285

286286
def _append_log(self, text: str):
287-
current = self.cmd_text.get('1.0', 'end-1c')
288-
new = (current + '\n' + text) if current else text
289-
self.cmd_text.delete('1.0', 'end')
290-
self.cmd_text.insert('1.0', new)
287+
# Efficiently append to the end without rewriting everything
288+
if self.cmd_text.get('1.0', 'end-1c'):
289+
self.cmd_text.insert('end', '\n' + text)
290+
else:
291+
self.cmd_text.insert('end', text)
292+
self.cmd_text.see('end')
291293

292294
def _reader_worker(self):
293295
try:
@@ -404,7 +406,7 @@ def _extract_ffmpeg_zip(self, zip_bytes: bytes) -> str | None:
404406
base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), 'bin'))
405407
os.makedirs(base_dir, exist_ok=True)
406408
with zipfile.ZipFile(BytesIO(zip_bytes)) as zf:
407-
zf.extractall(base_dir)
409+
self._safe_zip_extract(zf, base_dir)
408410
for root, _dirs, files in os.walk(base_dir):
409411
for f in files:
410412
if f.lower() == ('ffmpeg.exe' if os.name == 'nt' else 'ffmpeg'):
@@ -445,7 +447,7 @@ def worker():
445447
base_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), 'bin'))
446448
os.makedirs(base_dir, exist_ok=True)
447449
with tarfile.open(fileobj=BytesIO(content), mode='r:xz') as tf:
448-
tf.extractall(base_dir)
450+
self._safe_tar_extract(tf, base_dir)
449451
ffpath = None
450452
for root, _d, files in os.walk(base_dir):
451453
for f in files:
@@ -482,6 +484,34 @@ def poll():
482484
threading.Thread(target=worker, daemon=True).start()
483485
self.after(200, poll)
484486

487+
def _safe_tar_extract(self, tf: tarfile.TarFile, base_dir: str):
488+
base = os.path.realpath(base_dir)
489+
for member in tf.getmembers():
490+
member_path = os.path.realpath(os.path.join(base, member.name))
491+
if not member_path.startswith(base + os.sep) and member_path != base:
492+
raise RuntimeError(f"Entrada insegura no tar: {member.name}")
493+
tf.extractall(base)
494+
495+
def _safe_zip_extract(self, zf: zipfile.ZipFile, base_dir: str):
496+
base = os.path.realpath(base_dir)
497+
for zi in zf.infolist():
498+
name = zi.filename
499+
# reject absolute paths or drive letters on Windows
500+
if os.path.isabs(name) or (len(name) > 1 and name[1] == ':'):
501+
raise RuntimeError(f"Entrada insegura no zip (absoluta): {name}")
502+
dest = os.path.realpath(os.path.join(base, name))
503+
if not dest.startswith(base + os.sep) and dest != base:
504+
raise RuntimeError(f"Entrada insegura no zip: {name}")
505+
# after validation, extract members
506+
for zi in zf.infolist():
507+
dest = os.path.realpath(os.path.join(base, zi.filename))
508+
if zi.is_dir() or zi.filename.endswith('/'):
509+
os.makedirs(dest, exist_ok=True)
510+
continue
511+
os.makedirs(os.path.dirname(dest), exist_ok=True)
512+
with zf.open(zi, 'r') as src, open(dest, 'wb') as out:
513+
out.write(src.read())
514+
485515
def install_ffmpeg_via_winget(self):
486516
if platform.system() != 'Windows':
487517
messagebox.showerror('Erro', 'Instalação via winget só está disponível no Windows.')

0 commit comments

Comments
 (0)