From cb6291410d051bb8a61ef542f4297b919cedbce8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 09:34:03 +0000 Subject: [PATCH] Shop Review A I4: Shop-Limit 300 je Stunde, hoechstens 3 offene Bestellungen je IP und Shop in 30 Minuten Die IP wird nur als gesalzener Kurz-Hash (sha256, 16 Hex-Zeichen) im Bestell-JSON abgelegt; der Tagesjob entfernt ihn bei der Anonymisierung mit. Die Pruefung laeuft unter der Bestandssperre, Honigtopf und Fehlversuche legen nichts an und zaehlen nicht. Antwort 429 zu_viele_offen mit Retry-After 1800. Co-Authored-By: Claude Opus 5.5 --- server/routes/publicShop.js | 10 +++--- server/services/shop/bestellungen.js | 22 +++++++++++-- server/services/shop/jobs.js | 1 + test/publicShopKasse.test.js | 46 ++++++++++++++++++++++++++-- test/shopJobs.test.js | 3 +- 5 files changed, 71 insertions(+), 11 deletions(-) diff --git a/server/routes/publicShop.js b/server/routes/publicShop.js index e2d08eb..43d9d47 100644 --- a/server/routes/publicShop.js +++ b/server/routes/publicShop.js @@ -3,7 +3,7 @@ import { config } from '../config.js' import { analyseDomains } from '../services/analyse.js' import { getDocument } from '../services/appwriteAdmin.js' import { bildMeta, bildPfad } from '../services/shop/bilder.js' -import { abschlussAusstehend, bestellungLesen, legeBestellungAn, pruefeRueckUrl, reserviert, schliesseAb, tokenPasst } from '../services/shop/bestellungen.js' +import { abschlussAusstehend, bestellungLesen, ipHashVon, legeBestellungAn, pruefeRueckUrl, reserviert, schliesseAb, tokenPasst } from '../services/shop/bestellungen.js' import { sessionHolen } from '../services/shop/connect.js' import { istGeoeffnet, oeffentlicheRechtstexte, shopPerSiteKey } from '../services/shop/kontext.js' import { euro } from '../services/shop/geld.js' @@ -458,7 +458,7 @@ router.post( const PRUEFEN_JE_IP = 60 const BESTELLUNG_JE_IP = 10 const BESTELLUNG_FENSTER_MS = 10 * MINUTE_MS -const BESTELLUNG_JE_SHOP = 60 +const BESTELLUNG_JE_SHOP = 300 const STATUS_JE_IP = 120 const STUNDE_MS = 60 * MINUTE_MS const BESTELL_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/ @@ -466,7 +466,7 @@ const BESTELL_TOKEN_RE = /^[A-Za-z0-9_-]{16,128}$/ const GESCHLOSSEN_TEXT = 'Der Shop ist gerade geschlossen.' const GESCHLOSSEN = () => fehlerMit(409, { _: GESCHLOSSEN_TEXT }, { code: 'geschlossen' }) // Fehlercodes aus berechne/legeBestellungAn, die Kaeufer sehen duerfen (Stripe-Fehler haben ebenfalls einen String-code) -const KAEUFER_CODES = new Set(['geschlossen', 'leer', 'nicht_verfuegbar', 'menge', 'variante', 'ausverkauft', 'lieferart', 'land', 'nur_abholung', 'mindestbestellwert', 'zu_viele', 'zahlung_nicht_moeglich']) +const KAEUFER_CODES = new Set(['geschlossen', 'leer', 'nicht_verfuegbar', 'menge', 'variante', 'ausverkauft', 'lieferart', 'land', 'nur_abholung', 'mindestbestellwert', 'zu_viele', 'zu_viele_offen', 'zahlung_nicht_moeglich']) const KONFLIKT_CODES = new Set(['ausverkauft', 'nicht_verfuegbar', 'nur_abholung']) /** Artikel-Map fuer berechne: id => normalisiertes Produkt (aus dem JSON-Text), aktiv aus Spalte und JSON (wie bei legeBestellungAn). */ @@ -487,7 +487,7 @@ function produktKarte(docs) { /** Fehler aus legeBestellungAn fuer den Kaeufer: eigene Fehler (bekannter code) mit ihrem Text, alles andere 503 ohne Rohtext. */ function bestellFehler(err) { if (typeof err?.code === 'string' && KAEUFER_CODES.has(err.code) && Number.isInteger(err.status) && err.status >= 400 && err.status < 600) { - const extra = { code: err.code, ...(typeof err.produktId === 'string' ? { produktId: err.produktId } : {}) } + const extra = { code: err.code, ...(typeof err.produktId === 'string' ? { produktId: err.produktId } : {}), ...(Number.isInteger(err.retryAfter) ? { retryAfter: err.retryAfter } : {}) } if (err.status < 500) return fehlerMit(err.status, { _: String(err.message) }, extra) return Object.assign(new Error(String(err.message)), { status: 503, fehler: { _: String(err.message) }, ...extra, dienst: true }) } @@ -562,7 +562,7 @@ router.post( if (istFalle(siteKey, b, now)) return { body: { url: pruefeRueckUrl(b.rueckUrl, ctx) } } try { - const { url } = await legeBestellungAn(ctx, { ...warenkorbEingabe(b), rueckUrl: b.rueckUrl }, { now }) + const { url } = await legeBestellungAn(ctx, { ...warenkorbEingabe(b), rueckUrl: b.rueckUrl }, { now, ipHash: ipHashVon(req.ip) }) return { body: { url } } } catch (err) { return bestellErgebnis(err) diff --git a/server/services/shop/bestellungen.js b/server/services/shop/bestellungen.js index 26ee603..dda288f 100644 --- a/server/services/shop/bestellungen.js +++ b/server/services/shop/bestellungen.js @@ -3,6 +3,7 @@ import { config } from '../../config.js' import { createDocument, getDocument, listAllDocuments, listDocuments, Query, updateDocument } from '../appwriteAdmin.js' import { analyseDomains } from '../analyse.js' import { withLocks } from '../keyedLock.js' +import { ipSchluessel } from '../termine/limits.js' import { checkoutAnlegen, erstatten, sessionAblaufen, sessionHolen } from './connect.js' import { datumKurz, heute, plusTage, uhrzeit, zuUtc } from '../termine/zeit.js' import { euro } from './geld.js' @@ -29,6 +30,10 @@ const NUMMER_VERSUCHE = 5 /** Stripe verlangt mindestens 30 Minuten; die Reserve deckt Zeit zwischen Berechnung und Aufruf ab. */ const SESSION_RESERVE_S = 30 const MAX_JSON = 60000 +/** Offene Bestellungen, die eine IP je Shop gleichzeitig haben darf (Fenster: Reservierungsdauer), und der Text dazu. */ +const OFFEN_JE_IP = 3 +const OFFEN_FENSTER_MS = 30 * 60 * 1000 +const ZU_VIELE_OFFEN_TEXT = 'Sie haben bereits offene Bestellungen. Bitte schließen Sie diese zuerst ab oder warten Sie 30 Minuten.' const GESCHLOSSEN_TEXT = 'Der Shop ist gerade geschlossen.' const ZAHLUNG_TEXT = 'Die Zahlung kann gerade nicht gestartet werden. Bitte versuchen Sie es später noch einmal.' @@ -37,6 +42,11 @@ const KLARNA_HINWEIS = 'Bitte aktiviere die Zahlungsarten in deinem Stripe-Dashboard unter Einstellungen > Zahlungsmethoden.' const col = () => config.collections.shopOrders + +/** Gesalzener Kurz-Hash der IP (16 Hex-Zeichen) fuer die Obergrenze offener Bestellungen; die Adresse selbst wird nie gespeichert. */ +export function ipHashVon(ip) { + return createHash('sha256').update(`shop-ip:${config.sessionSecret}:${ipSchluessel(ip)}`).digest('hex').slice(0, 16) +} const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert) function fehler(status, code, text, extra = {}) { @@ -237,10 +247,11 @@ async function storniere(orderId, daten, was, now) { * Preise, Bestand und Versand kommen immer aus der Datenbank; der Browser liefert nur Artikel, Mengen, Lieferart und Land. * @param {object} ctx Kontext aus shopPerSiteKey * @param {{ positionen: Array, lieferart: string, land?: string, rueckUrl?: string }} eingabe - * @param {{ now?: Date }} [opt] + * @param {{ now?: Date, ipHash?: string }} [opt] ipHash (ipHashVon): hoechstens 3 offene Bestellungen je IP in 30 Minuten (429 zu_viele_offen), + * der Hash steht in der Bestellung (wird vom Tagesjob mit anonymisiert) * @returns {Promise<{ url: string, orderId: string, bestellnummer: string }>} */ -export async function legeBestellungAn(ctx, eingabe, { now = new Date() } = {}) { +export async function legeBestellungAn(ctx, eingabe, { now = new Date(), ipHash } = {}) { const { shop } = ctx const e = shop.einstellungen const geschlossen = () => fehler(409, 'geschlossen', GESCHLOSSEN_TEXT) @@ -252,6 +263,12 @@ export async function legeBestellungAn(ctx, eingabe, { now = new Date() } = {}) const angelegt = await withLocks([`shop-bestand:${shop.id}`], async () => { const docs = await listeProdukte(shop.id) if (!(await istGeoeffnet(ctx, { produkte: docs }))) throw geschlossen() + if (typeof ipHash === 'string' && ipHash !== '') { + const seit = jetzt.getTime() - OFFEN_FENSTER_MS + const offene = await listAllDocuments(col(), [Query.equal('shopId', shop.id), Query.equal('status', 'offen')]) + const eigene = offene.filter((d) => new Date(d.erstelltAt).getTime() > seit && bestellDaten(d).ipHash === ipHash).length + if (eigene >= OFFEN_JE_IP) throw fehler(429, 'zu_viele_offen', ZU_VIELE_OFFEN_TEXT, { retryAfter: OFFEN_FENSTER_MS / 1000 }) + } const produkte = produktKarte(docs) const ergebnis = berechne({ produkte, @@ -288,6 +305,7 @@ export async function legeBestellungAn(ctx, eingabe, { now = new Date() } = {}) zahlungsarten: e.zahlungsarten, ...(ergebnis.lieferart === 'abholung' ? { abholung: { adresse: e.abholung.adresse, hinweis: e.abholung.hinweis } } : {}), rechtstexteHash: shop.rechtstexte.bestaetigt.hash, + ...(typeof ipHash === 'string' && ipHash !== '' ? { ipHash } : {}), verlauf: [{ at: jetzt.toISOString(), was: 'angelegt' }], } const json = JSON.stringify(daten) diff --git a/server/services/shop/jobs.js b/server/services/shop/jobs.js index d9a4c50..653b1eb 100644 --- a/server/services/shop/jobs.js +++ b/server/services/shop/jobs.js @@ -160,6 +160,7 @@ async function anonymisiereBestellungen(lauf, shopId, grenze) { const daten = bestellDaten(doc) delete daten.lieferadresse delete daten.rechnungsadresse + delete daten.ipHash daten.kaeufer = { name: '', email: '', telefon: '' } await updateDocument(sammlung, doc.$id, { kaeuferEmail: '', diff --git a/test/publicShopKasse.test.js b/test/publicShopKasse.test.js index 89dbaca..e1d9895 100644 --- a/test/publicShopKasse.test.js +++ b/test/publicShopKasse.test.js @@ -331,7 +331,7 @@ test('bestellung: fremde Origin 403 und unbekannter Shop aktiv:false; kein Rumpf assert.equal(orders().length, 0) }) -test('bestellung: Grenze 10 je IP und 10 Minuten (Retry-After 600), Grenze 60 je Shop und Stunde (3600)', async () => { +test('bestellung: Grenze 10 je IP und 10 Minuten (Retry-After 600), Grenze 300 je Shop und Stunde (3600) für alle Anfragen', async () => { const eigene = `10.8.8.${(n % 250) + 1}` for (let i = 0; i < 10; i += 1) assert.equal((await bestellen({ kontakt_url: 'x' }, { ip: eigene })).status, 200, `Versuch ${i + 1}`) const zuViele = await bestellen({ kontakt_url: 'x' }, { ip: eigene }) @@ -340,16 +340,56 @@ test('bestellung: Grenze 10 je IP und 10 Minuten (Retry-After 600), Grenze 60 je resetTermineLimitsForTests() let ok = 0 - for (let i = 0; i < 60; i += 1) { + for (let i = 0; i < 300; i += 1) { const res = await bestellen({ kontakt_url: 'x' }, { ip: `10.7.${Math.floor(i / 200)}.${(i % 200) + 1}` }) if (res.status === 200) ok += 1 } - assert.equal(ok, 60) + assert.equal(ok, 300) const shopGrenze = await bestellen({ kontakt_url: 'x' }, { ip: '10.7.9.9' }) assert.equal(shopGrenze.status, 429) assert.equal(kopf(shopGrenze, 'retry-after'), '3600') }) +test('bestellung I4: höchstens 3 offene Bestellungen je IP und Shop in 30 Minuten; andere IPs, abgeschlossene und alte Bestellungen zählen nicht', async () => { + await aufbau({ produkte: [artikel('a1', {})] }) + const eigene = `10.9.9.${(n % 250) + 1}` + for (let i = 0; i < 3; i += 1) assert.equal((await bestellen({}, { ip: eigene })).status, 200, `Bestellung ${i + 1}`) + assert.equal(orders().length, 3) + const vierte = await bestellen({}, { ip: eigene }) + assert.equal(vierte.status, 429) + assert.equal(vierte.body.code, 'zu_viele_offen') + assert.equal(vierte.body.fehler._, 'Sie haben bereits offene Bestellungen. Bitte schließen Sie diese zuerst ab oder warten Sie 30 Minuten.') + assert.equal(kopf(vierte, 'retry-after'), '1800') + assert.equal(orders().length, 3, 'keine vierte Bestellung') + assert.equal(sessionCalls().length, 3, 'keine vierte Stripe-Sitzung') + + // Der Hash steht in der Bestellung, die Adresse nicht + const json = orders()[0].bestellung + assert.match(JSON.parse(json).ipHash, /^[0-9a-f]{16}$/) + assert.ok(!JSON.stringify(orders()).includes(eigene), 'die IP-Adresse selbst wird nirgends gespeichert') + assert.equal(new Set(orders().map((o) => JSON.parse(o.bestellung).ipHash)).size, 1) + + // Eine andere IP ist nicht betroffen + assert.equal((await bestellen({}, { ip: '10.9.8.1' })).status, 200) + // Eine storniert/abgeschlossene Bestellung zählt nicht mehr + const erste = orders()[0] + aw.seed('shopOrders', { ...erste, status: 'storniert' }) + assert.equal((await bestellen({}, { ip: eigene })).status, 200) + assert.equal((await bestellen({}, { ip: eigene })).status, 429) + // Nach 30 Minuten sind die alten Bestellungen aus dem Fenster + setzeUhrFuerTests(() => new Date(JETZT.getTime() + 31 * 60 * 1000)) + const spaeter = await bestellen({ formToken: formToken(SITE_KEY, new Date(JETZT.getTime() + 20 * 60 * 1000)) }, { ip: eigene }) + assert.equal(spaeter.status, 200) +}) + +test('bestellung I4: Honigtopf-Anfragen und Fehlversuche legen nichts an und zählen nicht als offene Bestellung', async () => { + const eigene = `10.9.7.${(n % 250) + 1}` + for (let i = 0; i < 5; i += 1) await bestellen({ kontakt_url: 'x' }, { ip: eigene }) + await bestellen({ positionen: [] }, { ip: eigene }) + assert.equal(orders().length, 0) + assert.equal((await bestellen({}, { ip: eigene })).status, 200) +}) + test('bestellung: Mindestantwortzeit gilt auch bei Fehlern', async () => { config.shop.minAntwortMs = 200 const t0 = performance.now() diff --git a/test/shopJobs.test.js b/test/shopJobs.test.js index 8e447f9..2f57c23 100644 --- a/test/shopJobs.test.js +++ b/test/shopJobs.test.js @@ -109,7 +109,7 @@ test('anonymisierGrenze: 1. Januar Berlin, drei Jahre nach Ende des Bestelljahre test('Bestellung aus 2026 wird genau am 1. Januar 2030 (Berlin) anonymisiert, nicht eine Sekunde vorher', async () => { shop('s_a') - const o = bestellung('s_a', { erstelltAt: '2026-06-01T10:00:00.000Z' }) + const o = bestellung('s_a', { erstelltAt: '2026-06-01T10:00:00.000Z' }, { ipHash: 'abcdef0123456789' }) const vorher = await taeglicherJob(new Date('2029-12-31T22:59:59.000Z')) // 23:59:59 Berlin assert.equal(vorher.bestellungenAnonymisiert, 0) assert.equal(order(o).anonymisiertAt, undefined) @@ -122,6 +122,7 @@ test('Bestellung aus 2026 wird genau am 1. Januar 2030 (Berlin) anonymisiert, ni assert.equal(doc.kaeuferEmail, '') assert.ok(doc.anonymisiertAt) const d = daten(o) + assert.equal(d.ipHash, undefined, 'der IP-Hash wird mit anonymisiert') assert.deepEqual(d.kaeufer, { name: '', email: '', telefon: '' }) assert.equal('lieferadresse' in d, false) assert.equal('rechnungsadresse' in d, false)