fix: audit — json try/except, ZIP-bomb drhider, classify reset only processing
Deploy contracts-flask / validate (push) Successful in 0s

This commit is contained in:
2026-06-30 11:25:29 +04:00
parent 8deebc7ac4
commit d17baa767c
6 changed files with 115 additions and 21 deletions
+33 -12
View File
@@ -218,8 +218,7 @@ class Handler(BaseHTTPRequestHandler):
def _handle_api_sync(self):
"""Удалить ВСЕ документы, НЕ входящие в keep_ids. БД = зеркало таблицы."""
from db.connection import execute, query
length = int(self.headers.get("Content-Length", 0))
body = json.loads(self.rfile.read(length)) if length > 0 else {}
body = self._read_json_body()
keep_ids = set(body.get("keep_ids", []))
# Prevent DoS: too many IDs
if len(keep_ids) > 1000:
@@ -283,8 +282,9 @@ class Handler(BaseHTTPRequestHandler):
Отдельный процесс — свои коннекты, своя память.
HTTP-сервер продолжает отвечать на batch-progress.
"""
length = int(self.headers.get("Content-Length", 0))
body = json.loads(self.rfile.read(length))
body = self._read_json_body()
if not body:
return
batch_id = body.get("batch_id")
if not batch_id:
self._json({"ok": False, "error": "batch_id required"}, 400)
@@ -326,8 +326,9 @@ class Handler(BaseHTTPRequestHandler):
# ── POST /apply-groups ───────────────────────────────────────────────
def _handle_apply_groups(self):
length = int(self.headers.get("Content-Length", 0))
body = json.loads(self.rfile.read(length))
body = self._read_json_body()
if not body:
return
batch_id = body.get("batch_id")
groups = body.get("groups", [])
if not batch_id:
@@ -392,8 +393,9 @@ class Handler(BaseHTTPRequestHandler):
def _handle_api_prompts_save(self):
from db import prompts as db_p
length = int(self.headers.get("Content-Length", 0))
body = json.loads(self.rfile.read(length))
body = self._read_json_body()
if not body:
return
role = body.get("role", "")
name = body.get("name", "v" + __import__("datetime").datetime.now().isoformat()[:16])
prompt_body = body.get("body", "")
@@ -407,8 +409,9 @@ class Handler(BaseHTTPRequestHandler):
def _handle_api_prompts_activate(self):
from db import prompts as db_p
length = int(self.headers.get("Content-Length", 0))
body = json.loads(self.rfile.read(length))
body = self._read_json_body()
if not body:
return
pid = body.get("id", "")
if not pid:
self._json({"ok": False, "error": "id required"}, 400)
@@ -418,8 +421,9 @@ class Handler(BaseHTTPRequestHandler):
def _handle_api_prompts_delete(self):
from db import prompts as db_p
length = int(self.headers.get("Content-Length", 0))
body = json.loads(self.rfile.read(length))
body = self._read_json_body()
if not body:
return
pid = body.get("id", "")
if not pid:
self._json({"ok": False, "error": "id required"}, 400)
@@ -532,6 +536,23 @@ class Handler(BaseHTTPRequestHandler):
# ── Helpers ───────────────────────────────────────────────────────────
def _read_json_body(self):
"""Безопасное чтение JSON из тела POST-запроса.
Возвращает dict или None (если ошибка — уже отправлен 400)."""
try:
length = int(self.headers.get("Content-Length", 0))
except (ValueError, TypeError):
self._json({"ok": False, "error": "invalid Content-Length"}, 400)
return None
if length == 0:
return {}
try:
raw = self.rfile.read(length)
return json.loads(raw)
except (json.JSONDecodeError, Exception) as e:
self._json({"ok": False, "error": f"invalid JSON: {e}"}, 400)
return None
def _json(self, data, status=200):
self.send_response(status)
self._send_cors()
+3 -2
View File
@@ -79,9 +79,10 @@ def list_pending(batch_id):
def reset_classify_status(batch_id):
"""Сбросить classify_status на 'pending' для всех документов батча (включая 'processing' crash recovery)."""
"""Сбросить classify_status на 'pending' только для 'processing' (crash recovery).
Уже классифицированные ('classified', 'garbage', 'failed') НЕ трогаем."""
return execute(
"UPDATE documents SET classify_status='pending', error_message=NULL WHERE batch_id=%s",
"UPDATE documents SET classify_status='pending', error_message=NULL WHERE batch_id=%s AND classify_status='processing'",
(batch_id,),
)
+15 -7
View File
@@ -251,25 +251,29 @@ class TwoPassObfuscator:
self._sorted_keys.clear()
def _expand_zips(self, files: List[Tuple[str, bytes, str]]) -> List[Tuple[str, bytes, str]]:
"""Распаковать ZIP-файлы, заменив их содержимым. Остальные файлы — как есть."""
"""Распаковать ZIP-файлы, заменив их содержимым. Остальные файлы — как есть.
Защита от ZIP-бомб: ratio + накопительный размер (как в compare/unzip.py)."""
result = []
for fname, content, ctype in files:
if fname.lower().endswith('.zip'):
try:
with zipfile.ZipFile(io.BytesIO(content)) as zf:
total_size = sum(info.file_size for info in zf.infolist())
if total_size > 500 * 1024 * 1024: # 500 MB
log.warning("ZIP too large, skipping expansion: %s", fname)
result.append((fname, content, ctype))
continue
if len(zf.infolist()) > 500:
log.warning("ZIP too many files, skipping: %s", fname)
result.append((fname, content, ctype))
continue
total_uncompressed = 0
for info in zf.infolist():
if info.is_dir():
continue
# cp437 → utf8 (как в services/unzip.py)
# ZIP bomb: ratio check
if info.compress_size > 0:
ratio = info.file_size / info.compress_size
if ratio > 100:
log.warning("ZIP bomb ratio %.0f:1, skipping: %s", ratio, fname)
result.append((fname, content, ctype))
break
# cp437 → utf8 (как в compare/unzip.py)
name = info.filename
try:
name = name.encode("cp437").decode("utf-8", errors="replace")
@@ -280,6 +284,10 @@ class TwoPassObfuscator:
if not name or name.endswith("/") or ".." in name or "/" in name or "\\" in name:
continue
inner_data = zf.read(info)
total_uncompressed += len(inner_data)
if total_uncompressed > 500 * 1024 * 1024: # 500 MB
log.warning("ZIP uncompressed limit exceeded, stopping: %s", fname)
break
result.append((name, inner_data, ""))
except Exception as e:
log.warning("Failed to expand ZIP %s: %s", fname, e)