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 };