fix: harden retry JSON parsing and exempt personal auth routes

webui/static/hoover/api.js
  Guard retryRes.json() with .catch(() => null) so non-JSON
  responses (e.g. nginx 502/503) don't throw and lose the
  actual status code. Falls back to 'HTTP <status>' error string.

lib/db_sqlite.py
  Replace unsafe sql.split(';') loop with conn.executescript()
  which properly handles semicolons inside string literals.

webui/server.py
  Add _AUTH_PERSONAL set and _is_personal_auth() so personal
  auth operations (session, password, logout, webauthn creds)
  skip subsystem permission checks. Users with only firewall:read
  can now manage their own credentials without needing auth:rw.
This commit is contained in:
2026-07-27 20:38:50 +00:00
parent e48ba72b81
commit 76cd219050
3 changed files with 27 additions and 11 deletions
+1 -4
View File
@@ -168,7 +168,4 @@ class SQLiteBackend(Database):
def _execute_direct(self, sql: str) -> None: def _execute_direct(self, sql: str) -> None:
"""Execute raw SQL without prepared statements (for DDL).""" """Execute raw SQL without prepared statements (for DDL)."""
for line in sql.split(";"): self.conn.executescript(sql)
line = line.strip()
if line:
self.conn.execute(line)
+22 -2
View File
@@ -125,6 +125,18 @@ _AUTH_EXEMPT = {
("POST", "/api/auth/webauthn/authenticate-finish"), ("POST", "/api/auth/webauthn/authenticate-finish"),
} }
# ── Personal auth routes (operates on own account, no subsystem permission needed) ──
# These routes require a valid JWT but do NOT require an "auth" permission entry.
# A user with only "firewall:read" can still view session, change password, logout, etc.
_AUTH_PERSONAL = {
("GET", "/api/auth/session"),
("POST", "/api/auth/password"),
("POST", "/api/auth/logout"),
("GET", "/api/auth/webauthn/credentials"),
}
# Pattern: DELETE /api/auth/webauthn/creds/<id> — match prefix only
def _subsystem_from_path(path: str) -> str | None: def _subsystem_from_path(path: str) -> str | None:
"""Extract subsystem name from API path.""" """Extract subsystem name from API path."""
@@ -136,6 +148,14 @@ def _subsystem_from_path(path: str) -> str | None:
return None return None
def _is_personal_auth(method: str, path: str) -> bool:
"""Check if route is a personal auth operation (no subsystem permission needed)."""
if (method, path) in _AUTH_PERSONAL:
return True
# Personal credential deletion: DELETE /api/auth/webauthn/creds/<id>
return method == "DELETE" and path.startswith("/api/auth/webauthn/creds/")
def _has_permission(perms: dict, subsystem: str, method: str) -> bool: def _has_permission(perms: dict, subsystem: str, method: str) -> bool:
"""Check if user has permission for subsystem + method.""" """Check if user has permission for subsystem + method."""
level = perms.get(subsystem) level = perms.get(subsystem)
@@ -188,9 +208,9 @@ def _auth_middleware():
user_permissions = payload.get("permissions", {}) user_permissions = payload.get("permissions", {})
# Check subsystem permissions # Check subsystem permissions (skip personal auth routes)
subsystem = _subsystem_from_path(path) subsystem = _subsystem_from_path(path)
if subsystem: if subsystem and not _is_personal_auth(method, path):
if subsystem not in user_permissions: if subsystem not in user_permissions:
return jsonify({"ok": False, "error": "forbidden"}), 403 return jsonify({"ok": False, "error": "forbidden"}), 403
if not _has_permission(user_permissions, subsystem, method): if not _has_permission(user_permissions, subsystem, method):
+4 -5
View File
@@ -176,17 +176,16 @@ export async function apiFetch(url, options = {}) {
headers['X-Session-Id'] = refreshedStored.session_id; headers['X-Session-Id'] = refreshedStored.session_id;
} }
const retryRes = await fetch(url, { method, headers, body: options.body, credentials: 'same-origin', ...opts }); const retryRes = await fetch(url, { method, headers, body: options.body, credentials: 'same-origin', ...opts });
const json = await retryRes.json();
if (retryRes.ok) { if (retryRes.ok) {
return { ok: json.ok, data: json.ok ? json.data : json, error: null, status: retryRes.status }; const json = await retryRes.json().catch(() => null);
return { ok: json?.ok ?? true, data: json ? (json.ok ? json.data : json) : null, error: null, status: retryRes.status };
} }
if (retryRes.status === 401) { if (retryRes.status === 401) {
redirectLogin(); redirectLogin();
return { ok: false, data: null, error: 'Session expired', status: 401 }; return { ok: false, data: null, error: 'Session expired', status: 401 };
} }
if (!retryRes.ok) { const json = await retryRes.json().catch(() => null);
return { ok: false, data: null, error: json.error || `HTTP ${retryRes.status}`, status: retryRes.status }; return { ok: false, data: null, error: json?.error || `HTTP ${retryRes.status}`, status: retryRes.status };
}
} }
redirectLogin(); redirectLogin();
return { ok: false, data: null, error: 'Session expired', status: 401 }; return { ok: false, data: null, error: 'Session expired', status: 401 };