Compare commits
3
Commits
a28c8355fd
...
8e46dbbfed
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e46dbbfed | ||
|
|
955fceb3b8 | ||
|
|
d0619141bb |
@@ -30,6 +30,7 @@ if (!process.env.DATABASE_URL && process.env.DB_USER && process.env.DB_PASSWORD
|
||||
process.env.DATABASE_URL = `mysql://${u}:${p}@${h}:${port}/${process.env.DB_NAME}`;
|
||||
}
|
||||
|
||||
import { apiBackstopRateLimiter } from './middleware/rateLimit.js';
|
||||
import authRoutes from './routes/auth.routes.js';
|
||||
import customerRoutes from './routes/customer.routes.js';
|
||||
import addressRoutes from './routes/address.routes.js';
|
||||
@@ -346,6 +347,11 @@ app.use('/api', (req, res, next) => {
|
||||
next();
|
||||
});
|
||||
|
||||
// Globaler Backstop-Rate-Limiter für ALLE /api-Requests (Pentest R148).
|
||||
// Großzügige Obergrenze pro IP – ergänzt die feineren Limiter (Login etc.),
|
||||
// die als erste greifen. Siehe middleware/rateLimit.ts für die Begründung.
|
||||
app.use('/api', apiBackstopRateLimiter);
|
||||
|
||||
// Öffentliche Routes (OHNE Authentifizierung)
|
||||
app.use('/api/public/consent', consentPublicRoutes);
|
||||
|
||||
|
||||
@@ -132,6 +132,47 @@ export const staffPasswordReAuthLimiter = rateLimit({
|
||||
* (Pentest 2026-05-20 INFO 28.4). 30 Requests pro 15 min pro IP reicht
|
||||
* für legitime Kunden weit aus.
|
||||
*/
|
||||
/**
|
||||
* Globaler Backstop-Limiter für ALLE /api-Requests (Pentest R148).
|
||||
*
|
||||
* Hintergrund: Es gab bislang KEINEN generellen /api-Limiter – nur die
|
||||
* dedizierten oben (Login, Passwort-Reset, Staff-Re-Auth, Consent). Damit war
|
||||
* jeder authentifizierte Endpoint gegen Enumeration/Scripted-Abuse/DoS
|
||||
* ungedrosselt. Dieser Limiter ist eine großzügige Obergrenze, KEIN Ersatz für
|
||||
* die feineren Limiter (die feuern früher und bleiben aktiv).
|
||||
*
|
||||
* Key = NUR die (IPv6-/56-normalisierte) IP. Bewusst NICHT (IP+User): den
|
||||
* User-Claim könnten wir hier nur unverifiziert aus dem Token lesen (die volle
|
||||
* `authenticate`-Prüfung inkl. DB läuft erst pro Route). Ein Angreifer könnte
|
||||
* dann mit gefälschten userId-Claims beliebig frische Buckets erzeugen und den
|
||||
* Backstop umgehen. Per-Account-Präzision liefern ohnehin die Login-Limiter.
|
||||
*
|
||||
* Limit per Env `API_RATE_LIMIT_PER_MIN` (Default 1200/min ≈ 20/s pro IP) –
|
||||
* für legitime Nutzung (auch mehrere Nutzer hinter NAT) weit ausreichend,
|
||||
* bremst aber Flooding massiv. Healthcheck (`/api/health`) ist ausgenommen.
|
||||
*
|
||||
* KEIN SecurityEvent pro geblocktem Request: Bei einem Flood würde das den
|
||||
* Security-/Audit-Store selbst zumüllen (Amplification). Die 429 stehen im
|
||||
* Access-Log; die feineren Limiter melden weiterhin an das Monitoring.
|
||||
*/
|
||||
const API_RATE_LIMIT_PER_MIN = Math.max(
|
||||
parseInt(process.env.API_RATE_LIMIT_PER_MIN || '', 10) || 1200,
|
||||
60,
|
||||
);
|
||||
|
||||
export const apiBackstopRateLimiter = rateLimit({
|
||||
windowMs: 60 * 1000,
|
||||
limit: API_RATE_LIMIT_PER_MIN,
|
||||
standardHeaders: 'draft-7',
|
||||
legacyHeaders: false,
|
||||
message: {
|
||||
success: false,
|
||||
error: 'Zu viele Anfragen in kurzer Zeit. Bitte einen Moment warten.',
|
||||
},
|
||||
keyGenerator: (req): string => ipKeyGenerator(req.ip || 'unknown'),
|
||||
skip: (req) => req.path === '/health',
|
||||
});
|
||||
|
||||
export const publicConsentRateLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
limit: 30,
|
||||
|
||||
@@ -35,7 +35,7 @@ const REGISTRY_BASE = process.env.BLZ_REGISTRY_BASE || 'https://registry.npmjs.o
|
||||
const FETCH_TIMEOUT_MS = 20_000;
|
||||
|
||||
// ---- Typen ----
|
||||
type BankTuple = [string, string]; // [bankName, bic]
|
||||
type BankTuple = [string, string?]; // [bankName, bic?] – BIC fehlt bei manchen Banken
|
||||
type CurrentData = Record<string, BankTuple>;
|
||||
interface NextData {
|
||||
valid: string;
|
||||
@@ -159,7 +159,7 @@ export function lookupBlz(blz: string): BankInfo | null {
|
||||
if (!/^[1-9]\d{7}$/.test(blz)) return null;
|
||||
const entry = combinedData()[blz];
|
||||
if (!entry) return null;
|
||||
return { bankName: entry[0], bic: entry[1], blz };
|
||||
return { bankName: entry[0], bic: entry[1] ?? '', blz };
|
||||
}
|
||||
|
||||
/** Extrahiert die BLZ aus einer deutschen IBAN und schlägt sie nach. */
|
||||
@@ -171,20 +171,53 @@ export function lookupByIban(iban: string): BankInfo | null {
|
||||
|
||||
// ---- Validierung des Roh-Datensatzes (gegen Müll/HTML-Antworten) ----
|
||||
|
||||
// Prüft eine BLZ→[Name,BIC]-Map vollständig: JEDER Schlüssel eine 8-stellige
|
||||
// BLZ, JEDER Wert genau [string, string] (BIC darf leer sein). Nicht nur die
|
||||
// erste Zeile (Pentest R148, Code-Note) – sonst käme ein Set mit 1 echten +
|
||||
// 999 Fake-Einträgen durch.
|
||||
function assertValidBankMap(map: Record<string, unknown>, label: string): void {
|
||||
for (const [blz, entry] of Object.entries(map)) {
|
||||
if (!/^\d{8}$/.test(blz)) {
|
||||
throw new Error(`${label}: ungültige BLZ "${blz}"`);
|
||||
}
|
||||
// Wert ist [Name] (Bank ohne BIC) ODER [Name, BIC]. Beides ist in der
|
||||
// echten Bundesbank-Datei vorhanden (z.B. reine Zahlungsverkehr-BLZ).
|
||||
if (
|
||||
!Array.isArray(entry) ||
|
||||
entry.length < 1 ||
|
||||
entry.length > 2 ||
|
||||
typeof entry[0] !== 'string' ||
|
||||
(entry.length === 2 && typeof entry[1] !== 'string')
|
||||
) {
|
||||
throw new Error(`${label}: Eintrag ${blz} hat unerwartetes Format`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function assertValidRawDataset(raw: any): asserts raw is RawDataset {
|
||||
if (!raw || typeof raw !== 'object') throw new Error('Datensatz ist kein Objekt');
|
||||
const cur = raw.current;
|
||||
if (!cur || typeof cur !== 'object' || Array.isArray(cur)) throw new Error('current fehlt/ungültig');
|
||||
const keys = Object.keys(cur);
|
||||
if (keys.length < 1000) throw new Error(`current zu klein (${keys.length} Einträge)`);
|
||||
const sample = cur[keys[0]];
|
||||
if (!Array.isArray(sample) || sample.length !== 2 || typeof sample[0] !== 'string' || typeof sample[1] !== 'string') {
|
||||
throw new Error('current-Eintrag hat unerwartetes Format');
|
||||
}
|
||||
// VOLLSTÄNDIGE Formatprüfung aller Einträge, nicht nur des ersten.
|
||||
assertValidBankMap(cur, 'current');
|
||||
|
||||
const next = raw.next;
|
||||
if (!next || typeof next !== 'object' || typeof next.valid !== 'string' || typeof next.upsert !== 'object' || !Array.isArray(next.remove)) {
|
||||
if (!next || typeof next !== 'object' || typeof next.valid !== 'string' || typeof next.upsert !== 'object' || Array.isArray(next.upsert) || !Array.isArray(next.remove)) {
|
||||
throw new Error('next fehlt/ungültig');
|
||||
}
|
||||
// Datum plausibel?
|
||||
if (Number.isNaN(new Date(next.valid).getTime())) {
|
||||
throw new Error('next.valid ist kein gültiges Datum');
|
||||
}
|
||||
// upsert-Einträge ebenso streng; remove muss aus 8-stelligen BLZ bestehen.
|
||||
assertValidBankMap(next.upsert, 'next.upsert');
|
||||
for (const blz of next.remove) {
|
||||
if (typeof blz !== 'string' || !/^\d{8}$/.test(blz)) {
|
||||
throw new Error(`next.remove enthält ungültige BLZ "${blz}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Ausgehende Requests (nur Updater) ----
|
||||
|
||||
@@ -97,6 +97,23 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung
|
||||
|
||||
## ✅ Erledigt
|
||||
|
||||
- [x] **🛡️ Globaler API-Rate-Limit-Backstop + BLZ-Guard-Härtung (Pentest R148)** (2026-08-12)
|
||||
- **Backstop:** Neuer genereller Limiter auf ALLE `/api`-Requests
|
||||
(`apiBackstopRateLimiter`, in `index.ts` vor den Routern). Vorher gab es
|
||||
KEINEN generellen Limiter – nur Login/Passwort-Reset/Staff-ReAuth/Consent;
|
||||
authentifizierte Endpoints waren gegen Enumeration/DoS ungedrosselt.
|
||||
Key = **nur IPv6-/56-normalisierte IP** (bewusst nicht IP+User: der
|
||||
User-Claim wäre hier nur unverifiziert lesbar → Bypass per Fake-Token).
|
||||
Limit per Env `API_RATE_LIMIT_PER_MIN` (Default **1200/min**, Floor 60),
|
||||
`/api/health` ausgenommen. Kein SecurityEvent pro Block (sonst Flood-
|
||||
Amplification). Verifiziert (60×200→429, health bleibt 200).
|
||||
- **Deckt den offenen IPv6-Test mit ab:** außerhalb der Auth-Pfade greift jetzt
|
||||
ebenfalls ein IPv6-normalisierter Limiter.
|
||||
- **BLZ-Poisoning-Guard:** prüft jetzt JEDEN Dataset-Eintrag (nicht nur die
|
||||
erste Zeile) – 8-stellige BLZ, Wert `[Name]` oder `[Name,BIC]`,
|
||||
`next.remove` = BLZ-Liste, `next.valid` = gültiges Datum. Banken **ohne BIC**
|
||||
(`[Name]`, z.B. BLZ 60050009) korrekt zugelassen → `lookupBlz` liefert `bic:''`.
|
||||
|
||||
- [x] **🔄 BLZ-/Bankdaten: Auto-Update via Volume + Einstellungen** (2026-08-12)
|
||||
- Neue Einstellungen-Seite **Einstellungen → Bankdaten (BLZ)** (`/settings/bank-data`):
|
||||
zeigt Datenstand (aktive Quelle Volume/Image, Version, Anzahl Banken,
|
||||
|
||||
@@ -2508,12 +2508,14 @@ function AddressModal({
|
||||
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
// Beim Öffnen / Wechsel zwischen Adressen aus den Props re-initialisieren.
|
||||
// Vorher als unbedingte if-setState im Render-Body → Reset bei jedem
|
||||
// Tastendruck, Straße ließ sich nicht eintippen.
|
||||
// Guard `if (!isOpen)`: Modal bleibt dauerhaft gemountet – ohne Reset beim
|
||||
// Öffnen blieben nach „Abbrechen" die vorher getippten Daten stehen.
|
||||
// Deps nur isOpen + Adress-ID → kein Reset bei jedem Tastendruck.
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
setFormData(getInitialFormData());
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [address?.id]);
|
||||
}, [isOpen, address?.id]);
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={isEditing ? 'Adresse bearbeiten' : 'Adresse hinzufügen'}>
|
||||
@@ -2695,11 +2697,6 @@ function BankCardModal({
|
||||
const [ibanLookupLoading, setIbanLookupLoading] = useState(false);
|
||||
const [showIbanRequired, setShowIbanRequired] = useState(false);
|
||||
|
||||
// Reset form when bankCard changes
|
||||
useState(() => {
|
||||
setFormData(getInitialFormData());
|
||||
});
|
||||
|
||||
// BIC + Bankname aus der IBAN ableiten (offline über Bundesbank-BLZ im
|
||||
// Backend). Ist das IBAN-Feld leer, kommt eine OK-Messagebox statt einer
|
||||
// Anfrage. Gleichzeitig prüft der Server die IBAN-Prüfziffer (Tippfehler).
|
||||
@@ -2772,12 +2769,19 @@ function BankCardModal({
|
||||
|
||||
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
// Re-Init nur beim Wechsel zur anderen Karte – nicht bei jedem
|
||||
// Tastendruck (das löste vorher Reset auf DB-Wert aus).
|
||||
// Formular zurücksetzen, sobald die Modal geöffnet wird (Neuanlage: leer,
|
||||
// Bearbeiten: Kartendaten). Nötig, weil beide Modal-Instanzen dauerhaft
|
||||
// gemountet bleiben und nur per isOpen umgeschaltet werden – ohne diesen
|
||||
// Reset blieben nach „Abbrechen" die vorher getippten Daten stehen.
|
||||
// Läuft NICHT bei jedem Tastendruck (Deps nur isOpen + Karten-ID), sonst
|
||||
// würde jede Eingabe wieder auf den DB-Wert zurückspringen.
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
setFormData(getInitialFormData());
|
||||
setIbanLookupLoading(false);
|
||||
setShowIbanRequired(false);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [bankCard?.id]);
|
||||
}, [isOpen, bankCard?.id]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -2968,12 +2972,14 @@ function DocumentModal({
|
||||
|
||||
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
// Re-Init nur beim Wechsel zum anderen Ausweis – nicht bei jedem
|
||||
// Tastendruck (das löste vorher Reset auf DB-Wert aus).
|
||||
// Reset beim Öffnen (Guard `if (!isOpen)`): Modal bleibt gemountet, sonst
|
||||
// blieben nach „Abbrechen" die vorher getippten Daten stehen. Deps nur
|
||||
// isOpen + Ausweis-ID → kein Reset bei jedem Tastendruck.
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
setFormData(getInitialFormData());
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [document?.id]);
|
||||
}, [isOpen, document?.id]);
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={isEditing ? 'Ausweis bearbeiten' : 'Ausweis hinzufügen'}>
|
||||
@@ -3180,12 +3186,14 @@ function MeterModal({
|
||||
|
||||
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
// Re-Init nur beim Wechsel zum anderen Zähler – nicht bei jedem
|
||||
// Tastendruck (das löste vorher Reset auf DB-Wert aus).
|
||||
// Reset beim Öffnen (Guard `if (!isOpen)`): Modal bleibt gemountet, sonst
|
||||
// blieben nach „Abbrechen" die vorher getippten Daten stehen. Deps nur
|
||||
// isOpen + Zähler-ID → kein Reset bei jedem Tastendruck.
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
setFormData(getInitialFormData());
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [meter?.id]);
|
||||
}, [isOpen, meter?.id]);
|
||||
|
||||
const noDeliveryAddresses = deliveryAddresses.length === 0;
|
||||
const successorLocked = !isEditing && formData.isSuccessor && !!predecessor;
|
||||
@@ -3419,12 +3427,14 @@ function MeterReadingModal({
|
||||
|
||||
const isPending = createMutation.isPending || updateMutation.isPending;
|
||||
|
||||
// Re-Init nur beim Wechsel zum anderen Zählerstand – nicht bei
|
||||
// jedem Tastendruck (das löste vorher Reset auf DB-Wert aus).
|
||||
// Reset beim Öffnen (Guard `if (!isOpen)`): Modal bleibt gemountet, sonst
|
||||
// blieben nach „Abbrechen" die vorher getippten Daten stehen. Deps nur
|
||||
// isOpen + Zählerstand-ID → kein Reset bei jedem Tastendruck.
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
setFormData(getInitialFormData());
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [reading?.id]);
|
||||
}, [isOpen, reading?.id]);
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title={isEditing ? 'Zählerstand bearbeiten' : 'Zählerstand erfassen'}>
|
||||
|
||||
Reference in New Issue
Block a user