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:
+1
-1
@@ -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")
|
||||||
|
|||||||
+55
-23
@@ -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)
|
||||||
data[instance_uid] = {
|
except OSError:
|
||||||
"svcId": svc_id,
|
return {}
|
||||||
"displayName": display_name,
|
try:
|
||||||
"instanceUid": instance_uid,
|
if not _acquire_lock(fd):
|
||||||
}
|
return {}
|
||||||
_locked_write(p, data)
|
# Читаем
|
||||||
|
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] = {
|
||||||
|
"svcId": svc_id,
|
||||||
|
"displayName": display_name,
|
||||||
|
"instanceUid": instance_uid,
|
||||||
|
}
|
||||||
|
_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 — не падает если инстанса уже нет."""
|
||||||
|
def _rem(data):
|
||||||
|
data.pop(instance_uid, None)
|
||||||
|
_atomic_update(_path(client_id, stand), _rem)
|
||||||
|
|
||||||
pop с default=None — не падает если инстанса уже нет (уже удалён ранее)."""
|
|
||||||
p = _path(client_id, stand)
|
|
||||||
data = _locked_read(p)
|
|
||||||
data.pop(instance_uid, None)
|
|
||||||
_locked_write(p, data)
|
|
||||||
|
|
||||||
|
|
||||||
def list_all(client_id, stand):
|
def list_all(client_id, stand):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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>`;
|
||||||
|
|||||||
Reference in New Issue
Block a user