fix(backend): Geocode-Cache begrenzen, Reset-Link reparieren

- Der Geocode-Cache war nur auf dem Erfolgspfad begrenzt. Die beiden
  Negativ-Pfade (cache.set(key, null)) schrieben ungebremst — über den
  öffentlichen GET /api/public/geocode genügten erfundene Postleitzahlen,
  um Speicher und die alle 60 s geschriebene Cache-Datei beliebig wachsen
  zu lassen. Bei NODE_OPTIONS=--max_old_space_size=256 eine reale Grenze.
  Alle Schreibpfade laufen jetzt über rememberInCache(); loadCache()
  beschneidet eine bereits gewachsene Datei auf das Limit.
  Geprüft: eine künstlich auf 5000 Einträge aufgeblähte Cache-Datei wird
  beim Start auf 1000 reduziert.
- Der Cache war trotz Kommentar kein LRU, sondern ein FIFO: ein Treffer
  hat den Eintrag nicht ans Ende gerückt, häufig genutzte Adressen flogen
  zuerst raus. Treffer werden jetzt neu einsortiert.
- /api/public/geocode bekommt einen eigenen Limiter (20/15 min). Der
  Endpunkt löst ausgehende Nominatim-Anfragen aus und teilt sich mit dem
  Geocoding im Admin-Bereich die globale Wartezeit von 1100 ms.
  Geprüft: Anfragen 21-24 liefern 429.
- resetPassword prüfte 6 Zeichen, das Admin-Schema verlangt 12. Passwörter
  dazwischen kamen durch und scheiterten erst an der Mongoose-Validierung
  — beim Nutzer kam "Serverfehler" (500) an. Jetzt 400 mit klarer Meldung.
- APP_URL wird aktiv gesetzt statt auskommentiert, und der Rückfallwert
  hängt den Unterpfad an. Vorher fiel appUrl auf CORS_ORIGIN zurück, und
  der Link in der Reset-Mail zeigte auf das Portal statt in die App.
  Geprüft: erzeugte URL lautet .../nachsuche/passwort-zuruecksetzen?token=…
- Die beim Zusammenführen verlorengegangene Platzhalter-Prüfung für
  JWT_SECRET ist wieder da (fängt auch CHANGE_ME_IN_PRODUCTION).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
thomas 2026-08-25 19:41:08 +02:00
parent 474383d17b
commit 868699a1bc
21 changed files with 304 additions and 58 deletions

View File

@ -19,3 +19,16 @@ CORS_ORIGIN=http://localhost:3000
GEOCODE_URL=https://nominatim.openstreetmap.org/search
GEOCODE_USER_AGENT=drohnenfuehrer-app/1.0 (admin@localhost)
GEOCODE_MIN_DELAY_MS=1100
# Basis-URL der App fuer Links in E-Mails (Passwort-Reset).
# MUSS den Unterpfad enthalten, unter dem die App ausgeliefert wird.
# Ohne diesen Wert wird er aus CORS_ORIGIN + "/drohnenfuehrer" zusammengesetzt.
APP_URL=http://localhost:8081/drohnenfuehrer
# SMTP fuer Passwort-Reset-Mails (optional).
# Fehlt die Konfiguration, wird der Reset-Link nur ins Log geschrieben.
# SMTP_HOST=smtp.example.com
# SMTP_PORT=587
# SMTP_USER=noreply@example.com
# SMTP_PASS=
# SMTP_FROM=noreply@example.com

View File

