Kundenansicht: Terminplaner-POSTs nur noch ueber eine explizite Liste

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-04 08:34:29 +00:00
1 parent a72380cc8b
commit 8c1055e8a0
2 files changed
+58 -4

No files matched your search

+7 -2
View File
@@ -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\/?$/],
+51 -2
View File
@@ -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)