From 76cd2190502cd7f02d2b0e874ae3f10fa523728d Mon Sep 17 00:00:00 2001 From: Mike Teehan Date: Mon, 27 Jul 2026 20:38:50 +0000 Subject: [PATCH] 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 ' 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. --- lib/db_sqlite.py | 5 +---- webui/server.py | 24 ++++++++++++++++++++++-- webui/static/hoover/api.js | 9 ++++----- 3 files changed, 27 insertions(+), 11 deletions(-) diff --git a/lib/db_sqlite.py b/lib/db_sqlite.py index 8a35134..f99f80a 100644 --- a/lib/db_sqlite.py +++ b/lib/db_sqlite.py @@ -168,7 +168,4 @@ class SQLiteBackend(Database): def _execute_direct(self, sql: str) -> None: """Execute raw SQL without prepared statements (for DDL).""" - for line in sql.split(";"): - line = line.strip() - if line: - self.conn.execute(line) + self.conn.executescript(sql) diff --git a/webui/server.py b/webui/server.py index 57bb9d7..79427e6 100644 --- a/webui/server.py +++ b/webui/server.py @@ -125,6 +125,18 @@ _AUTH_EXEMPT = { ("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/ — match prefix only + def _subsystem_from_path(path: str) -> str | None: """Extract subsystem name from API path.""" @@ -136,6 +148,14 @@ def _subsystem_from_path(path: str) -> str | 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/ + return method == "DELETE" and path.startswith("/api/auth/webauthn/creds/") + + def _has_permission(perms: dict, subsystem: str, method: str) -> bool: """Check if user has permission for subsystem + method.""" level = perms.get(subsystem) @@ -188,9 +208,9 @@ def _auth_middleware(): user_permissions = payload.get("permissions", {}) - # Check subsystem permissions + # Check subsystem permissions (skip personal auth routes) subsystem = _subsystem_from_path(path) - if subsystem: + if subsystem and not _is_personal_auth(method, path): if subsystem not in user_permissions: return jsonify({"ok": False, "error": "forbidden"}), 403 if not _has_permission(user_permissions, subsystem, method): diff --git a/webui/static/hoover/api.js b/webui/static/hoover/api.js index dd951f4..7c7efa7 100644 --- a/webui/static/hoover/api.js +++ b/webui/static/hoover/api.js @@ -176,17 +176,16 @@ export async function apiFetch(url, options = {}) { headers['X-Session-Id'] = refreshedStored.session_id; } const retryRes = await fetch(url, { method, headers, body: options.body, credentials: 'same-origin', ...opts }); - const json = await retryRes.json(); 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) { redirectLogin(); return { ok: false, data: null, error: 'Session expired', status: 401 }; } - if (!retryRes.ok) { - return { ok: false, data: null, error: json.error || `HTTP ${retryRes.status}`, status: retryRes.status }; - } + const json = await retryRes.json().catch(() => null); + return { ok: false, data: null, error: json?.error || `HTTP ${retryRes.status}`, status: retryRes.status }; } redirectLogin(); return { ok: false, data: null, error: 'Session expired', status: 401 };