@ -14,7 +14,12 @@ const config = {
geocodeUserAgent: process.env.GEOCODE_USER_AGENT || 'drohnenfuehrer-app/1.0 (admin@localhost)',
geocodeMinDelayMs: parseInt(process.env.GEOCODE_MIN_DELAY_MS || '1100', 10),
smtpConfigured: !!(process.env.SMTP_HOST && process.env.SMTP_USER && process.env.SMTP_PASS),
appUrl: process.env.APP_URL || process.env.CORS_ORIGIN?.split(',')[0] || 'http://localhost:8081'
// Basis fuer Links in E-Mails (Passwort-Reset). MUSS den Unterpfad enthalten,
// unter dem die App ausgeliefert wird - der CORS_ORIGIN-Rueckfall kennt ihn
// nicht und erzeugt sonst Links, die auf dem Portal statt in der App landen.
appUrl: (process.env.APP_URL
|| `${(process.env.CORS_ORIGIN?.split(',')[0] || 'http://localhost:8081').replace(/\/+$/, '')}/${APP_NAME}`
).replace(/\/+$/, '')
};
// Validate required environment variables
@ -46,11 +51,21 @@ if (config.nodeEnv === 'production') {
}
});
// Check for insecure defaults in production
if (config.jwtSecret === 'your-secret-key-change-in-production') {
console.error('❌ Fehler: JWT_SECRET verwendet unsicheren Default-Wert!');
// Check for insecure defaults in production.
// Nicht nur der eine Default-String: podman-compose.yml setzt z. B.
// CHANGE_ME_IN_PRODUCTION, was eine reine Gleichheitspruefung durchlaesst.
if (/change[-_ ]?me|change-in-production|your-secret|secret-key/i.test(config.jwtSecret)) {
console.error('❌ Fehler: JWT_SECRET verwendet einen Platzhalter-Wert!');
console.error(' Bitte ein zufaelliges Secret setzen, z. B. mit: openssl rand -hex 32');
process.exit(1);
}
// Ohne APP_URL wird der Link in der Passwort-Reset-Mail aus CORS_ORIGIN
// zusammengesetzt. Das funktioniert nur, solange der Unterpfad dem App-Namen
// entspricht - bei abweichendem Deployment fuehrt der Link ins Leere.
if (!process.env.APP_URL) {
console.warn(`⚠️ Warnung: APP_URL ist nicht gesetzt. Reset-Links verwenden "${config.appUrl}".`);
}
}
module.exports = config;

View File

