Terminplaner: oeffentliche Schnittstelle v1 mit Missbrauchsschutz

Router /api/public/termine/v1 (config, tage, zeiten, buchungen, widerruf, Token-Seiten /termin/*, Kalender-Abo) mit
eigenem 16-KB-Parser und Fehlermiddleware, CORS nach Herkunft, Honeypot und Formular-Token, Grenzen je IP (IPv6 je /64),
siteKey und Adresse in limits.js, Mindestantwortzeit (TERMINE_MIN_ANTWORT_MS) und 10-Sekunden-Cache fuer tage/zeiten.
Vor dem globalen express.json eingehaengt; /api/public/ ist vom Kundenansicht-Waechter ausgenommen.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-03 08:17:43 +00:00
1 parent 43ac05aa06
commit 2b0c05b8e9
10 files changed
+1928 -7

No files matched your search

+87 -1
View File
@@ -1,11 +1,25 @@
import { test, beforeEach } from 'node:test'
import assert from 'node:assert/strict'
import { zaehle, zaehleAlle, darfG08, darfK21NichtZugeordnet, resetTermineLimitsForTests } from '../server/services/termine/limits.js'
import {
zaehle,
zaehleAlle,
darfG08,
darfK21NichtZugeordnet,
darfAnfrage,
darfAnfrageSite,
darfBuchungsversuch,
darfHaltung,
darfWiderrufsversuch,
darfG01,
ipSchluessel,
resetTermineLimitsForTests,
} from '../server/services/termine/limits.js'
// Grenzen im Speicher: festes Fenster ab dem ersten Ereignis, alle-oder-keine bei mehreren Regeln, Obergrenze der Schluessel.
const T0 = new Date('2026-10-05T08:00:00.000Z')
const STUNDE = 60 * 60 * 1000
const TAG = 24 * STUNDE
const MINUTE = 60 * 1000
const plus = (ms) => new Date(T0.getTime() + ms)
beforeEach(() => resetTermineLimitsForTests())
@@ -81,3 +95,75 @@ test('resetTermineLimitsForTests leert alles', () => {
resetTermineLimitsForTests()
assert.equal(zaehle('a', 1, TAG, T0), true)
})
// ---------------------------------------------------------------- Aufgabe 16: IP, siteKey, G01
test('ipSchluessel: IPv4 bleibt, IPv4-in-IPv6 wird IPv4, IPv6 zaehlt je /64 (auch in anderer Schreibweise)', () => {
assert.equal(ipSchluessel('203.0.113.7'), '203.0.113.7')
assert.equal(ipSchluessel('::ffff:203.0.113.7'), '203.0.113.7')
assert.equal(ipSchluessel('::FFFF:203.0.113.7'), '203.0.113.7')
const a = ipSchluessel('2001:db8:1:2::1')
assert.equal(ipSchluessel('2001:0db8:0001:0002:ffff:ffff:ffff:ffff'), a, 'dieselbe /64, ausgeschrieben')
assert.equal(ipSchluessel('2001:DB8:1:2:abcd::9'), a, 'dieselbe /64, Grossbuchstaben und Kurzform')
assert.equal(ipSchluessel('2001:db8:1:2:0:0:0:0'), a)
assert.notEqual(ipSchluessel('2001:db8:1:3::1'), a, 'andere /64')
assert.notEqual(ipSchluessel('2001:db8:2:2::1'), a)
assert.equal(ipSchluessel('fe80::1%eth0'), ipSchluessel('fe80::2'), 'Zonenkennung faellt weg')
assert.equal(ipSchluessel('::1'), ipSchluessel('0:0:0:0:ffff:ffff:ffff:ffff'), 'Loopback ist die /64 ::')
assert.notEqual(ipSchluessel('203.0.113.7'), ipSchluessel('203.0.113.8'))
assert.notEqual(ipSchluessel('203.0.113.7'), a, 'IPv4 und IPv6 stossen nie zusammen')
})
test('ipSchluessel: fehlende oder unlesbare Adresse ergibt einen festen Ersatzschluessel (nie einen Fehler)', () => {
for (const wert of [undefined, null, '', ' ', 'kaputt', '1.2.3', '999.1.1.1', 42, {}]) {
assert.equal(ipSchluessel(wert), 'unbekannt', JSON.stringify(wert))
}
})
test('darfAnfrage: 120 je IP und Minute (IPv6 je /64); darfAnfrageSite: 600 je siteKey und Minute', () => {
assert.equal(Array.from({ length: 125 }, () => darfAnfrage('203.0.113.7', T0)).filter(Boolean).length, 120)
assert.equal(darfAnfrage('203.0.113.8', T0), true, 'andere IP')
assert.equal(darfAnfrage('203.0.113.7', plus(MINUTE)), true, 'nach einer Minute wieder')
resetTermineLimitsForTests()
const erlaubt = Array.from({ length: 125 }, (_, i) => darfAnfrage(`2001:db8:1:2::${i + 1}`, T0))
assert.equal(erlaubt.filter(Boolean).length, 120, 'alle Adressen der /64 teilen sich 120')
assert.equal(darfAnfrage('2001:db8:1:3::1', T0), true)
resetTermineLimitsForTests()
assert.equal(Array.from({ length: 605 }, () => darfAnfrageSite('abcdefghijklmnop', T0)).filter(Boolean).length, 600)
assert.equal(darfAnfrageSite('bcdefghijklmnopq', T0), true)
assert.equal(darfAnfrageSite('abcdefghijklmnop', plus(MINUTE)), true)
})
test('darfBuchungsversuch: 10 je IP und 10 Minuten (IPv6 je /64); darfHaltung: 60 je siteKey und Stunde', () => {
assert.equal(Array.from({ length: 12 }, () => darfBuchungsversuch('203.0.113.7', T0)).filter(Boolean).length, 10)
assert.equal(darfBuchungsversuch('203.0.113.7', plus(10 * MINUTE - 1)), false)
assert.equal(darfBuchungsversuch('203.0.113.7', plus(10 * MINUTE)), true)
assert.equal(darfBuchungsversuch('2001:db8::1', T0) && darfBuchungsversuch('2001:db8:0:0:1::1', T0), true, 'getrennt von der IPv4')
resetTermineLimitsForTests()
for (let i = 0; i < 5; i += 1) assert.equal(darfBuchungsversuch(`2001:db8:9:9::${i + 1}`, T0), true)
for (let i = 0; i < 5; i += 1) assert.equal(darfBuchungsversuch(`2001:db8:9:9:1::${i + 1}`, T0), true)
assert.equal(darfBuchungsversuch('2001:db8:9:9:ffff::1', T0), false, 'elf Versuche aus einer /64')
resetTermineLimitsForTests()
assert.equal(Array.from({ length: 65 }, () => darfHaltung('abcdefghijklmnop', T0)).filter(Boolean).length, 60)
assert.equal(darfHaltung('bcdefghijklmnopq', T0), true)
assert.equal(darfHaltung('abcdefghijklmnop', plus(STUNDE)), true)
})
test('darfWiderrufsversuch: 10 je IP und Stunde', () => {
assert.equal(Array.from({ length: 12 }, () => darfWiderrufsversuch('203.0.113.7', T0)).filter(Boolean).length, 10)
assert.equal(darfWiderrufsversuch('203.0.113.8', T0), true)
assert.equal(darfWiderrufsversuch('203.0.113.7', plus(STUNDE)), true)
})
test('darfG01: 5 je Adresse und Funktion und Tag; die Zaehler der Arten stoeren sich nicht', () => {
assert.deepEqual(Array.from({ length: 7 }, () => darfG01('f1', 'max@example.com', T0)), [true, true, true, true, true, false, false])
assert.equal(darfG01('f2', 'max@example.com', T0), true, 'andere Funktion')
assert.equal(darfG01('f1', 'anna@example.com', T0), true, 'andere Adresse')
assert.equal(darfG01('f1', 'max@example.com', plus(TAG - 1)), false)
assert.equal(darfG01('f1', 'max@example.com', plus(TAG)), true, 'am naechsten Tag wieder')
assert.equal(darfG08('f1', 'max@example.com', T0), true, 'G08 zaehlt getrennt von G01')
})