From 8343a66a14838643e78b29c8b20aec35bfd3fd3a Mon Sep 17 00:00:00 2001 From: savsis Date: Mon, 14 Sep 2026 04:25:34 +0500 Subject: [PATCH] fix: admin panel showed raw JSON error envelopes instead of the actual message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found while adding one more input check (webhook URL scheme) and noticing the error would render as literal {"detail":"..."} text in the UI. Root cause is in the shared api() JS helper, not any individual route: on a non-ok response it did `throw new Error(await res.text())` — the raw response body, not the parsed message. FastAPI's default HTTPException handler returns {"detail": "message"} as JSON, so every `esc(e.message)` display in the panel (roughly 15 call sites) was showing the whole JSON envelope, curly braces and quotes included, not just the message inside it. Confirmed this wasn't already handled by checking login()'s own catch block — it hardcodes a fixed string instead of showing e.message at all, which only makes sense if e.message was never fit to show directly. This affects every validation message added the last several commits (prices, HWID settings, node creation, provision-guide, hwid-limit) and plenty from before tonight too — not something introduced by this session, but something this session's run of new validation made worth actually fixing rather than shipping another error message into a broken display path. api(): on error, try to JSON.parse the body and use .detail if it's a string; anything that doesn't match that exact shape (plain text body, malformed JSON, FastAPI's array-shaped 422 validation-error detail) falls through to the original raw-text behavior unchanged, so nothing that worked before regresses. Also added the actual check that prompted this: webhook URL must start with http:// or https://, rejecting things like a bare hostname or a file:// URL (webhooks.send() never reads or forwards the response body, so this was never a real exfiltration path, but it's an essentially free guard against both a fat-fingered URL that would otherwise silently never deliver anything, and the more deliberate file://-style misuse). Verification: the api() fix is pure client-side logic with no backend dependency, so tested directly under Node against a mocked fetch — 6 cases: the exact FastAPI {detail: string} shape extracting cleanly, a non-JSON error body falling back unchanged, the Pydantic array-detail 422 shape not crashing the parser, malformed JSON falling back to raw text, the 401/showLogin path completely unchanged, and the successful- response happy path unaffected. AST-extracted admin_set_webhook_settings out of api.py (still can't import it directly) and ran it against a fake legal/env module — 6 cases covering both accepted schemes, both rejected ones (ftp://, file://), a scheme-less bare hostname, and confirming clearing the webhook with an empty string still works. Co-Authored-By: Claude Sonnet 5 --- admin.html | 10 +++++++++- api.py | 2 ++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/admin.html b/admin.html index a59e582..efdce47 100644 --- a/admin.html +++ b/admin.html @@ -752,7 +752,15 @@ function esc(s) { async function api(path, opts) { const res = await fetch(path, { ...opts, headers: { "Content-Type": "application/json", ...(opts && opts.headers) } }); if (res.status === 401) { showLogin(); throw new Error("unauthorized"); } - if (!res.ok) throw new Error(await res.text()); + if (!res.ok) { + const text = await res.text(); + let message = text; + try { + const parsed = JSON.parse(text); + if (parsed && typeof parsed.detail === "string") message = parsed.detail; + } catch (e) {} + throw new Error(message); + } const ct = res.headers.get("content-type") || ""; return ct.includes("application/json") ? res.json() : res.text(); } diff --git a/api.py b/api.py index a65ae0e..7a39d45 100644 --- a/api.py +++ b/api.py @@ -530,6 +530,8 @@ def admin_get_webhook_settings(request: Request): def admin_set_webhook_settings(request: Request, body: dict = Body(...)): require_admin(request) url = (body.get("url") or "").strip() + if url and not (url.startswith("http://") or url.startswith("https://")): + raise HTTPException(400, "URL должен начинаться с http:// или https://") _update_env_var("WEBHOOK_URL", url) if url and not legal.read_env_var("WEBHOOK_SECRET", ""): _update_env_var("WEBHOOK_SECRET", secrets.token_hex(24))