From 25f3c1df4dc93b22105c5336fa48ac2646b405af Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 19:29:47 +0000 Subject: [PATCH] Kundenansicht: zentraler Waechter (2 h, Verwechslungsschutz, Schreibsperre ausser News) Co-Authored-By: Claude Opus 5.5 --- server/index.js | 4 + server/middleware/kundenansichtGuard.js | 56 ++++++++ server/routes/news.js | 14 +- test/kundenansichtGuard.test.js | 184 ++++++++++++++++++++++++ test/newsRoutes.test.js | 4 +- 5 files changed, 250 insertions(+), 12 deletions(-) create mode 100644 server/middleware/kundenansichtGuard.js create mode 100644 test/kundenansichtGuard.test.js diff --git a/server/index.js b/server/index.js index 5deed6d..8a7c77d 100644 --- a/server/index.js +++ b/server/index.js @@ -42,6 +42,7 @@ import adminSwitchLinkRoutes from './routes/admin/switchLink.js' import adminFinanzenRoutes from './routes/admin/finanzen.js' import teamRoutes from './routes/team/index.js' import { corsMiddleware } from './middleware/cors.js' +import { kundenansichtGuard } from './middleware/kundenansichtGuard.js' const __dirname = path.dirname(fileURLToPath(import.meta.url)) const publicDir = path.join(__dirname, '..', 'public') @@ -51,6 +52,9 @@ assertServerConfig() const app = express() app.set('trust proxy', 1) app.use(sessionMiddleware()) +// Kundenansicht (Mitarbeiter im Portal eines Kunden): Ablauf, Verwechslungsschutz und Schreibsperre vor allen +// Kunden-Routen, auch vor dem 15-MB-Parser der Gutschriften +app.use(kundenansichtGuard) // Uebernahmegutschrift: Nachweise kommen als Base64 im JSON (max. 10 MB Dateien) -> eigenes Limit, // muss vor dem globalen Parser stehen (der ueberspringt bereits geparste Bodies). Login-Pruefung // zuerst, damit niemand ohne Session grosse Bodies parsen laesst. diff --git a/server/middleware/kundenansichtGuard.js b/server/middleware/kundenansichtGuard.js new file mode 100644 index 0000000..4a5d0cd --- /dev/null +++ b/server/middleware/kundenansichtGuard.js @@ -0,0 +1,56 @@ +import { parsePortalSession } from './session.js' +import { endKundenansicht, isExpired, isKundenansicht, logOnce } from '../services/kundenansicht.js' + +/** + * Waechter der Kundenansicht vor allen Kunden-Routen (Spec 2026-10-01, Abschnitt 3): + * 1. abgelaufen (2 h) -> 401 view_expired, Cookies weg + * 2. fremde Kennung im Header X-Kundenansicht -> 409 view_replaced (Tab einer anderen Kundenansicht) + * 3. Lesen ist erlaubt; Schreiben nur mit passender Kennung und nur, was auf der Erlaubnisliste steht. + * Neue Schreib-Routen sind damit in der Kundenansicht gesperrt, bis sie bewusst auf die Liste kommen. + * Alte Ansichten aus dem Admin-Panel (ohne viewId) duerfen nichts schreiben ausser Ab- und Anmelden. + */ +export const KUNDENANSICHT_ALLOWED = [ + ['POST', /^\/api\/news\/?$/], + ['POST', /^\/api\/news\/image\/?$/], + ['PATCH', /^\/api\/news\/[^/]+\/?$/], + ['DELETE', /^\/api\/news\/[^/]+\/?$/], + ['POST', /^\/api\/funktionen\/analyse\/heatmap-link\/?$/], + ['POST', /^\/api\/auth\/kundenansicht\/ende\/?$/], + ['POST', /^\/api\/auth\/logout\/?$/], + ['POST', /^\/api\/auth\/login\/?$/], +] +const LEGACY_ALLOWED = [ + ['POST', /^\/api\/auth\/logout\/?$/], + ['POST', /^\/api\/auth\/login\/?$/], +] + +// Eigene Pruefung (Ticketsystem-JWT, Admin-Sitzung) oder Ablauf/Wechsel selbst behandelt (me, Einloesen) +const PASS_PREFIXES = ['/api/team/', '/api/admin/', '/api/portal-admin/'] +const PASS_EXACT = ['/api/auth/me', '/api/auth/kundenansicht'] +const READ_METHODS = ['GET', 'HEAD', 'OPTIONS'] + +const replaced = (res, session) => + res.status(409).json({ code: 'view_replaced', kunde: session.name || '', error: 'Diese Ansicht wurde durch eine andere Kundenansicht ersetzt.' }) + +export async function kundenansichtGuard(req, res, next) { + const session = parsePortalSession(req) + if (!session?.viewAs) return next() + const pathname = req.originalUrl.split('?')[0] + if (!pathname.startsWith('/api/')) return next() + if (PASS_PREFIXES.some((p) => pathname.startsWith(p)) || PASS_EXACT.includes(pathname.replace(/\/$/, ''))) return next() + + const current = isKundenansicht(session) + if (current && isExpired(session)) { + endKundenansicht(res) + await logOnce(session, 'abgelaufen') + return res.status(401).json({ code: 'view_expired', error: 'Die Kundenansicht ist abgelaufen. Bitte im Ticketsystem erneut öffnen.' }) + } + const header = req.get('X-Kundenansicht') + if (current && header && header !== session.viewId) return replaced(res, session) + if (READ_METHODS.includes(req.method)) return next() + if (current && !header) return replaced(res, session) + + const list = current ? KUNDENANSICHT_ALLOWED : LEGACY_ALLOWED + if (list.some(([method, re]) => method === req.method && re.test(pathname))) return next() + return res.status(403).json({ code: 'view_blocked', error: 'In der Kundenansicht nicht möglich.' }) +} diff --git a/server/routes/news.js b/server/routes/news.js index 9202aa0..39b9db1 100644 --- a/server/routes/news.js +++ b/server/routes/news.js @@ -34,13 +34,6 @@ function writeRateLimited(customerId) { return list.length > MAX_WRITES_PER_HOUR } -function rejectViewAs(req, res, next) { - if (req.session.viewAs) { - return res.status(403).json({ error: 'Im "Als Kunde ansehen"-Modus kann nichts geändert werden.' }) - } - return next() -} - function limitWrites(req, res, next) { if (writeRateLimited(getSessionCustomerId(req))) { return res.status(429).json({ error: 'Zu viele Änderungen in kurzer Zeit. Bitte versuch es in einer Stunde wieder.' }) @@ -82,7 +75,7 @@ router.get('/', requireSession, withProject(), async (req, res) => { } }) -router.post('/', requireSession, rejectViewAs, withProject(), limitWrites, async (req, res) => { +router.post('/', requireSession, withProject(), limitWrites, async (req, res) => { try { const post = await createPost({ customerId: getSessionCustomerId(req), projectId: req.projectId, input: req.body }) res.status(201).json(post) @@ -95,7 +88,6 @@ router.post('/', requireSession, rejectViewAs, withProject(), limitWrites, async router.post( '/image', requireSession, - rejectViewAs, withProject(), (req, res, next) => { if (newsImageRateLimited(getSessionCustomerId(req))) { @@ -131,7 +123,7 @@ router.get('/image/:projectId/:file', requireSession, async (req, res) => { } }) -router.patch('/:id', requireSession, rejectViewAs, withProject(), limitWrites, async (req, res) => { +router.patch('/:id', requireSession, withProject(), limitWrites, async (req, res) => { try { const post = await updatePost({ customerId: getSessionCustomerId(req), projectId: req.projectId, id: req.params.id, input: req.body }) res.json(post) @@ -140,7 +132,7 @@ router.patch('/:id', requireSession, rejectViewAs, withProject(), limitWrites, a } }) -router.delete('/:id', requireSession, rejectViewAs, withProject(), limitWrites, async (req, res) => { +router.delete('/:id', requireSession, withProject(), limitWrites, async (req, res) => { try { await deletePost({ customerId: getSessionCustomerId(req), projectId: req.projectId, id: req.params.id }) res.status(204).end() diff --git a/test/kundenansichtGuard.test.js b/test/kundenansichtGuard.test.js new file mode 100644 index 0000000..1dfb4b3 --- /dev/null +++ b/test/kundenansichtGuard.test.js @@ -0,0 +1,184 @@ +import { test, after } from 'node:test' +import assert from 'node:assert/strict' +import os from 'node:os' +import path from 'node:path' +import { mkdtempSync } from 'node:fs' +import { startAppwriteMock } from './support/appwriteMock.js' +import { startFakeStripe } from './support/fakeStripe.js' +import { useTestEnv } from './support/testEnv.js' +import { schemaKeys } from './support/schema.js' + +// Waechter der Kundenansicht: Ablauf nach 2 h, Verwechslungsschutz (X-Kundenansicht), Schreibsperre ausser News +process.env.NEWS_UPLOAD_DIR = mkdtempSync(path.join(os.tmpdir(), 'kundenansicht-guard-')) +const aw = await startAppwriteMock({ + schema: { + customerViewLog: schemaKeys('customerViewLog'), + portalFeatures: schemaKeys('portalFeatures'), + newsPosts: schemaKeys('newsPosts'), + }, +}) +const st = await startFakeStripe() +useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) +const { config } = await import('../server/config.js') +const { startTestApp } = await import('./support/testApp.js') +const { seedFixtures, customerCookie, kundenansichtCookie, viewAsCookie } = await import('./support/fixtures.js') +const { kundenansichtGuard, KUNDENANSICHT_ALLOWED } = await import('../server/middleware/kundenansichtGuard.js') +const { createStaffPreviewToken } = await import('../server/services/staffPreviewTokens.js') + +// Kunden-Router mit denselben Mount-Pfaden wie server/index.js +const MOUNTS = [ + ['/api/auth', (await import('../server/routes/auth.js')).default], + ['/api/projects', (await import('../server/routes/projects.js')).default], + ['/api/features', (await import('../server/routes/features.js')).default], + ['/api/news', (await import('../server/routes/news.js')).default], + ['/api', (await import('../server/routes/customerData.js')).default], + ['/api/chat', (await import('../server/routes/chat.js')).default], + ['/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], +] +const app = await startTestApp({ '/': kundenansichtGuard, ...Object.fromEntries(MOUNTS) }) +after(() => Promise.all([app.close(), aw.close(), st.close()])) + +seedFixtures(aw) +aw.seed('customerPortalAccess', { $id: 'pa1', customerId: 'c1', enabled: true }) +aw.seed('websiteProjects', { $id: 'p1', customerId: 'c1', projectName: 'Bäckerei', liveDomain: 'baeckerei.de' }) +aw.seed('portalFeatures', { $id: 'f_p1_news', customerId: 'c1', projectId: 'p1', featureKey: 'news', state: 'active', enabled: true, siteKey: 'abcdefghijklmnop' }) + +const VIEW = 'a'.repeat(32) +const note = { projectId: 'p1', type: 'note', title: 'Hallo', text: 'Wir haben offen', paper: 'sun', duration: '1m' } +const kv = (extra = {}) => ({ cookie: kundenansichtCookie(extra), headers: { 'x-kundenansicht': extra.viewId || VIEW } }) + +async function call(method, url, { cookie, headers = {}, body } = {}) { + const res = await fetch(app.url + url, { + method, + redirect: 'manual', + headers: { ...(cookie ? { cookie } : {}), ...(body !== undefined ? { 'content-type': 'application/json' } : {}), ...headers }, + body: body === undefined ? undefined : JSON.stringify(body), + }) + const text = await res.text() + let parsed = text + try { + parsed = text ? JSON.parse(text) : null + } catch { + /* Text-Antwort */ + } + return { status: res.status, body: parsed, headers: res.headers } +} + +/** Alle nicht-lesenden Routen der Router (router.stack), :param durch 'x' ersetzt. */ +function writeRoutes(mounts) { + const routes = [] + for (const [prefix, router] of mounts) { + for (const layer of router.stack) { + if (!layer.route) continue + for (const method of Object.keys(layer.route.methods)) { + if (['get', 'head', 'options', '_all'].includes(method)) continue + const sub = String(layer.route.path).replace(/:[A-Za-z]+/g, 'x') + routes.push({ method: method.toUpperCase(), path: `${prefix === '/api' ? '/api' : prefix}${sub === '/' ? '' : sub}` || '/' }) + } + } + } + return routes +} +const allowed = (method, p) => KUNDENANSICHT_ALLOWED.some(([m, re]) => m === method && re.test(p)) +const logEntries = () => [...aw.docs('customerViewLog').values()] +// geloeschtes Cookie: Ablauf 1970 (ein signiertes behaelt dabei einen leeren signierten Wert "s:.") +const clears = (res, name) => res.headers.getSetCookie().some((c) => c.startsWith(`${name}=`) && /Expires=Thu, 01 Jan 1970/.test(c)) + +test('jede Schreib-Route ausserhalb der Erlaubnisliste -> 403 view_blocked', async () => { + const routes = writeRoutes(MOUNTS) + assert.ok(routes.length >= 25, `nur ${routes.length} Schreib-Routen gefunden`) + let checked = 0 + for (const { method, path: p } of routes) { + if (allowed(method, p)) continue + const res = await call(method, p, kv()) + assert.equal(res.status, 403, `${method} ${p}`) + assert.equal(res.body.code, 'view_blocked', `${method} ${p}`) + checked += 1 + } + assert.ok(checked >= 20, `nur ${checked} gesperrte Routen geprueft`) +}) + +test('Erlaubnisliste: genau News, Analyse-Link, Ende, Logout und Login', () => { + const ok = [ + ['POST', '/api/news'], ['POST', '/api/news/image'], ['PATCH', '/api/news/abc'], ['DELETE', '/api/news/abc'], + ['POST', '/api/funktionen/analyse/heatmap-link'], ['POST', '/api/auth/kundenansicht/ende'], + ['POST', '/api/auth/logout'], ['POST', '/api/auth/login'], + ] + for (const [m, p] of ok) assert.ok(allowed(m, p), `${m} ${p}`) + 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']]) { + assert.ok(!allowed(m, p), `${m} ${p}`) + } +}) + +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) +}) + +test('News anlegen in der Kundenansicht erlaubt', async () => { + const res = await call('POST', '/api/news', { ...kv(), body: note }) + assert.equal(res.status, 201) +}) + +test('fremde Kennung -> 409 view_replaced mit Kundennamen, auch beim Lesen', async () => { + const res = await call('GET', '/api/projects', { cookie: kundenansichtCookie(), headers: { 'x-kundenansicht': 'b'.repeat(32) } }) + assert.equal(res.status, 409) + assert.deepEqual([res.body.code, res.body.kunde], ['view_replaced', 'Kunde Eins']) +}) + +test('Schreiben ohne Kennung -> 409, Lesen ohne Kennung -> erlaubt', async () => { + const write = await call('POST', '/api/news', { cookie: kundenansichtCookie(), body: note }) + assert.equal(write.status, 409) + assert.equal(write.body.code, 'view_replaced') + const read = await call('GET', '/api/news?projectId=p1', { cookie: kundenansichtCookie() }) + assert.equal(read.status, 200) +}) + +test('genau bei viewExpiresAt abgelaufen: 401, Cookies geloescht, Protokoll abgelaufen genau einmal', async (t) => { + const at = Date.now() + t.mock.method(Date, 'now', () => at) + const viewId = 'e'.repeat(32) + const opts = kv({ viewId, viewExpiresAt: at }) + const before = logEntries().filter((d) => d.action === 'abgelaufen').length + const first = await call('GET', '/api/projects', opts) + const second = await call('POST', '/api/news', { ...opts, body: note }) + for (const res of [first, second]) { + assert.equal(res.status, 401) + assert.equal(res.body.code, 'view_expired') + assert.equal(res.body.error, 'Die Kundenansicht ist abgelaufen. Bitte im Ticketsystem erneut öffnen.') + assert.ok(clears(res, config.cookieName), 'Portal-Cookie geloescht') + assert.ok(clears(res, 'preview_session'), 'Preview-Cookie geloescht') + } + const expired = logEntries().filter((d) => d.action === 'abgelaufen') + assert.equal(expired.length, before + 1) + assert.deepEqual([expired.at(-1).customerId, expired.at(-1).staffName], ['c1', 'Andrej']) +}) + +test('alte Ansicht aus admin.html (viewAsCookie, ohne viewId): Lesen ok, News anlegen 403, Logout ok', async () => { + const cookie = viewAsCookie('c1') + assert.equal((await call('GET', '/api/news?projectId=p1', { cookie })).status, 200) + const write = await call('POST', '/api/news', { cookie, body: note }) + assert.equal(write.status, 403) + assert.equal(write.body.code, 'view_blocked') + assert.equal((await call('POST', '/api/auth/logout', { cookie })).status, 200) +}) + +test('normale Kunden-Sitzung wird nicht beruehrt (News anlegen 201 ohne Header)', async () => { + const res = await call('POST', '/api/news', { cookie: customerCookie('c1'), body: note }) + assert.equal(res.status, 201) +}) + +test('abgelaufenes Cookie blockiert weder /api/auth/me noch das Einloesen eines neuen Links', async () => { + const cookie = kundenansichtCookie({ viewExpiresAt: Date.now() - 1000 }) + assert.equal((await call('GET', '/api/auth/me', { cookie })).status, 200) + const token = createStaffPreviewToken({ + purpose: 'kundenansicht', + customerId: 'c1', + staff: { role: 'admin', isAdmin: false, appwriteUserId: 'u_staff2', name: 'Andrej', email: 'andrej@webklar.com' }, + }) + const res = await call('GET', `/api/auth/kundenansicht?token=${token}`, { cookie }) + assert.equal(res.status, 302) + assert.equal(res.headers.get('location'), '/dashboard.html') +}) diff --git a/test/newsRoutes.test.js b/test/newsRoutes.test.js index 324285a..eda8bbc 100644 --- a/test/newsRoutes.test.js +++ b/test/newsRoutes.test.js @@ -20,7 +20,9 @@ const { customerCookie, seedFixtures } = await import('./support/fixtures.js') const { default: newsRoutes } = await import('../server/routes/news.js') const { default: publicNewsRoutes } = await import('../server/routes/publicNews.js') -const app = await startTestApp({ '/api/public/news': publicNewsRoutes, '/api/news': newsRoutes }) +// Waechter wie in server/index.js: er sperrt Schreibzugriffe der alten Ansicht "Als Kunde ansehen" +const { kundenansichtGuard } = await import('../server/middleware/kundenansichtGuard.js') +const app = await startTestApp({ '/': kundenansichtGuard, '/api/public/news': publicNewsRoutes, '/api/news': newsRoutes }) after(() => Promise.all([app.close(), aw.close(), st.close()])) seedFixtures(aw)