From 8c1055e8a0c8c696d5864870c7350d960082d3c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 08:34:29 +0000 Subject: [PATCH] Kundenansicht: Terminplaner-POSTs nur noch ueber eine explizite Liste Co-Authored-By: Claude Sonnet 5.5 --- server/middleware/kundenansichtGuard.js | 9 ++++- test/kundenansichtGuard.test.js | 53 ++++++++++++++++++++++++- 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/server/middleware/kundenansichtGuard.js b/server/middleware/kundenansichtGuard.js index 7b194d7..297d7a7 100644 --- a/server/middleware/kundenansichtGuard.js +++ b/server/middleware/kundenansichtGuard.js @@ -15,8 +15,13 @@ export const KUNDENANSICHT_ALLOWED = [ ['PATCH', /^\/api\/news\/[^/]+\/?$/], ['DELETE', /^\/api\/news\/[^/]+\/?$/], ['POST', /^\/api\/funktionen\/analyse\/heatmap-link\/?$/], - // Terminplaner: Einstellungen und Termine kosten nichts und sind keine Erklaerungen im Namen des Kunden (Spec 2026-10-02, Abschnitt 9) - ['POST', /^\/api\/termine\/.+$/], + // Terminplaner: Einstellungen und Termine kosten nichts und sind keine Erklaerungen im Namen des Kunden (Spec 2026-10-02, + // Abschnitt 9). Genau diese sechs Pfade, jeder einzeln (keine Wildcard, Spec 2026-10-03): alles andere unter /api/termine, + // auch der Fragebogen (PUT /fragebogen, Bestellung), ist in der Kundenansicht gesperrt, ohne dass es einen Eintrag braucht. + ['POST', /^\/api\/termine\/einstellungen\/vorlage\/?$/], + ['POST', /^\/api\/termine\/ressourcen\/[^/]+\/feed\/?$/], + ['POST', /^\/api\/termine\/buchungen\/?$/], + ['POST', /^\/api\/termine\/buchungen\/[^/]+\/aktion\/?$/], ['PATCH', /^\/api\/termine\/einstellungen\/?$/], ['DELETE', /^\/api\/termine\/buchungen\/[^/]+\/?$/], ['POST', /^\/api\/auth\/kundenansicht\/ende\/?$/], diff --git a/test/kundenansichtGuard.test.js b/test/kundenansichtGuard.test.js index 72214c9..e8a628f 100644 --- a/test/kundenansichtGuard.test.js +++ b/test/kundenansichtGuard.test.js @@ -26,6 +26,7 @@ const { kundenansichtGuard, KUNDENANSICHT_ALLOWED } = await import('../server/mi const { createStaffPreviewToken } = await import('../server/services/staffPreviewTokens.js') // Kunden-Router mit denselben Mount-Pfaden wie server/index.js +const termineRouter = (await import('../server/routes/termine.js')).default const MOUNTS = [ ['/api/auth', (await import('../server/routes/auth.js')).default], ['/api/projects', (await import('../server/routes/projects.js')).default], @@ -36,7 +37,7 @@ const MOUNTS = [ ['/api/abo/takeover-credits', (await import('../server/routes/takeoverCredits.js')).default], ['/api/abo', (await import('../server/routes/abo.js')).default], ['/api/funktionen', (await import('../server/routes/funktionen.js')).default], - ['/api/termine', (await import('../server/routes/termine.js')).default], + ['/api/termine', termineRouter], ] // Oeffentliche Schnittstelle (ohne Session) wie in server/index.js hinter dem Waechter; nicht in MOUNTS, weil sie keine Kunden-Route ist const oeffentlich = (await import('../server/routes/publicTermine.js')).default @@ -113,11 +114,15 @@ test('Erlaubnisliste: genau News, Terminplaner, Analyse-Link, Ende, Logout und L ['DELETE', '/api/termine/buchungen/abc'], ['DELETE', '/api/termine/buchungen/abc/'], ] for (const [m, p] of ok) assert.ok(allowed(m, p), `${m} ${p}`) - assert.equal(KUNDENANSICHT_ALLOWED.length, 8 + 3, 'genau drei Termine-Eintraege dazu') + assert.equal(KUNDENANSICHT_ALLOWED.length, 8 + 6, 'genau sechs Termine-Eintraege dazu, keine Wildcard') for (const [m, p] of [ ['POST', '/api/chat/read'], ['POST', '/api/news/abc'], ['POST', '/api/auth/password'], ['POST', '/api/abo/checkout'], ['POST', '/api/funktionen/news/checkout'], // Terminplaner: nur der eigene Pfad, nur diese Methoden und Formen ['POST', '/api/termineX'], ['POST', '/api/termineX/buchungen'], ['POST', '/api/termine'], ['POST', '/api/termine/'], + // frueher durch die Wildcard POST /api/termine/.+ erlaubt, jetzt nur noch die ausgeschriebenen Pfade + ['POST', '/api/termine/zukunft'], ['POST', '/api/termine/fragebogen'], ['PUT', '/api/termine/fragebogen'], ['POST', '/api/termine/export.csv'], + ['POST', '/api/termine/buchungen/abc'], ['POST', '/api/termine/buchungen/abc/aktion/x'], ['POST', '/api/termine/einstellungen/vorlage/x'], + ['POST', '/api/termine/ressourcen/abc/feed/x'], ['POST', '/api/funktionen/termine/checkout'], ['PUT', '/api/termine/einstellungen'], ['PATCH', '/api/termine/buchungen/abc'], ['PATCH', '/api/termine/einstellungen/x'], ['DELETE', '/api/termine/einstellungen'], ['DELETE', '/api/termine/buchungen'], ['DELETE', '/api/termine/buchungen/abc/def'], ['DELETE', '/api/termine/ressourcen/abc'], ['PUT', '/api/termine/buchungen/abc'], @@ -126,6 +131,50 @@ test('Erlaubnisliste: genau News, Terminplaner, Analyse-Link, Ende, Logout und L } }) +// Spec 2026-10-03, Abschnitt 3: unbekannte Schreib-Pfade unter /api/termine sind in der Kundenansicht gesperrt, bevor der Router +// sie sieht (auch Pfade ohne Route und Gross-/Kleinschreibung); normale Kunden beruehrt der Waechter nicht +test('unbekanntes POST/PUT unter /api/termine in der Kundenansicht -> 403 view_blocked, normale Kunden nicht', async () => { + const faelle = [ + ['POST', '/api/termine/zukunft'], + ['PUT', '/api/termine/fragebogen'], + ['POST', '/api/termine/buchungen/abc/aktion/x'], + ['POST', '/API/Termine/Zukunft'], + ] + for (const [method, p] of faelle) { + const gesperrt = await call(method, p, { ...kv(), body: {} }) + assert.equal(gesperrt.status, 403, `${method} ${p}`) + assert.equal(gesperrt.body.code, 'view_blocked', `${method} ${p}`) + const kunde = await call(method, p, { cookie: customerCookie('c1'), body: {} }) + assert.notEqual(kunde.status, 403, `${method} ${p}: normaler Kunde`) + // PUT /fragebogen bekommt in Block C eine echte Route (dann antwortet sie selbst); die anderen Pfade gibt es nie + if (!p.endsWith('/fragebogen')) assert.equal(kunde.status, 404, `${method} ${p}: der Router kennt den Pfad nicht`) + } +}) + +// Router-Abgleich: die Erlaubnisliste und die echten Schreib-Routen des Terminplaners decken sich genau; eine neue Route faellt +// im Test "jede Schreib-Route ausserhalb der Erlaubnisliste -> 403" auf (sie muss gesperrt sein), ein toter Eintrag hier +test('Router-Abgleich: genau sechs Schreib-Routen des Terminplaners stehen auf der Erlaubnisliste', () => { + const erlaubt = writeRoutes([['/api/termine', termineRouter]]) + .filter(({ method, path: p }) => allowed(method, p)) + .map(({ method, path: p }) => `${method} ${p}`) + .sort() + assert.deepEqual(erlaubt, [ + 'DELETE /api/termine/buchungen/x', + 'PATCH /api/termine/einstellungen', + 'POST /api/termine/buchungen', + 'POST /api/termine/buchungen/x/aktion', + 'POST /api/termine/einstellungen/vorlage', + 'POST /api/termine/ressourcen/x/feed', + ]) + // und jeder Termine-Eintrag der Liste trifft genau eine echte Route (kein toter, kein doppelter Eintrag) + const routen = writeRoutes([['/api/termine', termineRouter]]) + const termineEintraege = KUNDENANSICHT_ALLOWED.filter(([, re]) => re.source.includes('termine')) + assert.equal(termineEintraege.length, 6) + for (const [m, re] of termineEintraege) { + assert.equal(routen.filter((r) => r.method === m && re.test(r.path)).length, 1, `${m} ${re}`) + } +}) + test('POST /api/chat/read ist gesperrt (Lesestatus bleibt unberuehrt)', async () => { const res = await call('POST', '/api/chat/read', kv()) assert.equal(res.status, 403)