v1.2.19: fixes from Codex audit — XSS in params, JS injection in onclick, scenario polling timeout, has_target validation, tracker atomic update

This commit is contained in:
2026-07-31 17:58:57 +04:00
parent e0e16b91a8
commit 58b823a260
4 changed files with 88 additions and 31 deletions
+1 -1
View File
@@ -32,7 +32,7 @@ from routes.api_scenario_defs import bp_defs as api_scenario_defs_bp
# Версия — показывается в топбаре UI. Меняется при КАЖДОМ изменении кода. # Версия — показывается в топбаре UI. Меняется при КАЖДОМ изменении кода.
# Нужна для фильтрации истории (пользователь видит только записи своей версии). # Нужна для фильтрации истории (пользователь видит только записи своей версии).
VERSION = "1.2.18" VERSION = "1.2.19"
# Flask-приложение с Jinja2-шаблонами из папки templates/ # Flask-приложение с Jinja2-шаблонами из папки templates/
app = Flask(__name__, template_folder="templates", static_folder="static") app = Flask(__name__, template_folder="templates", static_folder="static")
+49 -17
View File
@@ -113,36 +113,68 @@ def _locked_write(path, data):
os.close(fd) os.close(fd)
def add(client_id, stand, instance_uid, svc_id, display_name): def _atomic_update(path, mutator):
"""Добавить инстанс в трекер (вызывается из executor сразу после create). """Атомарно: открыть файл → заблокировать → прочитать → мутировать → записать.
Читает текущий файл → добавляет/обновляет запись → пишет обратно. Вся операция под ОДНИМ lock, в отличие от старого подхода
Если инстанс уже есть — перезаписывает (идемпотентность). _locked_read() + _locked_write() где между ними lock снимался.
Это предотвращает lost-update при конкурентных add/remove.
Args: Args:
client_id: str — ClientID из JWT path: str — путь к файлу
stand: str — "dev"/"test" mutator: callable(data) — функция, мутирующая dict (возвращать не нужно)
instance_uid: str — UUID созданного инстанса
svc_id: int — ID сервиса Returns:
display_name: str — displayName инстанса""" dict — результат после мутации (или {} если не смогли)"""
p = _path(client_id, stand) try:
data = _locked_read(p) fd = os.open(path, os.O_RDWR | os.O_CREAT, 0o644)
except OSError:
return {}
try:
if not _acquire_lock(fd):
return {}
# Читаем
data = {}
try:
raw = os.read(fd, 65536)
if raw:
data = json.loads(raw.decode("utf-8"))
except (json.JSONDecodeError, UnicodeDecodeError):
pass
# Мутируем
mutator(data)
# Пишем
os.lseek(fd, 0, 0)
os.ftruncate(fd, 0)
os.write(fd, json.dumps(data, indent=2).encode("utf-8"))
return data
finally:
fcntl.flock(fd, fcntl.LOCK_UN)
os.close(fd)
def add(client_id, stand, instance_uid, svc_id, display_name):
"""Добавить инстанс в трекер (атомарно, под одним lock).
Читает → добавляет/обновляет запись → пишет — всё под эксклюзивной блокировкой.
Если инстанс уже есть — перезаписывает (идемпотентность)."""
def _add(data):
data[instance_uid] = { data[instance_uid] = {
"svcId": svc_id, "svcId": svc_id,
"displayName": display_name, "displayName": display_name,
"instanceUid": instance_uid, "instanceUid": instance_uid,
} }
_locked_write(p, data) _atomic_update(_path(client_id, stand), _add)
def remove(client_id, stand, instance_uid): def remove(client_id, stand, instance_uid):
"""Удалить инстанс из трекера (после успешного delete). """Удалить инстанс из трекера (атомарно, под одним lock).
pop с default=None — не падает если инстанса уже нет (уже удалён ранее).""" pop с default=None — не падает если инстанса уже нет."""
p = _path(client_id, stand) def _rem(data):
data = _locked_read(p)
data.pop(instance_uid, None) data.pop(instance_uid, None)
_locked_write(p, data) _atomic_update(_path(client_id, stand), _rem)
def list_all(client_id, stand): def list_all(client_id, stand):
+4 -2
View File
@@ -55,8 +55,10 @@ def _validate_steps(steps):
# Для не-create: должен быть instance_ref, instance_uid, или старый формат (fallback) # Для не-create: должен быть instance_ref, instance_uid, или старый формат (fallback)
if op != "create": if op != "create":
has_target = bool(ref or uid or (not out)) # out в не-create — ошибка выше if not ref and not uid:
# ok: ref есть, uid есть, или старый формат без новых ключей (fallback) # Старый формат (без instance_ref/instance_uid) — разрешаем,
# будет найден по service_id через instance_map в scenario.py
pass
return None return None
+28 -5
View File
@@ -116,16 +116,21 @@ async function toggleScenarioSteps(defId) {
if (s.instance_ref) html += ` <span style="color:var(--muted);">→ ${_esc(s.instance_ref)}</span>`; if (s.instance_ref) html += ` <span style="color:var(--muted);">→ ${_esc(s.instance_ref)}</span>`;
if (s.instance_uid) html += ` <span style="color:var(--muted);">→ ${_esc(s.instance_uid.substring(0,8))}...</span>`; if (s.instance_uid) html += ` <span style="color:var(--muted);">→ ${_esc(s.instance_uid.substring(0,8))}...</span>`;
const params = Object.entries(s.params || {}); const params = Object.entries(s.params || {});
if (params.length) html += ' <span style="color:var(--muted);">(' + params.map(([k,v]) => k+'='+v).join(', ') + ')</span>'; if (params.length) html += ' <span style="color:var(--muted);">(' + params.map(([k,v]) => _esc(k)+'='+_esc(v)).join(', ') + ')</span>';
html += '</div>'; html += '</div>';
}); });
// Кнопки действий // Кнопки действий
// JS-escape для имени сценария в inline onclick:
// \ → \\ (backslash)
// ' → \' (terminate JS string literal)
// HTML-escape уже не нужен — внутри JS-строки в атрибуте HTML-теги не парсятся.
const escName = def.name.replace(/\\/g,'\\\\').replace(/'/g,"\\'");
html += `<button class="btn btn-sm" style="margin-top:4px;font-size:11px;background:var(--brand-primary);color:#fff;" onclick="event.stopPropagation();runScenario(${defId})">▶ Запустить</button>`; html += `<button class="btn btn-sm" style="margin-top:4px;font-size:11px;background:var(--brand-primary);color:#fff;" onclick="event.stopPropagation();runScenario(${defId})">▶ Запустить</button>`;
html += `<button class="btn btn-sm" style="margin-top:4px;margin-left:4px;font-size:11px;" onclick="event.stopPropagation();editScenario(${defId})">✏ Редактировать</button>`; html += `<button class="btn btn-sm" style="margin-top:4px;margin-left:4px;font-size:11px;" onclick="event.stopPropagation();editScenario(${defId})">✏ Редактировать</button>`;
html += `<button class="btn btn-sm" style="margin-top:4px;margin-left:4px;font-size:11px;" onclick="event.stopPropagation();cloneScenario(${defId},'${_esc(def.name)}')">📋 Копировать</button>`; html += `<button class="btn btn-sm" style="margin-top:4px;margin-left:4px;font-size:11px;" onclick="event.stopPropagation();cloneScenario(${defId},'${escName}')">📋 Копировать</button>`;
// Удалить — только для НЕ-seed сценариев // Удалить — только для НЕ-seed сценариев
if (!def.is_seed) html += `<button class="btn btn-sm" style="margin-top:4px;margin-left:4px;font-size:11px;color:var(--destructive);" onclick="event.stopPropagation();deleteScenario(${defId},'${_esc(def.name)}')">🗑 Удалить</button>`; if (!def.is_seed) html += `<button class="btn btn-sm" style="margin-top:4px;margin-left:4px;font-size:11px;color:var(--destructive);" onclick="event.stopPropagation();deleteScenario(${defId},'${escName}')">🗑 Удалить</button>`;
html += '</div>'; html += '</div>';
el.innerHTML = html; el.innerHTML = html;
@@ -182,10 +187,21 @@ async function runScenario(defId){
} }
// Поллинг статуса // Поллинг статуса
// Поллинг статуса с защитой от бесконечного зависания
let _scenarioPollErrors = 0;
scenarioPollTimer=setInterval(async()=>{ scenarioPollTimer=setInterval(async()=>{
try{ try{
const sr=await fetch('/api/scenario/run/'+runId); const sr=await fetch('/api/scenario/run/'+runId);
if(!sr.ok){ return; } if(!sr.ok){
_scenarioPollErrors++;
if(_scenarioPollErrors>=5){
stopScenarioPoll();
busy=false;
body.innerHTML='<span style="color:var(--destructive);">Ошибка: сервер недоступен</span>';
}
return;
}
_scenarioPollErrors = 0; // сброс при успехе
const last=await sr.json(); const last=await sr.json();
if(!last||last.error) return; if(!last||last.error) return;
@@ -203,7 +219,14 @@ async function runScenario(defId){
await refreshInstances(); // обновить список инстансов await refreshInstances(); // обновить список инстансов
if(historyOpen) await loadHistory(); // обновить историю if(historyOpen) await loadHistory(); // обновить историю
} }
}catch(e){} }catch(e){
_scenarioPollErrors++;
if(_scenarioPollErrors>=5){
stopScenarioPoll();
busy=false;
body.innerHTML=`<span style="color:var(--destructive);">Ошибка поллинга: ${_esc(e.message||'')}</span>`;
}
}
},3000); },3000);
}catch(e){ }catch(e){
body.innerHTML=`<span style="color:var(--destructive);">Ошибка: ${_esc(e.message)}</span>`; body.innerHTML=`<span style="color:var(--destructive);">Ошибка: ${_esc(e.message)}</span>`;