diff --git a/drohnenfuehrer/backend/.env.example b/drohnenfuehrer/backend/.env.example index 21d31c2..feaf9bf 100644 --- a/drohnenfuehrer/backend/.env.example +++ b/drohnenfuehrer/backend/.env.example @@ -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 diff --git a/drohnenfuehrer/backend/config/env.js b/drohnenfuehrer/backend/config/env.js index 44a402e..c55eff9 100644 --- a/drohnenfuehrer/backend/config/env.js +++ b/drohnenfuehrer/backend/config/env.js @@ -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; diff --git a/drohnenfuehrer/backend/controllers/authController.js b/drohnenfuehrer/backend/controllers/authController.js index fea2aee..62766e0 100644 --- a/drohnenfuehrer/backend/controllers/authController.js +++ b/drohnenfuehrer/backend/controllers/authController.js @@ -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' }); } diff --git a/drohnenfuehrer/backend/middleware/rateLimiter.js b/drohnenfuehrer/backend/middleware/rateLimiter.js index 52901f5..105b94a 100644 --- a/drohnenfuehrer/backend/middleware/rateLimiter.js +++ b/drohnenfuehrer/backend/middleware/rateLimiter.js @@ -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 }; diff --git a/drohnenfuehrer/backend/routes/userRoutes.js b/drohnenfuehrer/backend/routes/userRoutes.js index c458486..0697be1 100644 --- a/drohnenfuehrer/backend/routes/userRoutes.js +++ b/drohnenfuehrer/backend/routes/userRoutes.js @@ -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); diff --git a/drohnenfuehrer/backend/utils/geocode.js b/drohnenfuehrer/backend/utils/geocode.js index 99a22cc..973cfdf 100644 --- a/drohnenfuehrer/backend/utils/geocode.js +++ b/drohnenfuehrer/backend/utils/geocode.js @@ -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', { diff --git a/drohnenfuehrer/docker-compose.yml b/drohnenfuehrer/docker-compose.yml index 4226a60..8a23240 100644 --- a/drohnenfuehrer/docker-compose.yml +++ b/drohnenfuehrer/docker-compose.yml @@ -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 diff --git a/nachsuche/backend/.env.example b/nachsuche/backend/.env.example index 01de079..eecf41e 100644 --- a/nachsuche/backend/.env.example +++ b/nachsuche/backend/.env.example @@ -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 diff --git a/nachsuche/backend/config/env.js b/nachsuche/backend/config/env.js index 8a49059..2a4c054 100644 --- a/nachsuche/backend/config/env.js +++ b/nachsuche/backend/config/env.js @@ -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; diff --git a/nachsuche/backend/controllers/authController.js b/nachsuche/backend/controllers/authController.js index 3975e84..43eb0a0 100644 --- a/nachsuche/backend/controllers/authController.js +++ b/nachsuche/backend/controllers/authController.js @@ -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 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' }); } diff --git a/nachsuche/backend/middleware/rateLimiter.js b/nachsuche/backend/middleware/rateLimiter.js index c727b48..105b94a 100644 --- a/nachsuche/backend/middleware/rateLimiter.js +++ b/nachsuche/backend/middleware/rateLimiter.js @@ -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 }; diff --git a/nachsuche/backend/routes/userRoutes.js b/nachsuche/backend/routes/userRoutes.js index c458486..0697be1 100644 --- a/nachsuche/backend/routes/userRoutes.js +++ b/nachsuche/backend/routes/userRoutes.js @@ -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); diff --git a/nachsuche/backend/utils/geocode.js b/nachsuche/backend/utils/geocode.js index 99a22cc..973cfdf 100644 --- a/nachsuche/backend/utils/geocode.js +++ b/nachsuche/backend/utils/geocode.js @@ -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', { diff --git a/nachsuche/docker-compose.yml b/nachsuche/docker-compose.yml index f9f3fc8..8bf12bb 100644 --- a/nachsuche/docker-compose.yml +++ b/nachsuche/docker-compose.yml @@ -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 diff --git a/stoeberhunde/backend/.env.example b/stoeberhunde/backend/.env.example index 10be23e..69c5b33 100644 --- a/stoeberhunde/backend/.env.example +++ b/stoeberhunde/backend/.env.example @@ -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 diff --git a/stoeberhunde/backend/config/env.js b/stoeberhunde/backend/config/env.js index afd7446..d74d61d 100644 --- a/stoeberhunde/backend/config/env.js +++ b/stoeberhunde/backend/config/env.js @@ -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; diff --git a/stoeberhunde/backend/controllers/authController.js b/stoeberhunde/backend/controllers/authController.js index a0154f3..818ceb7 100644 --- a/stoeberhunde/backend/controllers/authController.js +++ b/stoeberhunde/backend/controllers/authController.js @@ -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' }); } diff --git a/stoeberhunde/backend/middleware/rateLimiter.js b/stoeberhunde/backend/middleware/rateLimiter.js index c727b48..105b94a 100644 --- a/stoeberhunde/backend/middleware/rateLimiter.js +++ b/stoeberhunde/backend/middleware/rateLimiter.js @@ -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 }; diff --git a/stoeberhunde/backend/routes/userRoutes.js b/stoeberhunde/backend/routes/userRoutes.js index c458486..0697be1 100644 --- a/stoeberhunde/backend/routes/userRoutes.js +++ b/stoeberhunde/backend/routes/userRoutes.js @@ -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); diff --git a/stoeberhunde/backend/utils/geocode.js b/stoeberhunde/backend/utils/geocode.js index 99a22cc..973cfdf 100644 --- a/stoeberhunde/backend/utils/geocode.js +++ b/stoeberhunde/backend/utils/geocode.js @@ -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', { diff --git a/stoeberhunde/docker-compose.yml b/stoeberhunde/docker-compose.yml index 8170155..0480fa0 100644 --- a/stoeberhunde/docker-compose.yml +++ b/stoeberhunde/docker-compose.yml @@ -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