From ebf4b0363c3cb760e7a0640fd7cb1804cdc6bd68 Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Thu, 27 Aug 2026 11:03:24 +0200 Subject: [PATCH] =?UTF-8?q?fix(#351):=20CSRF-403=20bei=20KI-Chat=20und=20W?= =?UTF-8?q?iki-Save=20behoben=20=E2=80=94=20/auth/me=20liefert=20csrf=5Fto?= =?UTF-8?q?ken,=20streamChat=20nutzt=20gemeinsamen=20Client-Token=20statt?= =?UTF-8?q?=20totem=20sessionStorage-Key;=20Regressionstests=20pytest+vite?= =?UTF-8?q?st?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PROGRESS.md | 12 ++++- app/services/auth_service.py | 2 + frontend/src/__tests__/ai/streamChat.test.ts | 56 ++++++++++++++++++++ frontend/src/api/ai.ts | 4 +- frontend/src/api/auth.ts | 4 ++ tests/test_auth.py | 18 +++++++ 6 files changed, 93 insertions(+), 3 deletions(-) create mode 100644 frontend/src/__tests__/ai/streamChat.test.ts diff --git a/PROGRESS.md b/PROGRESS.md index a7d6551..f2108dd 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -1,7 +1,17 @@ # LeoPlatform — Fortschritts-Tracking -> **Letztes Update:** 2026-08-21 +> **Letztes Update:** 2026-08-27 > **Status:** Phase A-K done (261/261 Tasks), 25 Plugins aktiv, Alembic 0136, 2174 Tests + +## Produktions-Bugfixes (2026-08-27) + +| Bug | Issue | Fix | Verifikation (Live-Messung 2026-08-27) | +|---|---|---|---| +| KI-Chat `Stream failed: 403` (sessionStorage-Key `leocrm_csrf_token` wird nie geschrieben → Request ohne X-CSRF-Token) | [#351](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/351) | streamChat nutzt `getCsrfToken()` aus dem gemeinsamen Client | Vitest streamChat.test.ts 2/2 passed: X-CSRF-Token-Header bewiesen | +| Alle Mutationen (Wiki-Save etc.) 403 nach Seiten-Reload (`/auth/me` lieferte csrf_token nicht zurück → In-Memory-Token nach Reload weg) | [#351](https://forgejo.media-on.de/Leopoldadmin/leocrm/issues/351) | `/auth/me` liefert `csrf_token` aus Session; useCurrentUser stellt ihn beim Bootstrap wieder her | pytest test_auth.py 11/11 passed inkl. neuem Regressionstest `test_me_returns_csrf_token_for_reload_restore` | + +**Gates:** ruff exit=0 · tsc --noEmit exit=0 · pytest 11 passed · Vitest 2 passed + > **Audit:** Komplette Vernetzungs-Audit durchgeführt — ~1800 Vernetzungen, 93% verbunden, 6 kritische Findings --- diff --git a/app/services/auth_service.py b/app/services/auth_service.py index d9621e1..850fa96 100644 --- a/app/services/auth_service.py +++ b/app/services/auth_service.py @@ -162,6 +162,8 @@ class AuthService: "is_system_admin": session_data.get("is_system_admin", False), "tenant_id": session_data["tenant_id"], "tenant_name": tenant.name if tenant else None, + # CSRF token from session — lets the frontend restore it on reload + "csrf_token": session_data.get("csrf_token"), } async def switch_tenant( diff --git a/frontend/src/__tests__/ai/streamChat.test.ts b/frontend/src/__tests__/ai/streamChat.test.ts new file mode 100644 index 0000000..4e1bf1b --- /dev/null +++ b/frontend/src/__tests__/ai/streamChat.test.ts @@ -0,0 +1,56 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { getCsrfToken, setCsrfToken } from '@/api/client'; +import { streamChat } from '@/api/ai'; + +describe('streamChat CSRF header', () => { + const originalFetch = globalThis.fetch; + + beforeEach(() => { + setCsrfToken('test-csrf-token'); + globalThis.fetch = vi.fn().mockResolvedValue( + new Response( + 'data: {"type":"done"}\n\ndata: [DONE]\n\n', + { + status: 200, + headers: { 'Content-Type': 'text/event-stream' }, + }, + ), + ) as unknown as typeof fetch; + }); + + afterEach(() => { + setCsrfToken(null); + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + it('sends the shared client CSRF token as X-CSRF-Token header', async () => { + expect(getCsrfToken()).toBe('test-csrf-token'); + + const gen = streamChat('conv-1', 'Hallo'); + for await (const _ of gen) { + // consume stream until [DONE] + } + + const fetchMock = globalThis.fetch as ReturnType; + expect(fetchMock).toHaveBeenCalledTimes(1); + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + const headers = init.headers as Record; + // Regression: token previously came from a never-written sessionStorage key → 403 + expect(headers['X-CSRF-Token']).toBe('test-csrf-token'); + }); + + it('targets the conversation stream endpoint with JSON body', async () => { + const gen = streamChat('conv-42', 'Frage', 'agent-9'); + for await (const _ of gen) { + // consume stream until [DONE] + } + + const fetchMock = globalThis.fetch as ReturnType; + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe('/api/v1/ai/conversations/conv-42/stream'); + expect(init.method).toBe('POST'); + expect(init.credentials).toBe('include'); + expect(JSON.parse(init.body as string)).toEqual({ content: 'Frage', agent_id: 'agent-9' }); + }); +}); diff --git a/frontend/src/api/ai.ts b/frontend/src/api/ai.ts index 8e8f03e..610360f 100644 --- a/frontend/src/api/ai.ts +++ b/frontend/src/api/ai.ts @@ -5,7 +5,7 @@ * AI Assistant plugin routes under `/ai/...`. */ -import { apiDelete, apiGet, apiPost, apiPut } from './client'; +import { apiDelete, apiGet, apiPost, apiPut, getCsrfToken } from './client'; // ─── Types ─── @@ -126,7 +126,7 @@ export async function* streamChat( content: string, agentId?: string ): AsyncGenerator { - const csrfToken = sessionStorage.getItem('leocrm_csrf_token'); + const csrfToken = getCsrfToken(); const response = await fetch(`/api/v1/ai/conversations/${conversationId}/stream`, { method: 'POST', headers: { diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index 18005d9..612fb06 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -70,6 +70,10 @@ export function useCurrentUser() { queryKey: ['currentUser'], queryFn: async () => { const data = await apiGet('/auth/me'); + // Restore CSRF token after page reload — /auth/me returns it from the session + if (data.csrf_token) { + setCsrfToken(data.csrf_token); + } setUser(data.user || data); setAuthenticated(true); return data; diff --git a/tests/test_auth.py b/tests/test_auth.py index d496799..c7116cc 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -60,6 +60,24 @@ class TestAuthMe: assert "tenant_id" in data assert "tenant_name" in data + async def test_me_returns_csrf_token_for_reload_restore(self, client: AsyncClient, db_session): + """Regression (403 after page reload): /me returns the session csrf_token + so the frontend can restore it and mutations keep working.""" + await seed_tenant_and_users(db_session) + login_resp = await client.post( + "/api/v1/auth/login", + json={"email": "admin@tenanta.com", "password": "TestPass123!"}, + headers=ORIGIN_HEADER, + ) + assert login_resp.status_code == 200 + login_data = login_resp.json() + csrf_from_login = login_data["csrf_token"] + + resp = await client.get("/api/v1/auth/me") + assert resp.status_code == 200 + data = resp.json() + assert data["csrf_token"] == csrf_from_login + @pytest.mark.asyncio class TestAuthLogout: