Files
opencrm/backend/src/services/blzData.service.ts
T
duffyduckandClaude Opus 4.8 955fceb3b8 BLZ-Guard: vollstaendige Formatpruefung aller Eintraege (Pentest R148)
Der Poisoning-Guard pruefte bisher nur die erste current-Zeile - ein Set mit
1 echten + 999 Fake-Eintraegen kaeme durch. Jetzt wird JEDER Eintrag in
current + next.upsert geprueft (8-stellige BLZ, Wert [Name] oder [Name,BIC]),
next.remove auf 8-stellige BLZ, next.valid auf ein gueltiges Datum.

Dabei korrekt beruecksichtigt: Banken ohne BIC haben nur [Name] (Laenge 1) -
lookupBlz liefert dann bic:''. Real gegen den echten Datensatz verifiziert
(3506 Eintraege, inkl. BIC-lose wie BLZ 60050009).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-12 14:46:56 +02:00

391 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import fs from 'fs';
import path from 'path';
import { getSetting, getSettingBool, setSetting } from './appSetting.service.js';
/**
* BLZ-/Bankdaten-Service.
*
* Liefert BIC + Banknamen zu einer deutschen BLZ/IBAN. Zwei Datenquellen,
* in dieser Reihenfolge:
* 1. VOLUME ein zur Laufzeit aktualisierter Datensatz unter BANKDATA_DIR
* (Bind-Mount, siehe docker-compose). Wird vom Auto-Updater
* befüllt.
* 2. BUILTIN die ins Image gebackenen JSON-Daten des npm-Pakets
* `bankdata-germany` (Fallback, immer vorhanden).
*
* Es wird KEIN Paketcode ausgeführt nur die reinen JSON-Datendateien
* (current.json = { "<BLZ>": ["Name","BIC"] }, next.json = Delta der nächsten
* Periode) werden gelesen und mit eigener Logik indiziert.
*
* Datenschutz: Beim Lookup verlässt KEINE IBAN den Server. Nur der
* Auto-Updater macht ausgehende Requests und lädt dabei lediglich eine
* öffentliche Datendatei (keine Kundendaten).
*/
// Kompiliert nach CommonJS das native `require` ist zur Laufzeit verfügbar
// und wird nur genutzt, um den Pfad der gebackenen Paketdaten aufzulösen.
declare const require: NodeRequire;
// ---- Pfade / Quellen (per Env überschreibbar) ----
const BANKDATA_DIR = process.env.BANKDATA_DIR || path.join(process.cwd(), 'bankdata');
const DATASET_FILE = path.join(BANKDATA_DIR, 'blz-dataset.json');
const PACKAGE = 'bankdata-germany';
const CDN_BASE = process.env.BLZ_CDN_BASE || 'https://cdn.jsdelivr.net/npm';
const REGISTRY_BASE = process.env.BLZ_REGISTRY_BASE || 'https://registry.npmjs.org';
const FETCH_TIMEOUT_MS = 20_000;
// ---- Typen ----
type BankTuple = [string, string?]; // [bankName, bic?] BIC fehlt bei manchen Banken
type CurrentData = Record<string, BankTuple>;
interface NextData {
valid: string;
upsert: Record<string, BankTuple>;
remove: string[];
}
interface RawDataset {
current: CurrentData;
next: NextData;
}
interface LoadedDataset extends RawDataset {
source: 'volume' | 'builtin';
version: string;
fetchedAt: string | null;
}
export interface BankInfo {
bankName: string;
bic: string;
blz: string;
}
// ---- In-Memory-Cache (per mtime invalidiert) ----
let cache: { dataset: LoadedDataset; combined: CurrentData; key: string } | null = null;
function builtinDataDir(): string {
// require.resolve liefert .../dist/cjs/main.js → data/ liegt daneben.
const main = require.resolve(PACKAGE);
return path.join(path.dirname(main), 'data');
}
function builtinVersion(): string {
try {
const main = require.resolve(PACKAGE);
// main = <root>/dist/cjs/main.js → package.json zwei Ebenen höher.
const pkgPath = path.join(path.dirname(main), '..', '..', 'package.json');
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
return typeof pkg.version === 'string' ? pkg.version : 'unbekannt';
} catch {
return 'unbekannt';
}
}
function readJson(file: string): any {
return JSON.parse(fs.readFileSync(file, 'utf8'));
}
/**
* Lädt den aktiven Datensatz (Volume bevorzugt, sonst Builtin). Cached anhand
* der mtime der Volume-Datei bzw. eines statischen Keys für Builtin.
*/
function loadDataset(): LoadedDataset {
// Volume vorhanden?
let volumeMtime: number | null = null;
try {
volumeMtime = fs.statSync(DATASET_FILE).mtimeMs;
} catch {
volumeMtime = null;
}
const key = volumeMtime !== null ? `volume:${volumeMtime}` : 'builtin';
if (cache && cache.key === key) return cache.dataset;
let dataset: LoadedDataset;
if (volumeMtime !== null) {
try {
const raw: any = readJson(DATASET_FILE);
// Metadaten VOR der Assertion lesen (die narrowt raw auf RawDataset).
const version = typeof raw.version === 'string' ? raw.version : 'unbekannt';
const fetchedAt = typeof raw.fetchedAt === 'string' ? raw.fetchedAt : null;
assertValidRawDataset(raw);
dataset = {
source: 'volume',
version,
fetchedAt,
current: raw.current,
next: raw.next,
};
} catch (err) {
console.error('[BLZ] Volume-Datensatz unlesbar, nutze Builtin:', err);
dataset = loadBuiltin();
}
} else {
dataset = loadBuiltin();
}
const combined = combine(dataset);
cache = { dataset, combined, key };
return dataset;
}
function loadBuiltin(): LoadedDataset {
const dir = builtinDataDir();
const current = readJson(path.join(dir, 'current.json')) as CurrentData;
const next = readJson(path.join(dir, 'next.json')) as NextData;
return { source: 'builtin', version: builtinVersion(), fetchedAt: null, current, next };
}
/**
* Kombiniert current + next-Delta, wenn das aktuelle Datum den Gültig-ab-
* Zeitpunkt der nächsten Periode erreicht hat (identische Logik wie das
* npm-Paket).
*/
function combine(ds: RawDataset): CurrentData {
const validFrom = new Date(ds.next?.valid);
if (ds.next && !Number.isNaN(validFrom.getTime()) && new Date() >= validFrom) {
const merged: CurrentData = { ...ds.current, ...ds.next.upsert };
for (const blz of ds.next.remove || []) delete merged[blz];
return merged;
}
return ds.current;
}
function combinedData(): CurrentData {
loadDataset();
return cache!.combined;
}
// ---- Lookup ----
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 };
}
/** Extrahiert die BLZ aus einer deutschen IBAN und schlägt sie nach. */
export function lookupByIban(iban: string): BankInfo | null {
if (!/^DE\d{20}$/i.test(iban)) return null;
// IBAN: DE + 2 Prüfziffern + 8 BLZ + 10 Kontonummer
return lookupBlz(iban.slice(4, 12));
}
// ---- 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)`);
// 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.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) ----
async function fetchJson(url: string): Promise<any> {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS);
try {
const res = await fetch(url, {
signal: ctrl.signal,
headers: { Accept: 'application/json', 'User-Agent': 'OpenCRM-BLZ-Updater' },
});
if (!res.ok) throw new Error(`HTTP ${res.status} bei ${url}`);
return await res.json();
} finally {
clearTimeout(t);
}
}
// Erlaubtes Versionsformat (semver-Kern, rein numerisch). Die Version wird in
// die CDN-URL interpoliert ohne strikte Prüfung könnte eine manipulierte
// Registry-Antwort (`../`, Slashes, Query-Zeichen) den Pfad verbiegen. Der Host
// bleibt zwar fix (CDN_BASE), aber wir lassen nur `1.2.3` zu.
const VERSION_RE = /^\d{1,6}\.\d{1,6}\.\d{1,6}$/;
/** Ermittelt die neueste verfügbare Paket-Version (npm dist-tag latest). */
export async function fetchLatestVersion(): Promise<string> {
const manifest = await fetchJson(`${REGISTRY_BASE}/${PACKAGE}/latest`);
if (!manifest || typeof manifest.version !== 'string') throw new Error('Registry lieferte keine Version');
if (!VERSION_RE.test(manifest.version)) {
throw new Error(`Unerwartetes Versionsformat: ${manifest.version}`);
}
return manifest.version;
}
// ---- Versionsvergleich (numerische Segmente, z.B. 2.2603.0) ----
export function compareVersions(a: string, b: string): number {
const pa = a.split('.').map((n) => parseInt(n, 10) || 0);
const pb = b.split('.').map((n) => parseInt(n, 10) || 0);
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
const d = (pa[i] || 0) - (pb[i] || 0);
if (d !== 0) return d > 0 ? 1 : -1;
}
return 0;
}
function activeVersion(): string {
return loadDataset().version;
}
// ---- Update-Durchführung ----
let updating = false;
export interface UpdateResult {
changed: boolean;
version: string;
message: string;
}
/**
* Lädt (falls neuer) den aktuellen Datensatz von der CDN ins Volume.
* `force` lädt auch bei gleicher Version neu.
*/
export async function runUpdate(force = false): Promise<UpdateResult> {
if (updating) {
return { changed: false, version: activeVersion(), message: 'Update läuft bereits.' };
}
updating = true;
const nowIso = new Date().toISOString();
try {
const latest = await fetchLatestVersion();
await setSetting('blzLatestVersion', latest);
await setSetting('blzLastCheckedAt', nowIso);
const current = activeVersion();
if (!force && current !== 'unbekannt' && compareVersions(latest, current) <= 0) {
await setSetting('blzLastError', '');
return { changed: false, version: current, message: `Bereits aktuell (${current}).` };
}
// Datendateien der Zielversion laden.
const base = `${CDN_BASE}/${PACKAGE}@${latest}/dist/cjs/data`;
const [currentData, nextData] = await Promise.all([
fetchJson(`${base}/current.json`),
fetchJson(`${base}/next.json`),
]);
const raw = { current: currentData, next: nextData };
assertValidRawDataset(raw); // wirft bei Müll → kein Überschreiben
const payload = JSON.stringify({
version: latest,
fetchedAt: nowIso,
source: 'cdn',
current: currentData,
next: nextData,
});
fs.mkdirSync(BANKDATA_DIR, { recursive: true });
const tmp = `${DATASET_FILE}.tmp`;
fs.writeFileSync(tmp, payload, 'utf8');
fs.renameSync(tmp, DATASET_FILE); // atomar
cache = null; // Cache invalidieren → nächster Lookup lädt neu
await setSetting('blzLastUpdatedAt', nowIso);
await setSetting('blzLastError', '');
console.log(`[BLZ] Datensatz aktualisiert auf ${latest} (${Object.keys(currentData).length} Einträge).`);
return { changed: true, version: latest, message: `Aktualisiert auf ${latest}.` };
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
await setSetting('blzLastError', msg).catch(() => {});
await setSetting('blzLastCheckedAt', nowIso).catch(() => {});
console.error('[BLZ] Update fehlgeschlagen:', msg);
throw err instanceof Error ? err : new Error(msg);
} finally {
updating = false;
}
}
// ---- Status für die Einstellungen-Seite ----
export interface BlzStatus {
activeSource: 'volume' | 'builtin';
activeVersion: string;
builtinVersion: string;
entryCount: number;
nextValidFrom: string | null;
fetchedAt: string | null;
autoUpdateEnabled: boolean;
intervalDays: number;
lastCheckedAt: string | null;
lastUpdatedAt: string | null;
latestVersion: string | null;
updateAvailable: boolean;
lastError: string | null;
}
export async function getStatus(): Promise<BlzStatus> {
const ds = loadDataset();
const combined = combinedData();
const autoUpdateEnabled = await getSettingBool('blzAutoUpdateEnabled');
const intervalDays = parseInt((await getSetting('blzUpdateIntervalDays')) || '30', 10) || 30;
const lastCheckedAt = (await getSetting('blzLastCheckedAt')) || null;
const lastUpdatedAt = (await getSetting('blzLastUpdatedAt')) || null;
const latestVersion = (await getSetting('blzLatestVersion')) || null;
const lastErrorRaw = (await getSetting('blzLastError')) || '';
const updateAvailable =
!!latestVersion && ds.version !== 'unbekannt' && compareVersions(latestVersion, ds.version) > 0;
return {
activeSource: ds.source,
activeVersion: ds.version,
builtinVersion: builtinVersion(),
entryCount: Object.keys(combined).length,
nextValidFrom: ds.next?.valid || null,
fetchedAt: ds.fetchedAt,
autoUpdateEnabled,
intervalDays,
lastCheckedAt,
lastUpdatedAt,
latestVersion,
updateAvailable,
lastError: lastErrorRaw || null,
};
}