From 9589141521ad5b612c6275bde7e4c27e1bf1f4d9 Mon Sep 17 00:00:00 2001 From: gramps Date: Mon, 27 Apr 2026 16:48:19 -0700 Subject: [PATCH] feat(settings): allowlist /api/settings keys (v1.7.1) --- app.py | 16 ++++++++- docs/wiki/current-wip.md | 2 +- readme.md | 4 +-- tests/test_settings_allowlist.py | 57 ++++++++++++++++++++++++++++++++ 4 files changed, 75 insertions(+), 4 deletions(-) create mode 100644 tests/test_settings_allowlist.py diff --git a/app.py b/app.py index 364f0f1..310ce48 100644 --- a/app.py +++ b/app.py @@ -55,7 +55,7 @@ syslog_handler.setFormatter( log.addHandler(syslog_handler) # --- Configuration --- -VERSION = "1.7.0" +VERSION = "1.7.1" OLLAMA_BASE = "http://localhost:11434" SEARXNG_BASE = "http://localhost:8888" BASE_DIR = Path(__file__).parent @@ -102,6 +102,12 @@ MAX_PRESET_PROMPT_CHARS = 12000 MAX_SETTINGS_KEYS = 16 MAX_SETTINGS_VALUE_CHARS = 8000 MAX_CONVERSATION_TITLE_CHARS = 200 +ALLOWED_SETTINGS_KEYS = { + "profile_enabled", + "default_model", + "search_enabled", + "memory_enabled", +} # --- Templates and Static Files --- templates = Jinja2Templates(directory=str(BASE_DIR / "templates")) @@ -1439,6 +1445,14 @@ async def update_settings(request: Request): raise HTTPException(status_code=400, detail="Settings payload must be an object") if len(body) > MAX_SETTINGS_KEYS: raise HTTPException(status_code=413, detail="Too many settings in one request") + unknown_keys = sorted( + key for key in body.keys() if str(key) not in ALLOWED_SETTINGS_KEYS + ) + if unknown_keys: + raise HTTPException( + status_code=400, + detail=f"Unknown setting key(s): {', '.join(unknown_keys)}", + ) db = get_db() for key, value in body.items(): if len(str(key)) > 80 or len(str(value)) > MAX_SETTINGS_VALUE_CHARS: diff --git a/docs/wiki/current-wip.md b/docs/wiki/current-wip.md index 276ce21..8888dd2 100644 --- a/docs/wiki/current-wip.md +++ b/docs/wiki/current-wip.md @@ -17,7 +17,7 @@ Total identified items: 26 2. [P0][DONE] Add CSRF/origin protection for browser-initiated state-changing requests. 3. [P0][DONE] Block unsafe URL schemes in rendered search-result links (e.g., javascript:). 4. [P0][DONE] Add rate limiting and request body size limits for chat/search/profile APIs. -5. [P1] Restrict settings updates to an allowlist of valid keys. +5. [P1][DONE] Restrict settings updates to an allowlist of valid keys. 6. [P1] Add pagination + hard caps on list endpoints (memories, conversations, message history). 7. [P1] Stop returning raw exception text to clients; use safe error envelopes. 8. [P1] Add automated tests for chat streaming, auto-search trigger, and memory command paths. diff --git a/readme.md b/readme.md index f99e057..16bd868 100644 --- a/readme.md +++ b/readme.md @@ -1,4 +1,4 @@ -# ⚡ JarvisChat v1.7.0 +# ⚡ JarvisChat v1.7.1 ![screenshot](docs/images/screenshot.png) @@ -62,7 +62,7 @@ Top 10 (brief): 2. P0 [DONE]: Add CSRF/origin protection for state-changing requests 3. P0 [DONE]: Block unsafe URL schemes in rendered links 4. P0 [DONE]: Add rate limiting and request size limits -5. P1: Restrict `/api/settings` updates to allowlisted keys +5. P1 [DONE]: Restrict `/api/settings` updates to allowlisted keys 6. P1: Add pagination + hard caps for list APIs 7. P1: Replace raw exception leakage with safe client errors 8. P1: Add automated tests for streaming/search/memory paths diff --git a/tests/test_settings_allowlist.py b/tests/test_settings_allowlist.py new file mode 100644 index 0000000..afaec78 --- /dev/null +++ b/tests/test_settings_allowlist.py @@ -0,0 +1,57 @@ +import os +from pathlib import Path + +from fastapi.testclient import TestClient + +import app as app_module + + +def make_admin_client(tmp_path: Path) -> tuple[TestClient, dict[str, str]]: + os.environ["JARVISCHAT_ADMIN_PIN"] = "1234" + app_module.DB_PATH = tmp_path / "jarvischat-settings.db" + app_module.SESSIONS.clear() + app_module.PIN_ATTEMPTS.clear() + app_module.init_db() + + client = TestClient(app_module.app) + login = client.post( + "/api/auth/login", + json={"pin": "1234"}, + headers={"Origin": "http://testserver"}, + ) + assert login.status_code == 200 + sid = login.json()["session_id"] + headers = {"X-Session-ID": sid, "Origin": "http://testserver"} + return client, headers + + +def test_settings_allow_known_keys(tmp_path: Path): + client, headers = make_admin_client(tmp_path) + try: + resp = client.put( + "/api/settings", + json={ + "profile_enabled": "false", + "search_enabled": "true", + "memory_enabled": "false", + "default_model": "llama3.1:latest", + }, + headers=headers, + ) + assert resp.status_code == 200 + finally: + client.close() + + +def test_settings_reject_unknown_keys(tmp_path: Path): + client, headers = make_admin_client(tmp_path) + try: + resp = client.put( + "/api/settings", + json={"admin_pin_hash": "oops"}, + headers=headers, + ) + assert resp.status_code == 400 + assert "Unknown setting key" in resp.json().get("detail", "") + finally: + client.close()