@ -195,11 +195,13 @@ const resetPassword = async (req, res) => {
});
}
// Validate password length
if (newPassword.length < 6) {
// Muss zu minlength im Admin-Schema passen. Vorher stand hier 6: Passwoerter
// mit 6-11 Zeichen kamen durch und scheiterten erst an der Mongoose-
// Validierung, was als 500 "Serverfehler" beim Nutzer ankam.
if (newPassword.length < 12) {
return res.status(400).json({
success: false,
message: 'Passwort muss mindestens 6 Zeichen lang sein'
message: 'Passwort muss mindestens 12 Zeichen lang sein'
});
}

View File

@ -43,7 +43,7 @@ const authLimiter = rateLimit({
// Strict rate limiter for invite / set-password endpoints
const inviteLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 10, // Max 10 attempts per windowMs
max: 10,
skipSuccessfulRequests: true,
message: {
success: false,
@ -60,8 +60,32 @@ const inviteLimiter = rateLimit({
}
});
// Limiter für die öffentliche PLZ-Suche.
// Der Endpunkt stößt ausgehende Anfragen an Nominatim an und teilt sich mit dem
// Geocoding im Admin-Bereich die globale Mindestwartezeit von geocodeMinDelayMs.
// Ohne eigenes Limit können anonyme Aufrufe das Anlegen von Führern ausbremsen
// und die Nominatim-Nutzungsregeln verletzen.
const geocodeLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 20,
message: {
success: false,
message: 'Zu viele PLZ-Abfragen. Bitte warten Sie einen Moment.'
},
standardHeaders: true,
legacyHeaders: false,
handler: (req, res) => {
logger.warn(`Geocode rate limit exceeded for IP: ${req.ip}`);
res.status(429).json({
success: false,
message: 'Zu viele PLZ-Abfragen. Bitte warten Sie einen Moment.'
});
}
});
module.exports = {
apiLimiter,
authLimiter,
inviteLimiter
inviteLimiter,
geocodeLimiter
};

View File

@ -3,6 +3,7 @@ const router = express.Router();
const { authenticateToken } = require('../middleware/auth');
const { auditLog } = require('../middleware/auditLogger');
const { validateGPS, validateAvailability } = require('../middleware/validator');
const { geocodeLimiter } = require('../middleware/rateLimiter');
const {
getAllUsers,
getUserById,
@ -25,7 +26,8 @@ const {
// Public routes
router.get('/public/users', getPublicUsers);
router.get('/public/geocode', getGeocodeByPostalCode);
// Eigenes, engeres Limit: der Endpunkt loest ausgehende Nominatim-Anfragen aus.
router.get('/public/geocode', geocodeLimiter, getGeocodeByPostalCode);
// Protected routes (require authentication)
router.get('/users', authenticateToken, getAllUsers);

View File

@ -6,17 +6,41 @@ const logger = require('./logger');
const CACHE_FILE = path.join(__dirname, '..', 'geocode-cache.json');
const CACHE_SAVE_INTERVAL = 60000; // Save every 60 seconds
const CACHE_MAX_ENTRIES = 1000;
const cache = new Map();
let lastRequestTime = 0;
let cacheModified = false;
/**
* Einziger Schreibpfad in den Cache, inklusive Größenbegrenzung.
*
* Vorher war nur der Erfolgspfad begrenzt; die beiden Negativ-Pfade
* (Adresse nicht gefunden / unbrauchbare Koordinaten) haben ungebremst
* geschrieben. Über den öffentlichen /api/public/geocode genügten damit
* erfundene Postleitzahlen, um Speicher und Cache-Datei beliebig wachsen
* zu lassen bei --max_old_space_size=256 eine reale Grenze.
*/
const rememberInCache = (key, value) => {
// Map behält die Einfügereihenfolge: ein vorhandener Schlüssel muss neu
// eingefügt werden, damit er als "zuletzt benutzt" ans Ende rückt.
cache.delete(key);
while (cache.size >= CACHE_MAX_ENTRIES) {
cache.delete(cache.keys().next().value);
}
cache.set(key, value);
cacheModified = true;
};
// Load cache from file on startup
const loadCache = async () => {
try {
const data = await fs.readFile(CACHE_FILE, 'utf8');
const parsed = JSON.parse(data);
Object.entries(parsed).forEach(([key, value]) => {
// Nur die letzten CACHE_MAX_ENTRIES übernehmen eine früher unbegrenzt
// gewachsene Datei darf den Cache nicht wieder aufblähen.
const entries = Object.entries(parsed).slice(-CACHE_MAX_ENTRIES);
entries.forEach(([key, value]) => {
cache.set(key, value);
});
logger.info(`Geocoding cache loaded: ${cache.size} entries`);
@ -88,7 +112,12 @@ const geocodeAddress = async (address) => {
const cacheKey = normalized.toLowerCase();
if (cache.has(cacheKey)) {
return cache.get(cacheKey);
// Treffer ans Ende rücken, damit die Verdrängung wirklich den am längsten
// ungenutzten Eintrag trifft und nicht bloß den ältesten eingefügten.
const hit = cache.get(cacheKey);
cache.delete(cacheKey);
cache.set(cacheKey, hit);
return hit;
}
const elapsed = Date.now() - lastRequestTime;
@ -106,8 +135,7 @@ const geocodeAddress = async (address) => {
lastRequestTime = Date.now();
if (!Array.isArray(results) || results.length === 0) {
cache.set(cacheKey, null);
cacheModified = true;
rememberInCache(cacheKey, null);
return null;
}
@ -116,15 +144,12 @@ const geocodeAddress = async (address) => {
const lng = parseFloat(hit.lon);
if (Number.isNaN(lat) || Number.isNaN(lng)) {
cache.set(cacheKey, null);
cacheModified = true;
rememberInCache(cacheKey, null);
return null;
}
const coords = { lat, lng };
if (cache.size >= 1000) { cache.delete(cache.keys().next().value); }
cache.set(cacheKey, coords);
cacheModified = true;
rememberInCache(cacheKey, coords);
return coords;
} catch (error) {
logger.warn('Geocoding fehlgeschlagen', {

View File

@ -45,7 +45,8 @@ services:
# - SMTP_USER=user@example.com
# - SMTP_PASS=${SMTP_PASS}
# - SMTP_FROM=drohnenfuehrer@example.com
# - APP_URL=https://example.com/drohnenfuehrer
# Basis fuer Links in Passwort-Reset-Mails. MUSS den Unterpfad enthalten.
- APP_URL=${APP_URL:-http://localhost:8081/drohnenfuehrer}
depends_on:
mongo:
condition: service_healthy

View File

@ -19,3 +19,16 @@ CORS_ORIGIN=http://localhost:3000
GEOCODE_URL=https://nominatim.openstreetmap.org/search
GEOCODE_USER_AGENT=tracking-leaders-app/1.0 (admin@localhost)
GEOCODE_MIN_DELAY_MS=1100
# Basis-URL der App fuer Links in E-Mails (Passwort-Reset).
# MUSS den Unterpfad enthalten, unter dem die App ausgeliefert wird.
# Ohne diesen Wert wird er aus CORS_ORIGIN + "/nachsuche" zusammengesetzt.
APP_URL=http://localhost:8080/nachsuche
# SMTP fuer Passwort-Reset-Mails (optional).
# Fehlt die Konfiguration, wird der Reset-Link nur ins Log geschrieben.
# SMTP_HOST=smtp.example.com
# SMTP_PORT=587
# SMTP_USER=noreply@example.com
# SMTP_PASS=
# SMTP_FROM=noreply@example.com

View File

@ -15,7 +15,12 @@ const config = {
geocodeMinDelayMs: parseInt(process.env.GEOCODE_MIN_DELAY_MS || '1100', 10),
// E-Mail / SMTP (required for password-reset emails; optional otherwise)
smtpConfigured: !!(process.env.SMTP_HOST && process.env.SMTP_USER && process.env.SMTP_PASS),
appUrl: process.env.APP_URL || process.env.CORS_ORIGIN?.split(',')[0] || 'http://localhost:8080'
// Basis für Links in E-Mails (Passwort-Reset). MUSS den Unterpfad enthalten,
// unter dem die App ausgeliefert wird der CORS_ORIGIN-Rückfall kennt ihn
// nicht und erzeugt sonst Links, die auf dem Portal statt in der App landen.
appUrl: (process.env.APP_URL
|| `${(process.env.CORS_ORIGIN?.split(',')[0] || 'http://localhost:8080').replace(/\/+$/, '')}/${APP_NAME}`
).replace(/\/+$/, '')
};
// Validate required environment variables
@ -47,11 +52,21 @@ if (config.nodeEnv === 'production') {
}
});
// Check for insecure defaults in production
if (config.jwtSecret === 'your-secret-key-change-in-production') {
console.error('❌ Fehler: JWT_SECRET verwendet unsicheren Default-Wert!');
// Check for insecure defaults in production.
// Nicht nur der eine Default-String: podman-compose.yml setzt z. B.
// CHANGE_ME_IN_PRODUCTION, was eine reine Gleichheitsprüfung durchlässt.
if (/change[-_ ]?me|change-in-production|your-secret|changeme|secret-key/i.test(config.jwtSecret)) {
console.error('❌ Fehler: JWT_SECRET verwendet einen Platzhalter-Wert!');
console.error(' Bitte ein zufälliges Secret setzen, z. B. mit: openssl rand -hex 32');
process.exit(1);
}
// Ohne APP_URL wird der Link in der Passwort-Reset-Mail aus CORS_ORIGIN
// zusammengesetzt. Das funktioniert nur, solange der Unterpfad dem App-Namen
// entspricht bei abweichendem Deployment führt der Link ins Leere.
if (!process.env.APP_URL) {
console.warn(`⚠️ Warnung: APP_URL ist nicht gesetzt. Reset-Links verwenden "${config.appUrl}".`);
}
}
module.exports = config;

View File

@ -202,11 +202,13 @@ const resetPassword = async (req, res) => {
});
}
// Validate password length
if (newPassword.length < 6) {
// Muss zu minlength im Admin-Schema passen. Vorher stand hier 6: Passwörter
// mit 611 Zeichen kamen durch und scheiterten erst an der Mongoose-
// Validierung, was als 500 "Serverfehler" beim Nutzer ankam.
if (newPassword.length < 12) {
return res.status(400).json({
success: false,
message: 'Passwort muss mindestens 6 Zeichen lang sein'
message: 'Passwort muss mindestens 12 Zeichen lang sein'
});
}

View File

@ -60,8 +60,32 @@ const inviteLimiter = rateLimit({
}
});
// Limiter für die öffentliche PLZ-Suche.
// Der Endpunkt stößt ausgehende Anfragen an Nominatim an und teilt sich mit dem
// Geocoding im Admin-Bereich die globale Mindestwartezeit von geocodeMinDelayMs.
// Ohne eigenes Limit können anonyme Aufrufe das Anlegen von Führern ausbremsen
// und die Nominatim-Nutzungsregeln verletzen.
const geocodeLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 20,
message: {
success: false,
message: 'Zu viele PLZ-Abfragen. Bitte warten Sie einen Moment.'
},
standardHeaders: true,
legacyHeaders: false,
handler: (req, res) => {
logger.warn(`Geocode rate limit exceeded for IP: ${req.ip}`);
res.status(429).json({
success: false,
message: 'Zu viele PLZ-Abfragen. Bitte warten Sie einen Moment.'
});
}
});
module.exports = {
apiLimiter,
authLimiter,
inviteLimiter
inviteLimiter,
geocodeLimiter
};

View File

@ -3,6 +3,7 @@ const router = express.Router();
const { authenticateToken } = require('../middleware/auth');
const { auditLog } = require('../middleware/auditLogger');
const { validateGPS, validateAvailability } = require('../middleware/validator');
const { geocodeLimiter } = require('../middleware/rateLimiter');
const {
getAllUsers,
getUserById,
@ -25,7 +26,8 @@ const {
// Public routes
router.get('/public/users', getPublicUsers);
router.get('/public/geocode', getGeocodeByPostalCode);
// Eigenes, engeres Limit: der Endpunkt loest ausgehende Nominatim-Anfragen aus.
router.get('/public/geocode', geocodeLimiter, getGeocodeByPostalCode);
// Protected routes (require authentication)
router.get('/users', authenticateToken, getAllUsers);

View File

@ -6,17 +6,41 @@ const logger = require('./logger');
const CACHE_FILE = path.join(__dirname, '..', 'geocode-cache.json');
const CACHE_SAVE_INTERVAL = 60000; // Save every 60 seconds
const CACHE_MAX_ENTRIES = 1000;
const cache = new Map();
let lastRequestTime = 0;
let cacheModified = false;
/**
* Einziger Schreibpfad in den Cache, inklusive Größenbegrenzung.
*
* Vorher war nur der Erfolgspfad begrenzt; die beiden Negativ-Pfade
* (Adresse nicht gefunden / unbrauchbare Koordinaten) haben ungebremst
* geschrieben. Über den öffentlichen /api/public/geocode genügten damit
* erfundene Postleitzahlen, um Speicher und Cache-Datei beliebig wachsen
* zu lassen bei --max_old_space_size=256 eine reale Grenze.
*/
const rememberInCache = (key, value) => {
// Map behält die Einfügereihenfolge: ein vorhandener Schlüssel muss neu
// eingefügt werden, damit er als "zuletzt benutzt" ans Ende rückt.
cache.delete(key);
while (cache.size >= CACHE_MAX_ENTRIES) {
cache.delete(cache.keys().next().value);
}
cache.set(key, value);
cacheModified = true;
};
// Load cache from file on startup
const loadCache = async () => {
try {
const data = await fs.readFile(CACHE_FILE, 'utf8');
const parsed = JSON.parse(data);
Object.entries(parsed).forEach(([key, value]) => {
// Nur die letzten CACHE_MAX_ENTRIES übernehmen eine früher unbegrenzt
// gewachsene Datei darf den Cache nicht wieder aufblähen.
const entries = Object.entries(parsed).slice(-CACHE_MAX_ENTRIES);
entries.forEach(([key, value]) => {
cache.set(key, value);
});
logger.info(`Geocoding cache loaded: ${cache.size} entries`);
@ -88,7 +112,12 @@ const geocodeAddress = async (address) => {
const cacheKey = normalized.toLowerCase();
if (cache.has(cacheKey)) {
return cache.get(cacheKey);
// Treffer ans Ende rücken, damit die Verdrängung wirklich den am längsten
// ungenutzten Eintrag trifft und nicht bloß den ältesten eingefügten.
const hit = cache.get(cacheKey);
cache.delete(cacheKey);
cache.set(cacheKey, hit);
return hit;
}
const elapsed = Date.now() - lastRequestTime;
@ -106,8 +135,7 @@ const geocodeAddress = async (address) => {
lastRequestTime = Date.now();
if (!Array.isArray(results) || results.length === 0) {
cache.set(cacheKey, null);
cacheModified = true;
rememberInCache(cacheKey, null);
return null;
}
@ -116,15 +144,12 @@ const geocodeAddress = async (address) => {
const lng = parseFloat(hit.lon);
if (Number.isNaN(lat) || Number.isNaN(lng)) {
cache.set(cacheKey, null);
cacheModified = true;
rememberInCache(cacheKey, null);
return null;
}
const coords = { lat, lng };
if (cache.size >= 1000) { cache.delete(cache.keys().next().value); }
cache.set(cacheKey, coords);
cacheModified = true;
rememberInCache(cacheKey, coords);
return coords;
} catch (error) {
logger.warn('Geocoding fehlgeschlagen', {

View File

@ -47,7 +47,8 @@ services:
# - SMTP_USER=user@example.com
# - SMTP_PASS=${SMTP_PASS}
# - SMTP_FROM=nachsuche@example.com
# - APP_URL=https://example.com/nachsuche
# Basis fuer Links in Passwort-Reset-Mails. MUSS den Unterpfad enthalten.
- APP_URL=${APP_URL:-http://localhost:8080/nachsuche}
depends_on:
mongo:
condition: service_healthy

View File

@ -19,3 +19,16 @@ CORS_ORIGIN=http://localhost:3000
GEOCODE_URL=https://nominatim.openstreetmap.org/search
GEOCODE_USER_AGENT=stoeberhunde-app/1.0 (admin@localhost)
GEOCODE_MIN_DELAY_MS=1100
# Basis-URL der App fuer Links in E-Mails (Passwort-Reset).
# MUSS den Unterpfad enthalten, unter dem die App ausgeliefert wird.
# Ohne diesen Wert wird er aus CORS_ORIGIN + "/stoeberhunde" zusammengesetzt.
APP_URL=http://localhost:8082/stoeberhunde
# SMTP fuer Passwort-Reset-Mails (optional).
# Fehlt die Konfiguration, wird der Reset-Link nur ins Log geschrieben.
# SMTP_HOST=smtp.example.com
# SMTP_PORT=587
# SMTP_USER=noreply@example.com
# SMTP_PASS=
# SMTP_FROM=noreply@example.com

View File

@ -14,7 +14,12 @@ const config = {
geocodeUserAgent: process.env.GEOCODE_USER_AGENT || 'stoeberhunde-app/1.0 (admin@localhost)',
geocodeMinDelayMs: parseInt(process.env.GEOCODE_MIN_DELAY_MS || '1100', 10),
smtpConfigured: !!(process.env.SMTP_HOST && process.env.SMTP_USER && process.env.SMTP_PASS),
appUrl: process.env.APP_URL || process.env.CORS_ORIGIN?.split(',')[0] || 'http://localhost:8082'
// Basis fuer Links in E-Mails (Passwort-Reset). MUSS den Unterpfad enthalten,
// unter dem die App ausgeliefert wird - der CORS_ORIGIN-Rueckfall kennt ihn
// nicht und erzeugt sonst Links, die auf dem Portal statt in der App landen.
appUrl: (process.env.APP_URL
|| `${(process.env.CORS_ORIGIN?.split(',')[0] || 'http://localhost:8082').replace(/\/+$/, '')}/${APP_NAME}`
).replace(/\/+$/, '')
};
// Validate required environment variables
@ -46,11 +51,21 @@ if (config.nodeEnv === 'production') {
}
});
// Check for insecure defaults in production
if (config.jwtSecret === 'your-secret-key-change-in-production') {
console.error('❌ Fehler: JWT_SECRET verwendet unsicheren Default-Wert!');
// Check for insecure defaults in production.
// Nicht nur der eine Default-String: podman-compose.yml setzt z. B.
// CHANGE_ME_IN_PRODUCTION, was eine reine Gleichheitspruefung durchlaesst.
if (/change[-_ ]?me|change-in-production|your-secret|secret-key/i.test(config.jwtSecret)) {
console.error('❌ Fehler: JWT_SECRET verwendet einen Platzhalter-Wert!');
console.error(' Bitte ein zufaelliges Secret setzen, z. B. mit: openssl rand -hex 32');
process.exit(1);
}
// Ohne APP_URL wird der Link in der Passwort-Reset-Mail aus CORS_ORIGIN
// zusammengesetzt. Das funktioniert nur, solange der Unterpfad dem App-Namen
// entspricht - bei abweichendem Deployment fuehrt der Link ins Leere.
if (!process.env.APP_URL) {
console.warn(`⚠️ Warnung: APP_URL ist nicht gesetzt. Reset-Links verwenden "${config.appUrl}".`);
}
}
module.exports = config;

View File

@ -195,11 +195,13 @@ const resetPassword = async (req, res) => {
});
}
// Validate password length
if (newPassword.length < 6) {
// Muss zu minlength im Admin-Schema passen. Vorher stand hier 6: Passwoerter
// mit 6-11 Zeichen kamen durch und scheiterten erst an der Mongoose-
// Validierung, was als 500 "Serverfehler" beim Nutzer ankam.
if (newPassword.length < 12) {
return res.status(400).json({
success: false,
message: 'Passwort muss mindestens 6 Zeichen lang sein'
message: 'Passwort muss mindestens 12 Zeichen lang sein'
});
}

View File

@ -60,8 +60,32 @@ const inviteLimiter = rateLimit({
}
});
// Limiter für die öffentliche PLZ-Suche.
// Der Endpunkt stößt ausgehende Anfragen an Nominatim an und teilt sich mit dem
// Geocoding im Admin-Bereich die globale Mindestwartezeit von geocodeMinDelayMs.
// Ohne eigenes Limit können anonyme Aufrufe das Anlegen von Führern ausbremsen
// und die Nominatim-Nutzungsregeln verletzen.
const geocodeLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 20,
message: {
success: false,
message: 'Zu viele PLZ-Abfragen. Bitte warten Sie einen Moment.'
},
standardHeaders: true,
legacyHeaders: false,
handler: (req, res) => {
logger.warn(`Geocode rate limit exceeded for IP: ${req.ip}`);
res.status(429).json({
success: false,
message: 'Zu viele PLZ-Abfragen. Bitte warten Sie einen Moment.'
});
}
});
module.exports = {
apiLimiter,
authLimiter,
inviteLimiter
inviteLimiter,
geocodeLimiter
};

View File

@ -3,6 +3,7 @@ const router = express.Router();
const { authenticateToken } = require('../middleware/auth');
const { auditLog } = require('../middleware/auditLogger');
const { validateGPS, validateAvailability } = require('../middleware/validator');
const { geocodeLimiter } = require('../middleware/rateLimiter');
const {
getAllUsers,
getUserById,
@ -25,7 +26,8 @@ const {
// Public routes
router.get('/public/users', getPublicUsers);
router.get('/public/geocode', getGeocodeByPostalCode);
// Eigenes, engeres Limit: der Endpunkt loest ausgehende Nominatim-Anfragen aus.
router.get('/public/geocode', geocodeLimiter, getGeocodeByPostalCode);
// Protected routes (require authentication)
router.get('/users', authenticateToken, getAllUsers);

View File

@ -6,17 +6,41 @@ const logger = require('./logger');
const CACHE_FILE = path.join(__dirname, '..', 'geocode-cache.json');
const CACHE_SAVE_INTERVAL = 60000; // Save every 60 seconds
const CACHE_MAX_ENTRIES = 1000;
const cache = new Map();
let lastRequestTime = 0;
let cacheModified = false;
/**
* Einziger Schreibpfad in den Cache, inklusive Größenbegrenzung.
*
* Vorher war nur der Erfolgspfad begrenzt; die beiden Negativ-Pfade
* (Adresse nicht gefunden / unbrauchbare Koordinaten) haben ungebremst
* geschrieben. Über den öffentlichen /api/public/geocode genügten damit
* erfundene Postleitzahlen, um Speicher und Cache-Datei beliebig wachsen
* zu lassen bei --max_old_space_size=256 eine reale Grenze.
*/
const rememberInCache = (key, value) => {
// Map behält die Einfügereihenfolge: ein vorhandener Schlüssel muss neu
// eingefügt werden, damit er als "zuletzt benutzt" ans Ende rückt.
cache.delete(key);
while (cache.size >= CACHE_MAX_ENTRIES) {
cache.delete(cache.keys().next().value);
}
cache.set(key, value);
cacheModified = true;
};
// Load cache from file on startup
const loadCache = async () => {
try {
const data = await fs.readFile(CACHE_FILE, 'utf8');
const parsed = JSON.parse(data);
Object.entries(parsed).forEach(([key, value]) => {
// Nur die letzten CACHE_MAX_ENTRIES übernehmen eine früher unbegrenzt
// gewachsene Datei darf den Cache nicht wieder aufblähen.
const entries = Object.entries(parsed).slice(-CACHE_MAX_ENTRIES);
entries.forEach(([key, value]) => {
cache.set(key, value);
});
logger.info(`Geocoding cache loaded: ${cache.size} entries`);
@ -88,7 +112,12 @@ const geocodeAddress = async (address) => {
const cacheKey = normalized.toLowerCase();
if (cache.has(cacheKey)) {
return cache.get(cacheKey);
// Treffer ans Ende rücken, damit die Verdrängung wirklich den am längsten
// ungenutzten Eintrag trifft und nicht bloß den ältesten eingefügten.
const hit = cache.get(cacheKey);
cache.delete(cacheKey);
cache.set(cacheKey, hit);
return hit;
}
const elapsed = Date.now() - lastRequestTime;
@ -106,8 +135,7 @@ const geocodeAddress = async (address) => {
lastRequestTime = Date.now();
if (!Array.isArray(results) || results.length === 0) {
cache.set(cacheKey, null);
cacheModified = true;
rememberInCache(cacheKey, null);
return null;
}
@ -116,15 +144,12 @@ const geocodeAddress = async (address) => {
const lng = parseFloat(hit.lon);
if (Number.isNaN(lat) || Number.isNaN(lng)) {
cache.set(cacheKey, null);
cacheModified = true;
rememberInCache(cacheKey, null);
return null;
}
const coords = { lat, lng };
if (cache.size >= 1000) { cache.delete(cache.keys().next().value); }
cache.set(cacheKey, coords);
cacheModified = true;
rememberInCache(cacheKey, coords);
return coords;
} catch (error) {
logger.warn('Geocoding fehlgeschlagen', {

View File

@ -45,7 +45,8 @@ services:
# - SMTP_USER=user@example.com
# - SMTP_PASS=${SMTP_PASS}
# - SMTP_FROM=stoeberhunde@example.com
# - APP_URL=https://example.com/stoeberhunde
# Basis fuer Links in Passwort-Reset-Mails. MUSS den Unterpfad enthalten.
- APP_URL=${APP_URL:-http://localhost:8082/stoeberhunde}
depends_on:
mongo:
condition: service_healthy