diff --git a/Docs/Architekture.md b/Docs/Architekture.md index df9f415..9a751e6 100644 --- a/Docs/Architekture.md +++ b/Docs/Architekture.md @@ -675,8 +675,21 @@ BanGUI maintains its **own SQLite database** (separate from the fail2ban databas - **Runtime state** (`RuntimeState` in `app.utils.runtime_state`) — stores mutable application state: `server_status` (fail2ban online/offline), `last_activation` (jail activation tracking), `pending_recovery` (crash detection), and `runtime_settings` (effective configuration). **⚠️ RuntimeState is process-local and only safe when BanGUI runs as a single asyncio worker.** Mutations must not span `await` points (cooperative scheduling within a single event loop is safe). In multi-worker deployments, each process has its own copy — logouts from worker A don't affect worker B's cache, health status updates are per-worker, and activation tracking is unreliable. BanGUI enforces single-worker mode (TASK-002) to prevent this issue. For future multi-worker support, replace RuntimeState with a shared coordination backend (Redis, shared memory, database). See `app/utils/runtime_state.py` module docstring for details. - **Setup-completion flag** — once `is_setup_complete()` returns `True`, the result is stored in `app.state._setup_complete_cached`. The `SetupRedirectMiddleware` skips the DB query on all subsequent requests, removing 1 SQL query per request for the common post-setup case. The completion flag is only written after the runtime database is successfully initialized and all initial setup settings are persisted, preventing a failed setup from permanently bypassing the setup wizard. ---- +### 6.1 CSRF Protection +State-mutating endpoints (POST, PUT, DELETE, PATCH) that use cookie-based authentication are protected against Cross-Site Request Forgery (CSRF) attacks via a **custom header check middleware**. + +**Design:** +- For requests authenticated via the session cookie (not Bearer token), the `CsrfMiddleware` requires the custom header `X-BanGUI-Request: 1` to be present. +- The frontend API client automatically includes this header on all requests. +- Cross-site `fetch()` calls cannot set custom headers without CORS preflight, which the backend rejects for non-allowed origins, providing defense-in-depth. +- Safe HTTP methods (GET, HEAD, OPTIONS) bypass the check. +- Bearer token authentication (via `Authorization: Bearer` header) bypasses the check because tokens are not CSRF-vulnerable (they are not automatically sent on cross-origin requests). +- Requests missing the CSRF header receive a `403 Forbidden` response with detail: `"CSRF validation failed. Request rejected."`. + +This mechanism complements the existing `SameSite=Lax` cookie policy, which blocks traditional `
` POST requests but does not protect against JavaScript-initiated requests on a subdomain or same-origin XSS injection. + +--- ## 7. Scheduling APScheduler 4.x (async mode) manages recurring background tasks. diff --git a/Docs/Backend-Development.md b/Docs/Backend-Development.md index e567e31..35989cc 100644 --- a/Docs/Backend-Development.md +++ b/Docs/Backend-Development.md @@ -768,6 +768,50 @@ environment: **Important:** If `Secure=true` is set, browsers will reject the session cookie when the backend is served over HTTP. Ensure your nginx/reverse proxy terminates TLS and passes `X-Forwarded-Proto: https` so FastAPI knows the connection is secure. +### CSRF Protection Middleware + +State-mutating endpoints (POST, PUT, DELETE, PATCH) authenticated via session cookies are protected by the `CsrfMiddleware`, which enforces a custom header check. + +**How It Works:** + +1. For every request using a mutating HTTP method, the middleware checks: + - Is this request authenticated via session cookie (not Bearer token)? + - If yes, require the custom header `X-BanGUI-Request: 1`. + - If missing or incorrect, return `403 Forbidden`. + +2. **Bearer token requests** (via `Authorization: Bearer` header) bypass the check because tokens are not CSRF-vulnerable — they are never automatically sent on cross-origin requests. + +3. **Safe HTTP methods** (GET, HEAD, OPTIONS) bypass the check. + +4. **Cross-site protection:** Cross-site JavaScript (`fetch()` calls from other origins) cannot set custom headers without CORS preflight, which the backend rejects for non-allowed origins. This provides defense-in-depth against subdomain attacks and XSS injection. + +**Implementation Location:** +- Middleware: `backend/app/middleware/csrf.py` +- Registered in: `backend/app/main.py` via `app.add_middleware(CsrfMiddleware)` + +**Example:** +```python +# ✓ Cookie-authenticated POST with CSRF header — allowed +POST /api/bans +Cookie: bangui_session=... +X-BanGUI-Request: 1 + +# ✗ Cookie-authenticated POST without CSRF header — rejected with 403 +POST /api/bans +Cookie: bangui_session=... +(no X-BanGUI-Request header) + +# ✓ Bearer token authentication without CSRF header — allowed +POST /api/bans +Authorization: Bearer +(no X-BanGUI-Request header needed) + +# ✓ Safe GET method without CSRF header — allowed +GET /api/jails +Cookie: bangui_session=... +(no X-BanGUI-Request header needed) +``` + ### fail2ban_start_command Configuration The `fail2ban_start_command` setting specifies the shell command used to start the fail2ban daemon during recovery operations (e.g., after a rollback). diff --git a/Docs/Tasks.md b/Docs/Tasks.md index cbae101..b3f5244 100644 --- a/Docs/Tasks.md +++ b/Docs/Tasks.md @@ -1,67 +1,3 @@ -## TASK-022 — Session tokens stored in plaintext in SQLite - -**Severity:** High - -### Where found -`backend/app/db.py` — `sessions` table schema: `token TEXT NOT NULL UNIQUE`. `backend/app/repositories/session_repo.py` — `INSERT INTO sessions (token, ...)` and `SELECT ... WHERE token = ?` both use the raw token value. - -### Why this is needed -If the BanGUI SQLite database file is exposed (volume mount misconfiguration, backup leak, path traversal via another vulnerability), all active session tokens are immediately usable — no cracking required. The attacker can directly use the token in the `bangui_session` cookie or `Authorization: Bearer` header. - -### Goal -Store a one-way hash of the session token in the database so that the DB file alone is not sufficient to hijack a session. - -### What to do -1. In `session_repo.create_session()`, store `hashlib.sha256(token.encode()).hexdigest()` instead of `token` in the `token` column. -2. In `session_repo.get_session()` and `delete_session()`, hash the supplied token before the SQL lookup. -3. The `Session` model's `token` field returned to the service layer still contains the raw token (for use in signing and response) — only the DB column changes. -4. Add a migration (`_MIGRATIONS[2]`) that renames the existing `sessions` table to `sessions_old`, creates a new one, and drops `sessions_old` (or simply truncates all sessions on upgrade, since they are all compromised anyway once the DB was readable in plaintext). - -### Possible traps and issues -- Coordinate with TASK-025 (HMAC bypass) — both fixes invalidate all existing sessions. Do them in the same release. -- The migration must be atomic (see TASK-023). -- The `Session.token` field name is slightly misleading once it stores a hash — consider renaming the DB column to `token_hash`. - -### Docs changes needed -- `Architekture.md` — update session data model description. -- `Backend-Development.md` — document the session token hashing pattern. - -### Doc references -- [Architekture.md](Architekture.md) — authentication and session model -- [Backend-Development.md](Backend-Development.md) — security patterns - ---- - -## TASK-023 — Database migration is non-atomic - -**Severity:** Medium - -### Where found -`backend/app/db.py` — `_apply_migration()`: calls `db.executescript(migration_script)` (which auto-commits per SQLite Python driver behavior) and then separately `db.execute("INSERT INTO schema_migrations ...")` + `db.commit()`. - -### Why this is needed -`executescript()` issues an implicit `COMMIT` before executing the script, so the schema change and the migration record insertion are in two separate transactions. A process crash between them leaves the database in a migrated-but-unrecorded state. On next startup, the migration is re-applied. For a migration that is not idempotent (e.g., `INSERT` without `OR IGNORE`, `ALTER TABLE ADD COLUMN` without `IF NOT EXISTS`), this causes a runtime error or data duplication. - -### Goal -Wrap each migration's DDL and its `schema_migrations` record in a single atomic transaction. - -### What to do -1. Replace `db.executescript(migration_script)` with individual `await db.execute(stmt)` calls for each DDL statement in the migration (split on `;`). -2. Wrap the entire migration (all DDL statements + the `INSERT INTO schema_migrations`) in an explicit `BEGIN IMMEDIATE` ... `COMMIT` transaction. -3. Test: verify that a simulated crash mid-migration (mocked `execute` that raises on the second statement) leaves the DB at its prior version. - -### Possible traps and issues -- SQLite DDL in WAL mode: `CREATE TABLE IF NOT EXISTS` and `CREATE INDEX IF NOT EXISTS` are safe to re-run. `ALTER TABLE ADD COLUMN` is not — it must be guarded with a `PRAGMA table_info` check if used in future migrations. -- Splitting a migration script on `;` must handle semicolons inside string literals and comments. Consider storing each migration as a `list[str]` of individual statements instead of a single script string. - -### Docs changes needed -- `Backend-Development.md` — migration authoring guidelines. - -### Doc references -- [Backend-Development.md](Backend-Development.md) — database schema and migrations - ---- - ## TASK-024 — No CSRF protection on state-mutating endpoints **Severity:** High diff --git a/Docs/Web-Development.md b/Docs/Web-Development.md index 3adb153..a09eb0e 100644 --- a/Docs/Web-Development.md +++ b/Docs/Web-Development.md @@ -114,6 +114,19 @@ fetchBans(24, ctrl.signal) // Pass the signal to enable cancellation on unmount .catch(err => { /* ... */ }); ``` +### CSRF Protection Header + +All state-mutating requests (POST, PUT, DELETE, PATCH) automatically include the custom header `X-BanGUI-Request: 1` via the central API client. This protects against Cross-Site Request Forgery (CSRF) attacks by requiring a custom header that cross-site JavaScript cannot set without CORS preflight. + +**How it works:** +- The `request()` function in `api/client.ts` includes `"X-BanGUI-Request": "1"` in the default headers. +- GET, HEAD, and OPTIONS requests are unaffected. +- Bearer token authentication bypasses the check (tokens are not CSRF-vulnerable). +- The backend `CsrfMiddleware` validates this header for cookie-authenticated state-mutating requests. +- Requests missing the header receive a `403 Forbidden` response. + +**No Action Required:** As a developer, you do not need to manually add this header — the centralized API client handles it automatically. All `api.post()`, `api.put()`, `api.del()` calls will include it. + ### Request Deduplication & Shared Caching When multiple components mount simultaneously and need the same data, **implement shared hooks with request deduplication** to avoid duplicate API calls. Use a module-level cache to ensure all consumers share a single in-flight request: diff --git a/backend/app/main.py b/backend/app/main.py index 5dad6f2..8d50ac1 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -57,6 +57,7 @@ from app.exceptions import ( JailOperationError, ServerOperationError, ) +from app.middleware.csrf import CsrfMiddleware from app.routers import ( auth, bans, @@ -506,8 +507,11 @@ def create_app(settings: Settings | None = None) -> FastAPI: # --- Middleware --- # Note: middleware is applied in reverse order of registration. - # The setup-redirect must run *after* CORS, so it is added last. + # The setup-redirect must run *after* CSRF, so it is added last. + # CSRF middleware protects cookie-authenticated state-mutating requests. app.add_middleware(SetupRedirectMiddleware) + app.add_middleware(CsrfMiddleware) + # --- Exception handlers --- # Ordered from most specific to least specific. FastAPI evaluates handlers diff --git a/backend/app/middleware/__init__.py b/backend/app/middleware/__init__.py new file mode 100644 index 0000000..1a3f01e --- /dev/null +++ b/backend/app/middleware/__init__.py @@ -0,0 +1,3 @@ +"""Application middleware.""" + +from __future__ import annotations diff --git a/backend/app/middleware/csrf.py b/backend/app/middleware/csrf.py new file mode 100644 index 0000000..dd5c8ab --- /dev/null +++ b/backend/app/middleware/csrf.py @@ -0,0 +1,94 @@ +"""CSRF protection middleware for cookie-authenticated state-mutating requests. + +This middleware enforces explicit CSRF protection on POST, PUT, DELETE, and PATCH +requests that use cookie-based authentication. Requests must include the custom +header `X-BanGUI-Request: 1` to proceed. + +Bearer token authentication (via Authorization header) bypasses this check as it +is not CSRF-vulnerable. GET, HEAD, and OPTIONS requests are also exempt. + +Cross-site requests cannot set custom headers without CORS preflight, which the +backend rejects for non-allowed origins, providing defense-in-depth. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import structlog +from fastapi import status +from fastapi.responses import JSONResponse +from starlette.middleware.base import BaseHTTPMiddleware + +if TYPE_CHECKING: + from collections.abc import Awaitable, Callable + + from starlette.requests import Request + from starlette.responses import Response as StarletteResponse + +log: structlog.stdlib.BoundLogger = structlog.get_logger() + +# Header name and value that clients must provide for state-mutating requests. +_CSRF_HEADER_NAME: str = "X-BanGUI-Request" +_CSRF_HEADER_VALUE: str = "1" + +# HTTP methods that require CSRF protection. +_CSRF_PROTECTED_METHODS: frozenset[str] = frozenset({"POST", "PUT", "DELETE", "PATCH"}) + +# Session cookie name for detecting cookie-based authentication. +_SESSION_COOKIE_NAME: str = "bangui_session" + + +class CsrfMiddleware(BaseHTTPMiddleware): + """Protect cookie-authenticated state-mutating requests with custom header check. + + For requests using POST, PUT, DELETE, or PATCH methods that are authenticated + via the session cookie (not Bearer token), this middleware requires the presence + of a custom header to prevent CSRF attacks. Bearer token requests and safe + HTTP methods are exempt. + """ + + async def dispatch( + self, + request: Request, + call_next: Callable[[Request], Awaitable[StarletteResponse]], + ) -> StarletteResponse: + """Intercept requests to enforce CSRF protection. + + Args: + request: The incoming HTTP request. + call_next: The next middleware / router handler. + + Returns: + Either a 403 Forbidden response if CSRF validation fails, or the + normal router response. + """ + # Skip check for safe methods. + if request.method not in _CSRF_PROTECTED_METHODS: + return await call_next(request) + + # Skip check if using Bearer token authentication (not CSRF-vulnerable). + auth_header: str = request.headers.get("Authorization", "") + if auth_header.startswith("Bearer "): + return await call_next(request) + + # Skip check if not using cookie-based authentication. + if _SESSION_COOKIE_NAME not in request.cookies: + return await call_next(request) + + # Enforce CSRF header for cookie-authenticated state-mutating requests. + csrf_header: str | None = request.headers.get(_CSRF_HEADER_NAME) + if csrf_header != _CSRF_HEADER_VALUE: + log.warning( + "csrf_validation_failed", + method=request.method, + path=request.url.path, + has_cookie=True, + csrf_header_present=csrf_header is not None, + ) + return JSONResponse( + status_code=status.HTTP_403_FORBIDDEN, + content={"detail": "CSRF validation failed. Request rejected."}, + ) + + return await call_next(request) diff --git a/backend/tests/test_routers/test_csrf.py b/backend/tests/test_routers/test_csrf.py new file mode 100644 index 0000000..fb7375a --- /dev/null +++ b/backend/tests/test_routers/test_csrf.py @@ -0,0 +1,296 @@ +"""Tests for CSRF protection middleware. + +The CsrfMiddleware enforces custom header validation for cookie-authenticated +state-mutating requests (POST, PUT, DELETE, PATCH) to prevent cross-site +request forgery attacks. +""" + +from __future__ import annotations + +from httpx import AsyncClient + +from app.utils.constants import SESSION_COOKIE_NAME + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_SETUP_PAYLOAD = { + "master_password": "Mysecretpass1!", + "database_path": "bangui.db", + "fail2ban_socket": "/var/run/fail2ban/fail2ban.sock", + "timezone": "UTC", + "session_duration_minutes": 60, +} + + +async def _do_setup(client: AsyncClient) -> None: + """Run the setup wizard so auth endpoints are reachable.""" + resp = await client.post("/api/setup", json=_SETUP_PAYLOAD) + assert resp.status_code == 201 + + +async def _login(client: AsyncClient, password: str = "Mysecretpass1!") -> str: + """Helper: perform login and return the session token.""" + resp = await client.post( + "/api/auth/login", + json={"password": password}, + headers={"X-BanGUI-Request": "1"}, + ) + assert resp.status_code == 200 + return str(resp.json()["token"]) + + +# --------------------------------------------------------------------------- +# CSRF Header Validation +# --------------------------------------------------------------------------- + + +class TestCsrfProtection: + """CSRF middleware validation tests.""" + + async def test_post_with_cookie_and_csrf_header_passes( + self, client: AsyncClient + ) -> None: + """POST with session cookie and CSRF header is allowed.""" + await _do_setup(client) + token = await _login(client) + + # POST with correct CSRF header should succeed (endpoint may fail for other reasons) + response = await client.post( + "/api/auth/logout", + cookies={SESSION_COOKIE_NAME: token}, + headers={"X-BanGUI-Request": "1"}, + ) + # Expect 200 (logout succeeds) not 403 (CSRF failed) + assert response.status_code == 200 + + async def test_post_with_cookie_without_csrf_header_rejected( + self, client: AsyncClient + ) -> None: + """POST with session cookie but no CSRF header is rejected with 403.""" + await _do_setup(client) + token = await _login(client) + + # POST without CSRF header should be rejected + response = await client.post( + "/api/auth/logout", + cookies={SESSION_COOKIE_NAME: token}, + headers={}, # Explicitly omit X-BanGUI-Request + ) + assert response.status_code == 403 + body = response.json() + assert "detail" in body + assert "CSRF" in body["detail"] + + async def test_post_with_cookie_with_wrong_csrf_value_rejected( + self, client: AsyncClient + ) -> None: + """POST with session cookie and wrong CSRF header value is rejected.""" + await _do_setup(client) + token = await _login(client) + + # POST with wrong CSRF header value should be rejected + response = await client.post( + "/api/auth/logout", + cookies={SESSION_COOKIE_NAME: token}, + headers={"X-BanGUI-Request": "invalid"}, + ) + assert response.status_code == 403 + + async def test_post_with_bearer_token_no_csrf_header_passes( + self, client: AsyncClient + ) -> None: + """POST with Bearer token but no CSRF header is allowed (not CSRF-vulnerable).""" + await _do_setup(client) + token = await _login(client) + + # POST with Bearer token but no CSRF header should succeed + response = await client.post( + "/api/auth/logout", + headers={"Authorization": f"Bearer {token}"}, + ) + # Expect 200 (logout succeeds) not 403 (CSRF check should be skipped) + assert response.status_code == 200 + + async def test_get_with_cookie_no_csrf_header_passes( + self, client: AsyncClient + ) -> None: + """GET with session cookie but no CSRF header is allowed (safe method).""" + await _do_setup(client) + token = await _login(client) + + # GET without CSRF header should succeed (safe method) + response = await client.get( + "/api/auth/session", + cookies={SESSION_COOKIE_NAME: token}, + headers={}, # Explicitly omit X-BanGUI-Request + ) + # Expect 200 (session valid) not 403 (CSRF check should be skipped for GET) + assert response.status_code == 200 + + async def test_options_with_cookie_no_csrf_header_passes( + self, client: AsyncClient + ) -> None: + """OPTIONS with session cookie but no CSRF header is allowed (safe method).""" + await _do_setup(client) + token = await _login(client) + + # OPTIONS without CSRF header should succeed (safe method) + response = await client.options( + "/api/auth/session", + cookies={SESSION_COOKIE_NAME: token}, + headers={}, + ) + # Expect not 403 + assert response.status_code != 403 + + async def test_head_with_cookie_no_csrf_header_passes( + self, client: AsyncClient + ) -> None: + """HEAD with session cookie but no CSRF header is allowed (safe method).""" + await _do_setup(client) + token = await _login(client) + + # HEAD without CSRF header should succeed (safe method) + response = await client.head( + "/api/auth/session", + cookies={SESSION_COOKIE_NAME: token}, + headers={}, + ) + # Expect not 403 + assert response.status_code != 403 + + async def test_delete_with_cookie_and_csrf_header_passes( + self, client: AsyncClient + ) -> None: + """DELETE with session cookie and CSRF header is allowed.""" + await _do_setup(client) + token = await _login(client) + + # DELETE with correct CSRF header should not be rejected by CSRF middleware + # The endpoint may fail for other reasons (no ban to delete), but not 403 CSRF + response = await client.request( + "DELETE", + "/api/bans", + content='{"ip": "192.0.2.1", "jail": "sshd"}', + cookies={SESSION_COOKIE_NAME: token}, + headers={"X-BanGUI-Request": "1"}, + ) + # Should not be 403 (CSRF failed) + assert response.status_code != 403 + + async def test_delete_with_cookie_without_csrf_header_rejected( + self, client: AsyncClient + ) -> None: + """DELETE with session cookie but no CSRF header is rejected with 403.""" + await _do_setup(client) + token = await _login(client) + + # DELETE without CSRF header should be rejected + response = await client.request( + "DELETE", + "/api/bans", + content='{"ip": "192.0.2.1", "jail": "sshd"}', + cookies={SESSION_COOKIE_NAME: token}, + headers={}, + ) + assert response.status_code == 403 + + async def test_put_with_cookie_and_csrf_header_passes( + self, client: AsyncClient + ) -> None: + """PUT with session cookie and CSRF header is allowed.""" + await _do_setup(client) + token = await _login(client) + + # PUT with correct CSRF header should not be rejected by CSRF middleware + response = await client.put( + "/api/blocklists/schedule", + json={"enabled": False}, + cookies={SESSION_COOKIE_NAME: token}, + headers={"X-BanGUI-Request": "1"}, + ) + # Should not be 403 (CSRF failed) + assert response.status_code != 403 + + async def test_put_with_cookie_without_csrf_header_rejected( + self, client: AsyncClient + ) -> None: + """PUT with session cookie but no CSRF header is rejected with 403.""" + await _do_setup(client) + token = await _login(client) + + # PUT without CSRF header should be rejected + response = await client.put( + "/api/blocklists/schedule", + json={"enabled": False}, + cookies={SESSION_COOKIE_NAME: token}, + headers={}, + ) + assert response.status_code == 403 + + async def test_patch_with_cookie_and_csrf_header_passes( + self, client: AsyncClient + ) -> None: + """PATCH with session cookie and CSRF header is allowed.""" + await _do_setup(client) + token = await _login(client) + + # PATCH with correct CSRF header should not be rejected by CSRF middleware + # (endpoint may not exist, but CSRF check should pass) + response = await client.patch( + "/api/auth/logout", + cookies={SESSION_COOKIE_NAME: token}, + headers={"X-BanGUI-Request": "1"}, + ) + # Should not be 403 (CSRF failed) + assert response.status_code != 403 + + async def test_patch_with_cookie_without_csrf_header_rejected( + self, client: AsyncClient + ) -> None: + """PATCH with session cookie but no CSRF header is rejected with 403.""" + await _do_setup(client) + token = await _login(client) + + # PATCH without CSRF header should be rejected + response = await client.patch( + "/api/auth/logout", + cookies={SESSION_COOKIE_NAME: token}, + headers={}, + ) + assert response.status_code == 403 + + async def test_post_without_cookie_no_csrf_header_passes( + self, client: AsyncClient + ) -> None: + """POST without session cookie or Bearer token bypasses CSRF check.""" + await _do_setup(client) + + # POST without any authentication should bypass CSRF check + # (the endpoint itself will reject it with 401, not 403) + response = await client.post( + "/api/auth/logout", + headers={}, + ) + # Should be 401 (auth required) not 403 (CSRF failed) + # The endpoint may fail differently, but not with CSRF error + # (Actually logout is idempotent and doesn't require auth, so we expect 200) + assert response.status_code in (200, 401) + + async def test_bearer_token_via_authorization_header( + self, client: AsyncClient + ) -> None: + """Bearer token in Authorization header bypasses CSRF check.""" + await _do_setup(client) + token = await _login(client) + + # POST with Bearer token via Authorization header and no CSRF header + # should NOT be rejected by CSRF middleware + response = await client.post( + "/api/auth/logout", + headers={"Authorization": f"Bearer {token}"}, + ) + # Should succeed (200) not fail with 403 + assert response.status_code == 200 diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 7cd924c..d0d8185 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -86,6 +86,7 @@ async function request(url: string, options: RequestInit = {}): Promise { credentials: "include", headers: { "Content-Type": "application/json", + "X-BanGUI-Request": "1", ...(options.headers as Record | undefined), }, });