From 0c5ca76a4d392b0a132923449f7cb8bb17c891be Mon Sep 17 00:00:00 2001 From: Kenso Grimm Date: Wed, 7 Oct 2026 14:10:30 +0000 Subject: [PATCH] =?UTF-8?q?Buchungskalender:=20=C3=B6ffentliche=20API=20ge?= =?UTF-8?q?gen=20manipulierte=20IDs=20und=20Lookups=20absichern?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- server/routes/publicUnterkunft.js | 11 +++-- server/services/unterkunft/speicher.js | 5 +- test/unterkunftPublic.test.js | 64 +++++++++++++++++++++++++- 3 files changed, 73 insertions(+), 7 deletions(-) diff --git a/server/routes/publicUnterkunft.js b/server/routes/publicUnterkunft.js index a5ece1b..695f71b 100644 --- a/server/routes/publicUnterkunft.js +++ b/server/routes/publicUnterkunft.js @@ -7,7 +7,7 @@ import { belegteBereiche } from '../services/unterkunft/belegung.js' import { istTag } from '../services/unterkunft/datum.js' import { UkFehler } from '../services/unterkunft/fehler.js' import { unterkunftPerSiteKey } from '../services/unterkunft/kontext.js' -import { ladeUnterkunft, listeUnterkuenfte } from '../services/unterkunft/speicher.js' +import { ID_RE, ladeUnterkunft, listeUnterkuenfte } from '../services/unterkunft/speicher.js' import { formToken, ipHashVon, pruefeFormToken } from '../services/unterkunft/token.js' import { darfAnfrage, darfAnfrageSite, darfBuchungsversuch, zaehle } from '../services/termine/limits.js' import { heute } from '../services/termine/zeit.js' @@ -257,17 +257,18 @@ router.get( const ctx = await unterkunftPerSiteKey(req.params.siteKey, { now }) if (!ctx) return aktivFalse() if (!darfAnfrageSite(req.params.siteKey, now)) throw zuViele(60) + if (typeof req.query.unterkunft === 'string' && !ID_RE.test(req.query.unterkunft)) throw ungueltig({ unterkunft: 'Ungültige Unterkunft.' }) const { unterkunft, von, bis } = req.query - if (typeof unterkunft !== 'string' || unterkunft === '' || !istTag(von) || !istTag(bis) || bis <= von) { + if (typeof unterkunft !== 'string' || unterkunft === '' || !istTag(von) || !istTag(bis) || bis < von) { throw ungueltig({ zeitraum: 'Ungültiger Zeitraum.' }) } const tag = heute(now) const bisMax = monateDazu(tag, config.unterkunft.horizontMonate) - if (bis > bisMax) throw ungueltig({ zeitraum: 'Der Zeitraum liegt zu weit in der Zukunft.' }) + const bisBis = bis > bisMax ? bisMax : bis // Fenster des Widgets (UTC-Datum des Browsers) darf nie an einem Tag Abweichung scheitern const u = await ladeUnterkunft(unterkunft) if (!u || u.featureId !== ctx.featureId || u.aktiv !== true) throw nichtGefunden() const ab = von < tag ? tag : von - const belegt = ab < bis ? verschmelze(await belegteBereiche(u.$id, ab, bis, { now })) : [] + const belegt = ab < bisBis ? verschmelze(await belegteBereiche(u.$id, ab, bisBis, { now })) : [] return { body: { belegt, heute: tag, bisMax } } }), ) @@ -287,6 +288,7 @@ router.post( route( async (req, now, cors) => { const { siteKey } = req.params + if (!darfAnfrage(req.ip, now)) throw zuViele(60) const ctx = await unterkunftPerSiteKey(siteKey, { now }) if (!ctx) return { body: { aktiv: false } } pruefeHerkunft(req, cors, ctx.project) @@ -299,6 +301,7 @@ router.post( if (pruefung === 'zu_schnell') return { status: 201, body: { nummer: scheinNummer() } } if (pruefung !== 'ok') throw fehlerMit(400, 'neu_laden') if (!zaehle(`uk-anfrage:${siteKey}`, config.unterkunft.anfragenJeSiteStunde, STUNDE_MS, now)) throw zuViele(3600) + if (!ID_RE.test(text(b.unterkunft))) throw ungueltig({ unterkunft: 'Ungültige Unterkunft.' }) const personen = typeof b.personen === 'string' && /^\d{1,3}$/.test(b.personen) ? Number(b.personen) : b.personen const anfrage = await legeAnfrageAn( ctx, diff --git a/server/services/unterkunft/speicher.js b/server/services/unterkunft/speicher.js index 7e95ef3..c402c23 100644 --- a/server/services/unterkunft/speicher.js +++ b/server/services/unterkunft/speicher.js @@ -41,8 +41,11 @@ function schreiben(name, felder) { const nichtGefunden = (err) => err?.status === 404 && err.type !== 'collection_not_found' && err.type !== 'database_not_found' +/** Dokument-IDs landen roh im URL-Pfad: nur harmlose Zeichen durchlassen. */ +export const ID_RE = /^[A-Za-z0-9_]{1,36}$/ + async function ladeDok(name, id) { - if (!id) return null + if (typeof id !== 'string' || !ID_RE.test(id)) return null try { return lesen(name, await getDocument(config.collections[name], id)) } catch (err) { diff --git a/test/unterkunftPublic.test.js b/test/unterkunftPublic.test.js index 67df967..ae8b105 100644 --- a/test/unterkunftPublic.test.js +++ b/test/unterkunftPublic.test.js @@ -114,9 +114,14 @@ test('Belegung: fasst angrenzende Bereiche zusammen, ohne Quelle, auf das Fenste }) test('Belegung: ueber 18 Monate hinaus, kaputte Daten, fremde oder inaktive Unterkunft', async () => { - assert.equal((await send('GET', `${BASE}/belegung?unterkunft=u1&von=2026-11-01&bis=2028-05-02`)).status, 400) + aw.seed('ukSperren', { $id: 'sz', unterkunftId: 'u1', featureId: FEATURE, von: '2028-04-28', bis: '2028-06-10', quelle: 'manuell' }) + const geklemmt = await send('GET', `${BASE}/belegung?unterkunft=u1&von=2028-04-01&bis=2030-01-01`) + assert.equal(geklemmt.status, 200) + assert.equal(geklemmt.body.bisMax, '2028-05-01') + assert.deepEqual(geklemmt.body.belegt, [{ von: '2028-04-28', bis: '2028-05-01' }]) + assert.equal((await send('GET', `${BASE}/belegung?unterkunft=u1&von=2026-11-01&bis=2028-05-02`)).status, 200) + assert.equal((await send('GET', `${BASE}/belegung?unterkunft=u1&von=2026-11-05&bis=2026-11-04`)).status, 400) assert.equal((await send('GET', `${BASE}/belegung?unterkunft=u1&von=2026-11-01&bis=2028-05-01`)).status, 200) - assert.equal((await send('GET', `${BASE}/belegung?unterkunft=u1&von=2026-11-05&bis=2026-11-05`)).status, 400) assert.equal((await send('GET', `${BASE}/belegung?unterkunft=u1&von=morgen&bis=2026-11-05`)).status, 400) assert.equal((await send('GET', `${BASE}/belegung?von=2026-11-01&bis=2026-11-05`)).status, 400) assert.equal((await send('GET', `${BASE}/belegung?unterkunft=u3&von=2026-11-01&bis=2026-11-05`)).status, 404) @@ -200,3 +205,58 @@ test('CORS: Preflight erlaubt/fremd, POST von fremder oder fehlender Origin -> 4 assert.equal(unbekannt.status, 204) assert.equal(unbekannt.headers.get('access-control-allow-origin'), '*') }) + +test('Fix: manipulierte Unterkunfts-IDs -> 400 (Belegung, Anfrage); Speicher fragt nicht nach', async () => { + for (const id of ['../x', 'a?b', 'a%23b', 'x'.repeat(37)]) { + const r = await send('GET', `${BASE}/belegung?unterkunft=${encodeURIComponent(id)}&von=2026-11-01&bis=2026-11-05`) + assert.equal(r.status, 400, id) + assert.ok(r.body.felder.unterkunft) + const p = await post(anfrage({ unterkunft: id })) + assert.equal(p.status, 400, id) + assert.ok(p.body.felder.unterkunft) + } + assert.equal(aw.docs('ukAnfragen').size, 0) + const sp = await import('../server/services/unterkunft/speicher.js') + const echtesFetch = globalThis.fetch + let aufrufe = 0 + globalThis.fetch = (...a) => (aufrufe++, echtesFetch(...a)) + try { + for (const id of ['../x', 'a?b', '', 'x'.repeat(37), 5, null]) assert.equal(await sp.ladeUnterkunft(id), null) + assert.equal(await sp.ladeAnfrage('../../x'), null) + } finally { + globalThis.fetch = echtesFetch + } + assert.equal(aufrufe, 0) +}) + +test('Fix: POST prueft die IP-Grenze vor dem siteKey-Lookup', async () => { + const echtesFetch = globalThis.fetch + let aufrufe = 0 + globalThis.fetch = (u, ...a) => (String(u?.url ?? u).startsWith(aw.url) && aufrufe++, echtesFetch(u, ...a)) + try { + const stati = [] + for (let i = 0; i < 125; i++) { + const key = ('a'.repeat(15) + String.fromCharCode(97 + (i % 26))).slice(0, 16) + stati.push((await send('POST', `/api/public/unterkunft/v1/${key}/anfrage`, { body: anfrage(), headers: { origin: ORIGIN } })).status) + } + assert.ok(aufrufe > 0, 'unter der Grenze wird nachgeschlagen') + assert.equal(stati[119], 200) + assert.deepEqual(stati.slice(120), [429, 429, 429, 429, 429]) + const nachher = aufrufe + await send('POST', `${BASE}/anfrage`, { body: anfrage(), headers: { origin: ORIGIN } }) + assert.equal(aufrufe, nachher, 'ueber der IP-Grenze keine Lookups mehr') + } finally { + globalThis.fetch = echtesFetch + } +}) + +test('Fix: Pilotmodus mit fremdem Besitzer -> {aktiv:false}; zu grosser Rumpf -> 413', async () => { + config.unterkunft.mode = 'pilot' + config.unterkunft.pilotEmails = ['andere@example.com'] + K.leereCache() + assert.deepEqual((await send('GET', BASE)).body, { aktiv: false }) + config.unterkunft.pilotEmails = ['mimi@example.com'] + assert.equal((await send('GET', BASE)).body.aktiv, true) + const gross = await post(anfrage({ nachricht: 'x'.repeat(20000) })) + assert.equal(gross.status, 413) +})