From 712b8477b9aeb9a71f42269db7b83c8e0fbbf193 Mon Sep 17 00:00:00 2001 From: "matthias.lotz" Date: Tue, 25 Nov 2025 20:26:59 +0100 Subject: [PATCH] feat: Implement public/internal host separation Backend: - Add hostGate middleware for host-based API protection - Extend rate limiter with publicUploadLimiter (20/hour) - Add source_host and source_type to audit logs - Database migration for audit log source tracking - Unit tests for hostGate middleware (10/20 passing) Frontend: - Add hostDetection utility for runtime host detection - Implement React code splitting with lazy loading - Update App.js with ProtectedRoute component - Customize 404 page for public vs internal hosts - Update env-config.js for host configuration Docker: - Add environment variables to prod/dev docker-compose - Configure ENABLE_HOST_RESTRICTION flags - Set PUBLIC_HOST and INTERNAL_HOST variables Infrastructure: - Prepared for nginx-proxy-manager setup - Trust proxy configuration (TRUST_PROXY_HOPS=1) Note: Some unit tests still need adjustment for ENV handling --- .../FEATURE_REQUEST-FrontendPublic.md | 23 +- .../009_add_audit_log_source_tracking.sql | 11 + backend/src/middlewares/auditLog.js | 6 +- backend/src/middlewares/hostGate.js | 97 ++++++ backend/src/middlewares/index.js | 3 + backend/src/middlewares/rateLimiter.js | 63 +++- .../ManagementAuditLogRepository.js | 60 +++- backend/src/routes/upload.js | 3 +- backend/src/server.js | 11 +- .../tests/unit/middlewares/hostGate.test.js | 276 ++++++++++++++++++ docker/dev/docker-compose.yml | 5 + docker/prod/docker-compose.yml | 10 + frontend/.env.example | 4 + frontend/src/App.js | 120 ++++++-- frontend/src/Components/Pages/404Page.js | 39 ++- frontend/src/Utils/hostDetection.js | 94 ++++++ 16 files changed, 772 insertions(+), 53 deletions(-) create mode 100644 backend/src/database/migrations/009_add_audit_log_source_tracking.sql create mode 100644 backend/src/middlewares/hostGate.js create mode 100644 backend/tests/unit/middlewares/hostGate.test.js create mode 100644 frontend/src/Utils/hostDetection.js diff --git a/FeatureRequests/FEATURE_REQUEST-FrontendPublic.md b/FeatureRequests/FEATURE_REQUEST-FrontendPublic.md index 2d1731a..fa1d20e 100644 --- a/FeatureRequests/FEATURE_REQUEST-FrontendPublic.md +++ b/FeatureRequests/FEATURE_REQUEST-FrontendPublic.md @@ -11,15 +11,14 @@ Es soll unterschieden werden, welche Funktionen der App abhängig von der aufger - `deinprojekt.meindomain.de` (extern erreichbar): Nur Uploads und das Editieren via zugewiesenem Link (Management-UUID) sollen möglich sein. Keine Moderations-, Gruppen- oder Slideshow-Funktionen sichtbar oder nutzbar. - `deinprojekt.lan.meindomain.de` (Intranet): Vollständige Funktionalität (Slideshow, Groupsview, Moderation, Admin-Endpunkte) sowie volle Navigation/Buttons im Frontend. -Die Anwendung läuft in Docker auf einem Ubuntu-Server, vorgeschaltet ist ein `nginx-proxy-manager`. Die Domain `*.lan.meindomain.de` ist nur intern erreichbar und besitzt ein gültiges SSL-Zertifikat für das Intranet. +Die Anwendung läuft in Docker auf einem Ubuntu-Server, vorgeschaltet ist ein `nginx-proxy-manager`. Die Domain `*.lan.meindomain.de` ist nur intern erreichbar und besitzt ein gültiges SSL-Zertifikat für das Intranet (dns challenge letsencrypt). -Es wäre optional möglich, das public-Frontend extern zu hosten und nur die entsprechenden API-Endpunkte öffentlich verfügbar zu machen. ## Ziele -- Sicherheit: Admin-/Moderations-Funktionalität niemals über die öffentliche Subdomain erreichbar machen. -- UX: Im öffentlichen (externen) Kontext nur die Upload-Experience sichtbar und bedienbar. -- Flexibilität: Support sowohl für ein und denselben Host (Host-Header-Check) als auch für separat gehostetes public-Frontend. +- Sicherheit: Slideshow, Groupview und Admin-/Moderations-Funktionalität niemals über die öffentliche Subdomain erreichbar machen. +- UX: Im öffentlichen (externen) Kontext nur die Upload-Experience sichtbar und bedienbar. (die Upload Seite ist bereits so gestalltet, dass keine Menüpunkte sichtbar sind) + ## Vorschlag — Technische Umsetzung (hoher Level) @@ -80,23 +79,21 @@ Nach Durchsicht von `README.md`, `README.dev.md`, `CHANGELOG.md` und `AUTHENTICA 1. Domains — exakte Hosts - Dokumentation: Platzhalter-Hosts wurden als Beispiele verwendet (z. B. `deinprojekt.meindomain.de` und `deinprojekt.lan.meindomain.de`). - - Empfehlung / Bitte bestätigen: Nenne bitte die echten Subdomains, die Dein Deployment verwenden wird. Beispiel‑Antwort reicht: `public.example.com` und `public.lan.example.com`. + - Empfehlung / Bitte bestätigen: Nenne bitte die echten Subdomains, die Dein Deployment verwenden wird. Beispiel‑Antwort reicht: `deinprojekt.hobbyhimmel.de` und `deinprojekt.lan.hobbyhimmel.de`. 2. Host-Check vs. zusätzliche Checks - - Doku: Admin‑API ist bereits serverseitig per Bearer‑Token (`ADMIN_API_KEY`) geschützt. Management‑API nutzt UUID‑Token mit Rate‑Limits (10 req/h) und Brute‑Force‑Schutz. + - Doku: Admin‑API ist bereits serverseitig per Admin Login geschützt. Management‑API nutzt UUID‑Token mit Rate‑Limits (10 req/h) und Brute‑Force‑Schutz. - Empfehlung: Primär Host‑Header (`Host` / `X-Forwarded-Host`) prüfen (einfach, zuverlässig). Zusätzlich empfehle ich für Admin‑APIs die Kombination aus Bearer‑Token + Host‑Check (defense in depth). Bitte bestätigen, ob IP‑Whitelist gewünscht ist. -3. Externes Hosting des public‑Frontends - - Doku: Assets und Server liegen standardmäßig lokal (backend `src/data/images` / `src/data/previews`). Externes Hosting ist nicht Teil der Standardkonfiguration. - - Empfehlung: Behalte Assets intern (Standard). Wenn Du extern hosten willst, müssen CORS, Allowlist und ggf. signierte URLs implementiert werden. Bestätige, ob externes Hosting geplant ist. +3. Externes Hosting des public‑Frontends -> nicht mehr nötig 4. Management‑UUID (Editieren von extern) - Doku: Management‑Tokens sind permanent gültig bis Gruppe gelöscht; Token sind URL‑basiert und Rate‑limited (10 req/h). README zeigt, dass Management‑Portal für Self‑Service gedacht ist und kein zusätzliches network restriction vorgesehen ist. - Schlussfolgerung: Editieren per UUID ist technisch erlaubt und im Projekt vorgesehen. Wenn Du das beibehalten willst, ist keine weitere technische Änderung nötig. Falls Du TTL für Tokens möchtest, bitte angeben. 5. Admin‑APIs: Host‑only oder zusätzlich Bearer‑Token? - - Doku: Admin APIs sind bereits durch Bearer‑Token geschützt (`ADMIN_API_KEY`). - - Empfehlung: Behalte Bearer‑Token als Hauptschutz und ergänze Host‑Restriction (Admin nur intern erreichbar) für zusätzliche Sicherheit. Bitte bestätigen. + - ~~Doku: Admin APIs sind bereits durch Bearer‑Token geschützt (`ADMIN_API_KEY`).~~ + - ~~Empfehlung: Behalte Bearer‑Token als Hauptschutz und ergänze Host‑Restriction (Admin nur intern erreichbar) für zusätzliche Sicherheit. Bitte bestätigen.~~ 6. Rate‑Limits / Quotas für public Uploads - Doku: Management hat 10 req/h per IP; Upload‑Rate‑Limits für public uploads sind nicht konkret spezifiziert. @@ -104,7 +101,7 @@ Nach Durchsicht von `README.md`, `README.dev.md`, `CHANGELOG.md` und `AUTHENTICA 7. Logging / Monitoring - Doku: Es gibt umfassende Audit-Logs (`management_audit_log`, `deletion_log`). - - Empfehlung: Ergänze ein Feld/Label `source_host` oder `source_type` für public vs. internal Uploads für bessere Filterbarkeit. Bestätigen? + - Empfehlung: Ergänze ein Feld/Label `source_host` oder `source_type` für public vs. internal Uploads für bessere Filterbarkeit. Bestätigen? Passt! 8. Assets / CDN - Doku: Bilder und Previews werden lokal gespeichert; kein CDN-Flow vorhanden. Du hast klargestellt: Bilder sind intern und nur über UUID‑Links zugänglich. diff --git a/backend/src/database/migrations/009_add_audit_log_source_tracking.sql b/backend/src/database/migrations/009_add_audit_log_source_tracking.sql new file mode 100644 index 0000000..635a2d1 --- /dev/null +++ b/backend/src/database/migrations/009_add_audit_log_source_tracking.sql @@ -0,0 +1,11 @@ +-- Migration 009: Add source tracking to audit log +-- Adds source_host and source_type columns to management_audit_log + +-- Add source_host column (stores the hostname from which request originated) +ALTER TABLE management_audit_log ADD COLUMN source_host TEXT; + +-- Add source_type column (stores 'public' or 'internal') +ALTER TABLE management_audit_log ADD COLUMN source_type TEXT; + +-- Create index for filtering by source_type +CREATE INDEX IF NOT EXISTS idx_audit_log_source_type ON management_audit_log(source_type); diff --git a/backend/src/middlewares/auditLog.js b/backend/src/middlewares/auditLog.js index c3ebfa8..02dccfd 100644 --- a/backend/src/middlewares/auditLog.js +++ b/backend/src/middlewares/auditLog.js @@ -14,6 +14,8 @@ const auditLogMiddleware = (req, res, next) => { const ipAddress = req.ip || req.connection.remoteAddress || 'unknown'; const userAgent = req.get('user-agent') || 'unknown'; const managementToken = req.params.token || null; + const sourceHost = req.get('x-forwarded-host') || req.get('host') || 'unknown'; + const sourceType = req.requestSource || 'unknown'; /** * Log-Funktion für Controllers @@ -33,7 +35,9 @@ const auditLogMiddleware = (req, res, next) => { errorMessage, ipAddress, userAgent, - requestData + requestData, + sourceHost, + sourceType }); } catch (error) { console.error('Failed to write audit log:', error); diff --git a/backend/src/middlewares/hostGate.js b/backend/src/middlewares/hostGate.js new file mode 100644 index 0000000..e915152 --- /dev/null +++ b/backend/src/middlewares/hostGate.js @@ -0,0 +1,97 @@ +/** + * Host Gate Middleware + * Blockiert geschützte API-Routen für public Host + * Erlaubt nur Upload + Management für public Host + * + * Erkennt Host via X-Forwarded-Host (nginx-proxy-manager) oder Host Header + */ + +const PUBLIC_HOST = process.env.PUBLIC_HOST || 'deinprojekt.hobbyhimmel.de'; +const INTERNAL_HOST = process.env.INTERNAL_HOST || 'deinprojekt.lan.hobbyhimmel.de'; +const ENABLE_HOST_RESTRICTION = process.env.ENABLE_HOST_RESTRICTION !== 'false'; + +// Routes die NUR für internal Host erlaubt sind +const INTERNAL_ONLY_ROUTES = [ + '/api/admin', + '/api/groups', + '/api/slideshow', + '/api/migration', + '/api/moderation', + '/api/reorder', + '/api/batch-upload', + '/api/social-media', + '/api/auth/login', // Admin Login nur internal + '/api/auth/logout', + '/api/auth/session' +]; + +// Routes die für public Host erlaubt sind +const PUBLIC_ALLOWED_ROUTES = [ + '/api/upload', + '/api/manage', + '/api/previews', + '/api/consent' +]; + +/** + * Middleware: Host-basierte Zugriffskontrolle + * @param {Object} req - Express Request + * @param {Object} res - Express Response + * @param {Function} next - Next Middleware + */ +const hostGate = (req, res, next) => { + // Feature disabled only when explicitly set to false OR in test environment without explicit enable + const isTestEnv = process.env.NODE_ENV === 'test'; + const explicitlyEnabled = process.env.ENABLE_HOST_RESTRICTION === 'true'; + const explicitlyDisabled = process.env.ENABLE_HOST_RESTRICTION === 'false'; + + // Skip restriction if: + // - Explicitly disabled, OR + // - Test environment AND not explicitly enabled + if (explicitlyDisabled || (isTestEnv && !explicitlyEnabled)) { + req.isPublicHost = false; + req.isInternalHost = true; + req.requestSource = 'internal'; + return next(); + } + + // Get host from X-Forwarded-Host (nginx-proxy-manager) or Host header + const forwardedHost = req.get('x-forwarded-host'); + const hostHeader = req.get('host'); + const host = forwardedHost || hostHeader || ''; + const hostname = host.split(':')[0]; // Remove port if present + + // Determine if request is from public or internal host + req.isPublicHost = hostname === PUBLIC_HOST; + req.isInternalHost = hostname === INTERNAL_HOST || hostname === 'localhost' || hostname === '127.0.0.1'; + + // Log host detection for debugging + if (process.env.NODE_ENV !== 'production') { + console.log(`🔍 Host Detection: ${hostname} → ${req.isPublicHost ? 'PUBLIC' : 'INTERNAL'}`); + } + + // If public host, check if route is allowed + if (req.isPublicHost) { + const path = req.path; + + // Check if route is internal-only + const isInternalOnly = INTERNAL_ONLY_ROUTES.some(route => + path.startsWith(route) + ); + + if (isInternalOnly) { + console.warn(`🚫 Public host blocked access to: ${path} (Host: ${hostname})`); + return res.status(403).json({ + error: 'Not available on public host', + message: 'This endpoint is only available on the internal network' + }); + } + } + + // Add request source context for audit logging + req.requestSource = req.isPublicHost ? 'public' : 'internal'; + + next(); +}; + +module.exports = hostGate; diff --git a/backend/src/middlewares/index.js b/backend/src/middlewares/index.js index a82e7c1..da762d8 100644 --- a/backend/src/middlewares/index.js +++ b/backend/src/middlewares/index.js @@ -2,6 +2,7 @@ const express = require("express"); const fileUpload = require("express-fileupload"); const cors = require("./cors"); const session = require("./session"); +const hostGate = require("./hostGate"); const applyMiddlewares = (app) => { app.use(fileUpload()); @@ -9,6 +10,8 @@ const applyMiddlewares = (app) => { app.use(session); // JSON Parser für PATCH/POST Requests app.use(express.json()); + // Host Gate: Blockiert geschützte Routen für public Host + app.use(hostGate); }; module.exports = { applyMiddlewares }; \ No newline at end of file diff --git a/backend/src/middlewares/rateLimiter.js b/backend/src/middlewares/rateLimiter.js index c965541..0404d76 100644 --- a/backend/src/middlewares/rateLimiter.js +++ b/backend/src/middlewares/rateLimiter.js @@ -19,6 +19,15 @@ const RATE_LIMIT = { BLOCK_DURATION_MS: 24 * 60 * 60 * 1000 // 24 Stunden }; +// Public Upload Rate Limiting (strengere Limits für öffentliche Uploads) +const PUBLIC_UPLOAD_LIMIT = { + MAX_UPLOADS_PER_HOUR: parseInt(process.env.PUBLIC_UPLOAD_RATE_LIMIT || '20', 10), + WINDOW_MS: parseInt(process.env.PUBLIC_UPLOAD_RATE_WINDOW || '3600000', 10) // 1 Stunde +}; + +// In-Memory Storage für Public Upload Rate-Limiting +const publicUploadCounts = new Map(); // IP -> { count, resetTime } + /** * Extrahiere Client-IP aus Request */ @@ -169,13 +178,63 @@ function getStatistics() { reason: info.reason, blockedUntil: new Date(info.blockedUntil).toISOString(), failedAttempts: info.failedAttempts - })) + })), + publicUploadActiveIPs: publicUploadCounts.size }; } +/** + * Public Upload Rate Limiter Middleware + * Strengere Limits für öffentliche Uploads (20 pro Stunde pro IP) + * Wird nur auf public Host angewendet + */ +function publicUploadLimiter(req, res, next) { + // Skip wenn nicht public Host oder Feature disabled + if (!req.isPublicHost || process.env.NODE_ENV === 'test') { + return next(); + } + + const clientIP = getClientIP(req); + const now = Date.now(); + + // Hole oder erstelle Upload-Counter für IP + let uploadInfo = publicUploadCounts.get(clientIP); + + if (!uploadInfo || now > uploadInfo.resetTime) { + // Neues Zeitfenster + uploadInfo = { + count: 0, + resetTime: now + PUBLIC_UPLOAD_LIMIT.WINDOW_MS + }; + publicUploadCounts.set(clientIP, uploadInfo); + } + + // Prüfe Upload-Limit + if (uploadInfo.count >= PUBLIC_UPLOAD_LIMIT.MAX_UPLOADS_PER_HOUR) { + const resetIn = Math.ceil((uploadInfo.resetTime - now) / 1000 / 60); + console.warn(`🚫 Public upload limit exceeded for IP ${clientIP} (${uploadInfo.count}/${PUBLIC_UPLOAD_LIMIT.MAX_UPLOADS_PER_HOUR})`); + + return res.status(429).json({ + success: false, + error: 'Upload limit exceeded', + message: `You have reached the maximum of ${PUBLIC_UPLOAD_LIMIT.MAX_UPLOADS_PER_HOUR} uploads per hour. Please try again in ${resetIn} minutes.`, + limit: PUBLIC_UPLOAD_LIMIT.MAX_UPLOADS_PER_HOUR, + resetIn: resetIn + }); + } + + // Erhöhe Upload-Counter + uploadInfo.count++; + publicUploadCounts.set(clientIP, uploadInfo); + + // Request durchlassen + next(); +} + module.exports = { rateLimitMiddleware, recordFailedTokenValidation, cleanupExpiredEntries, - getStatistics + getStatistics, + publicUploadLimiter }; diff --git a/backend/src/repositories/ManagementAuditLogRepository.js b/backend/src/repositories/ManagementAuditLogRepository.js index c6589da..472b78a 100644 --- a/backend/src/repositories/ManagementAuditLogRepository.js +++ b/backend/src/repositories/ManagementAuditLogRepository.js @@ -20,6 +20,8 @@ class ManagementAuditLogRepository { * @param {string} logData.ipAddress - IP-Adresse * @param {string} logData.userAgent - User-Agent * @param {Object} logData.requestData - Request-Daten (wird als JSON gespeichert) + * @param {string} logData.sourceHost - Source Host (public/internal) + * @param {string} logData.sourceType - Source Type (public/internal) * @returns {Promise} ID des Log-Eintrags */ async logAction(logData) { @@ -34,22 +36,50 @@ class ManagementAuditLogRepository { managementToken: undefined // Token nie loggen } : null; - const query = ` - INSERT INTO management_audit_log - (group_id, management_token, action, success, error_message, ip_address, user_agent, request_data) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - `; + // Prüfe ob Spalten source_host und source_type existieren + const tableInfo = await dbManager.all(`PRAGMA table_info(management_audit_log)`); + const hasSourceColumns = tableInfo.some(col => col.name === 'source_host'); + + let query, params; - const result = await dbManager.run(query, [ - logData.groupId || null, - maskedToken, - logData.action, - logData.success ? 1 : 0, - logData.errorMessage || null, - logData.ipAddress || null, - logData.userAgent || null, - sanitizedData ? JSON.stringify(sanitizedData) : null - ]); + if (hasSourceColumns) { + query = ` + INSERT INTO management_audit_log + (group_id, management_token, action, success, error_message, ip_address, user_agent, request_data, source_host, source_type) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `; + params = [ + logData.groupId || null, + maskedToken, + logData.action, + logData.success ? 1 : 0, + logData.errorMessage || null, + logData.ipAddress || null, + logData.userAgent || null, + sanitizedData ? JSON.stringify(sanitizedData) : null, + logData.sourceHost || null, + logData.sourceType || null + ]; + } else { + // Fallback für alte DB-Schemas ohne source_host/source_type + query = ` + INSERT INTO management_audit_log + (group_id, management_token, action, success, error_message, ip_address, user_agent, request_data) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `; + params = [ + logData.groupId || null, + maskedToken, + logData.action, + logData.success ? 1 : 0, + logData.errorMessage || null, + logData.ipAddress || null, + logData.userAgent || null, + sanitizedData ? JSON.stringify(sanitizedData) : null + ]; + } + + const result = await dbManager.run(query, params); return result.lastID; } diff --git a/backend/src/routes/upload.js b/backend/src/routes/upload.js index 8e04512..f04ec1f 100644 --- a/backend/src/routes/upload.js +++ b/backend/src/routes/upload.js @@ -6,6 +6,7 @@ const path = require('path'); const ImagePreviewService = require('../services/ImagePreviewService'); const groupRepository = require('../repositories/GroupRepository'); const fs = require('fs'); +const { publicUploadLimiter } = require('../middlewares/rateLimiter'); const router = Router(); @@ -15,7 +16,7 @@ router.use('/upload', express.static( path.join(__dirname, '..', UPLOAD_FS_DIR) // Serve preview images via URL /previews but store files under data/previews router.use('/previews', express.static( path.join(__dirname, '..', PREVIEW_FS_DIR) )); -router.post('/upload', async (req, res) => { +router.post('/upload', publicUploadLimiter, async (req, res) => { /* #swagger.tags = ['Upload'] #swagger.summary = 'Upload a single image and create a new group' diff --git a/backend/src/server.js b/backend/src/server.js index 19face3..ac4224d 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -20,6 +20,10 @@ class Server { constructor(port) { this._port = port; this._app = express(); + const trustProxyHops = Number.parseInt(process.env.TRUST_PROXY_HOPS ?? '1', 10); + if (!Number.isNaN(trustProxyHops) && trustProxyHops > 0) { + this._app.set('trust proxy', trustProxyHops); + } } async generateOpenApiSpecIfNeeded() { @@ -95,8 +99,11 @@ class Server { this._app.use('/upload', express.static( __dirname + '/upload')); this._app.use('/api/previews', express.static( __dirname + '/data/previews')); - if (process.env.NODE_ENV !== 'production' && swaggerUi && swaggerDocument) { - this._app.use('/api/docs', swaggerUi.serve, swaggerUi.setup(swaggerDocument)); + if (process.env.NODE_ENV !== 'production' && swaggerUi) { + const swaggerDocument = this.loadSwaggerDocument(); + if (swaggerDocument) { + this._app.use('/api/docs', swaggerUi.serve, swaggerUi.setup(swaggerDocument)); + } } return this._app; } diff --git a/backend/tests/unit/middlewares/hostGate.test.js b/backend/tests/unit/middlewares/hostGate.test.js new file mode 100644 index 0000000..040b027 --- /dev/null +++ b/backend/tests/unit/middlewares/hostGate.test.js @@ -0,0 +1,276 @@ +/** + * Unit Tests für hostGate Middleware + * Testet Host-basierte Zugriffskontrolle + */ + +// Setup ENV VOR dem Require +process.env.ENABLE_HOST_RESTRICTION = 'true'; +process.env.PUBLIC_HOST = 'public.example.com'; +process.env.INTERNAL_HOST = 'internal.example.com'; +process.env.NODE_ENV = 'development'; + +const hostGate = require('../../../src/middlewares/hostGate'); + +describe('Host Gate Middleware', () => { + let req, res, next; + let originalEnv; + + beforeAll(() => { + // Sichere Original-Env + originalEnv = { ...process.env }; + }); + + beforeEach(() => { + // Mock Request + req = { + get: jest.fn(), + path: '/api/admin/test' + }; + + // Mock Response + res = { + status: jest.fn().mockReturnThis(), + json: jest.fn() + }; + + // Mock Next + next = jest.fn(); + + // Setup Environment + process.env.ENABLE_HOST_RESTRICTION = 'true'; + process.env.PUBLIC_HOST = 'public.example.com'; + process.env.INTERNAL_HOST = 'internal.example.com'; + process.env.NODE_ENV = 'development'; // NOT 'test' to enable restrictions + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + afterAll(() => { + // Restore Original-Env + process.env = originalEnv; + }); + + describe('Host Detection', () => { + test('should detect public host from X-Forwarded-Host header', () => { + req.get.mockImplementation((header) => { + if (header === 'x-forwarded-host') return 'public.example.com'; + return null; + }); + + hostGate(req, res, next); + + expect(req.isPublicHost).toBe(true); + expect(req.isInternalHost).toBe(false); + expect(req.requestSource).toBe('public'); + }); + + test('should detect internal host from X-Forwarded-Host header', () => { + req.get.mockImplementation((header) => { + if (header === 'x-forwarded-host') return 'internal.example.com'; + return null; + }); + + hostGate(req, res, next); + + expect(req.isPublicHost).toBe(false); + expect(req.isInternalHost).toBe(true); + expect(req.requestSource).toBe('internal'); + }); + + test('should fallback to Host header if X-Forwarded-Host not present', () => { + req.get.mockImplementation((header) => { + if (header === 'x-forwarded-host') return null; + if (header === 'host') return 'public.example.com'; + return null; + }); + + hostGate(req, res, next); + + expect(req.isPublicHost).toBe(true); + }); + + test('should handle localhost as internal host', () => { + req.get.mockImplementation((header) => { + if (header === 'x-forwarded-host') return null; + if (header === 'host') return 'localhost:3000'; + return null; + }); + + hostGate(req, res, next); + + expect(req.isInternalHost).toBe(true); + expect(req.isPublicHost).toBe(false); + }); + + test('should strip port from hostname', () => { + req.get.mockReturnValue('public.example.com:8080'); + + hostGate(req, res, next); + + expect(req.isPublicHost).toBe(true); + }); + }); + + describe('Route Protection', () => { + test('should block admin routes on public host', () => { + req.get.mockReturnValue('public.example.com'); + req.path = '/api/admin/deletion-log'; + + hostGate(req, res, next); + + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith({ + error: 'Not available on public host', + message: 'This endpoint is only available on the internal network' + }); + expect(next).not.toHaveBeenCalled(); + }); + + test('should block groups routes on public host', () => { + req.get.mockReturnValue('public.example.com'); + req.path = '/api/groups'; + + hostGate(req, res, next); + + expect(res.status).toHaveBeenCalledWith(403); + }); + + test('should block slideshow routes on public host', () => { + req.get.mockReturnValue('public.example.com'); + req.path = '/api/slideshow'; + + hostGate(req, res, next); + + expect(res.status).toHaveBeenCalledWith(403); + }); + + test('should block migration routes on public host', () => { + req.get.mockReturnValue('public.example.com'); + req.path = '/api/migration/start'; + + hostGate(req, res, next); + + expect(res.status).toHaveBeenCalledWith(403); + }); + + test('should block auth login on public host', () => { + req.get.mockReturnValue('public.example.com'); + req.path = '/api/auth/login'; + + hostGate(req, res, next); + + expect(res.status).toHaveBeenCalledWith(403); + }); + }); + + describe('Allowed Routes', () => { + test('should allow upload route on public host', () => { + req.get.mockReturnValue('public.example.com'); + req.path = '/api/upload'; + + hostGate(req, res, next); + + expect(next).toHaveBeenCalled(); + expect(res.status).not.toHaveBeenCalled(); + }); + + test('should allow manage routes on public host', () => { + req.get.mockReturnValue('public.example.com'); + req.path = '/api/manage/abc-123'; + + hostGate(req, res, next); + + expect(next).toHaveBeenCalled(); + }); + + test('should allow preview routes on public host', () => { + req.get.mockReturnValue('public.example.com'); + req.path = '/api/previews/image.jpg'; + + hostGate(req, res, next); + + expect(next).toHaveBeenCalled(); + }); + + test('should allow consent routes on public host', () => { + req.get.mockReturnValue('public.example.com'); + req.path = '/api/consent'; + + hostGate(req, res, next); + + expect(next).toHaveBeenCalled(); + }); + + test('should allow all routes on internal host', () => { + req.get.mockReturnValue('internal.example.com'); + req.path = '/api/admin/deletion-log'; + + hostGate(req, res, next); + + expect(next).toHaveBeenCalled(); + expect(res.status).not.toHaveBeenCalled(); + }); + }); + + describe('Feature Flags', () => { + test('should bypass restriction when NODE_ENV is test and not explicitly enabled', () => { + // Reload module with test environment + delete require.cache[require.resolve('../../../src/middlewares/hostGate')]; + process.env.NODE_ENV = 'test'; + process.env.ENABLE_HOST_RESTRICTION = 'false'; // Not explicitly enabled + const hostGateTest = require('../../../src/middlewares/hostGate'); + + req.get.mockReturnValue('public.example.com'); + req.path = '/api/admin/test'; + + hostGateTest(req, res, next); + + expect(next).toHaveBeenCalled(); + expect(res.status).not.toHaveBeenCalled(); + expect(req.isInternalHost).toBe(true); + + // Restore + delete require.cache[require.resolve('../../../src/middlewares/hostGate')]; + process.env.NODE_ENV = 'development'; + process.env.ENABLE_HOST_RESTRICTION = 'true'; + }); + + test('should work in test environment when explicitly enabled', () => { + // Already set up correctly + process.env.NODE_ENV = 'development'; + expect(req.isInternalHost).toBeUndefined(); // Not processed yet, just checking setup + }); + }); + + describe('Request Source Tracking', () => { + test('should set requestSource to "public" for public host', () => { + req.get.mockReturnValue('public.example.com'); + req.path = '/api/upload'; + + hostGate(req, res, next); + + expect(req.requestSource).toBe('public'); + }); + + test('should set requestSource to "internal" for internal host', () => { + req.get.mockReturnValue('internal.example.com'); + req.path = '/api/admin/test'; + + hostGate(req, res, next); + + expect(req.requestSource).toBe('internal'); + }); + + test('should set requestSource to "internal" when restrictions disabled', () => { + process.env.ENABLE_HOST_RESTRICTION = 'false'; + req.get.mockReturnValue('anything.example.com'); + req.path = '/api/test'; + + hostGate(req, res, next); + + expect(req.requestSource).toBe('internal'); + }); + }); +}); diff --git a/docker/dev/docker-compose.yml b/docker/dev/docker-compose.yml index 264d9a1..bc6d4c0 100644 --- a/docker/dev/docker-compose.yml +++ b/docker/dev/docker-compose.yml @@ -20,6 +20,8 @@ services: - CHOKIDAR_USEPOLLING=true - API_URL=http://localhost:5001 - CLIENT_URL=http://localhost:3000 + - PUBLIC_HOST=localhost + - INTERNAL_HOST=localhost depends_on: - backend-dev networks: @@ -40,6 +42,9 @@ services: - ./backend/config/.env:/usr/src/app/.env:ro environment: - NODE_ENV=development + - PUBLIC_HOST=localhost + - INTERNAL_HOST=localhost + - ENABLE_HOST_RESTRICTION=false networks: - dev-internal command: [ "npm", "run", "server" ] diff --git a/docker/prod/docker-compose.yml b/docker/prod/docker-compose.yml index 7aba6d9..69202f0 100644 --- a/docker/prod/docker-compose.yml +++ b/docker/prod/docker-compose.yml @@ -16,6 +16,8 @@ services: environment: - API_URL=http://backend:5000 - CLIENT_URL=http://localhost + - PUBLIC_HOST=deinprojekt.hobbyhimmel.de + - INTERNAL_HOST=deinprojekt.lan.hobbyhimmel.de networks: - npm-nw @@ -40,6 +42,14 @@ services: - ADMIN_SESSION_DIR=/usr/src/app/src/data/sessions # ⚠️ Für HTTP-only Labs per Override auf "false" setzen (nicht im Repo committen) - ADMIN_SESSION_COOKIE_SECURE=true + # Host Configuration (Public/Internal Separation) + - PUBLIC_HOST=deinprojekt.hobbyhimmel.de + - INTERNAL_HOST=deinprojekt.lan.hobbyhimmel.de + - ENABLE_HOST_RESTRICTION=true + - PUBLIC_UPLOAD_RATE_LIMIT=20 + - PUBLIC_UPLOAD_RATE_WINDOW=3600000 + # Trust nginx-proxy-manager (1 hop) + - TRUST_PROXY_HOPS=1 diff --git a/frontend/.env.example b/frontend/.env.example index 825b3ea..d573867 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -4,3 +4,7 @@ # via `REACT_APP_*` variables only if they are safe to expose to browsers. # Example: # REACT_APP_PUBLIC_API_BASE=https://example.com + +# Host Configuration (for public/internal separation) +PUBLIC_HOST=deinprojekt.hobbyhimmel.de +INTERNAL_HOST=deinprojekt.lan.hobbyhimmel.de diff --git a/frontend/src/App.js b/frontend/src/App.js index 0512ca7..6d5ce2b 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -1,31 +1,115 @@ +import React, { lazy, Suspense } from 'react'; import './App.css'; -import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'; +import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'; import { AdminSessionProvider } from './contexts/AdminSessionContext.jsx'; +import { getHostConfig } from './Utils/hostDetection.js'; -// Pages +// Always loaded (public + internal) import MultiUploadPage from './Components/Pages/MultiUploadPage'; -import SlideshowPage from './Components/Pages/SlideshowPage'; -import GroupsOverviewPage from './Components/Pages/GroupsOverviewPage'; -import ModerationGroupsPage from './Components/Pages/ModerationGroupsPage'; -import ModerationGroupImagesPage from './Components/Pages/ModerationGroupImagesPage'; -import PublicGroupImagesPage from './Components/Pages/PublicGroupImagesPage'; import ManagementPortalPage from './Components/Pages/ManagementPortalPage'; -import FZF from './Components/Pages/404Page.js' +import NotFoundPage from './Components/Pages/404Page.js'; + +// Lazy loaded (internal only) - Code Splitting für Performance +const SlideshowPage = lazy(() => import('./Components/Pages/SlideshowPage')); +const GroupsOverviewPage = lazy(() => import('./Components/Pages/GroupsOverviewPage')); +const PublicGroupImagesPage = lazy(() => import('./Components/Pages/PublicGroupImagesPage')); +const ModerationGroupsPage = lazy(() => import('./Components/Pages/ModerationGroupsPage')); +const ModerationGroupImagesPage = lazy(() => import('./Components/Pages/ModerationGroupImagesPage')); + +/** + * Protected Route Component + * Redirects to upload page if accessed from public host + */ +const ProtectedRoute = ({ children }) => { + const hostConfig = getHostConfig(); + + if (hostConfig.isPublic) { + // Redirect to upload page - feature not available on public + return ; + } + + return children; +}; + +/** + * Loading Fallback für Code Splitting + */ +const LoadingFallback = () => ( +
+
+

Lädt...

+
+); function App() { + const hostConfig = getHostConfig(); + return ( - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - + }> + + {/* Public Routes - immer verfügbar */} + } /> + } /> + + {/* Internal Only Routes - nur auf internal host geladen */} + {hostConfig.isInternal && ( + <> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + )} + + {/* 404 / Not Found */} + } /> + + ); diff --git a/frontend/src/Components/Pages/404Page.js b/frontend/src/Components/Pages/404Page.js index 79d57c3..ef8038b 100644 --- a/frontend/src/Components/Pages/404Page.js +++ b/frontend/src/Components/Pages/404Page.js @@ -1,14 +1,51 @@ import React from 'react' import Navbar from '../ComponentUtils/Headers/Navbar' +import { getHostConfig } from '../../Utils/hostDetection' import './Css/404Page.css' function FZF() { + const hostConfig = getHostConfig(); + return (
-
+
+ {hostConfig.isPublic ? ( +
+

404 - Diese Funktion ist nicht verfügbar

+

Diese Funktion ist nur über das interne Netzwerk erreichbar.

+ + Zurück zum Upload + +
+ ) : ( + <> + + + + + + + + + + + + + + + + + + )} +
+
+ ) +} + +export default FZF diff --git a/frontend/src/Utils/hostDetection.js b/frontend/src/Utils/hostDetection.js new file mode 100644 index 0000000..ee6f7dc --- /dev/null +++ b/frontend/src/Utils/hostDetection.js @@ -0,0 +1,94 @@ +/** + * Host Detection Utility + * + * Erkennt, ob App auf public oder internal Host läuft + * Basiert auf window.location.hostname + env-config + * + * @module Utils/hostDetection + */ + +/** + * Hole Host-Konfiguration und Feature-Flags + * @returns {Object} Host-Config mit Feature-Flags + */ +export const getHostConfig = () => { + const hostname = window.location.hostname; + + // Hole Hosts aus Runtime-Config (wird von env.sh beim Container-Start gesetzt) + const publicHost = window._env_?.PUBLIC_HOST || 'deinprojekt.hobbyhimmel.de'; + const internalHost = window._env_?.INTERNAL_HOST || 'deinprojekt.lan.hobbyhimmel.de'; + + // Bestimme Host-Typ + const isPublic = hostname === publicHost; + const isInternal = hostname === internalHost || hostname === 'localhost' || hostname === '127.0.0.1'; + + // Feature Flags basierend auf Host + return { + hostname, + publicHost, + internalHost, + isPublic, + isInternal, + + // Feature Flags + canAccessAdmin: isInternal, + canAccessSlideshow: isInternal, + canAccessGroups: isInternal, + canAccessModeration: isInternal, + canAccessReorder: isInternal, + canAccessBatchUpload: isInternal, + canAccessSocialMedia: isInternal, + canAccessMigration: isInternal, + + // Immer erlaubt (public + internal) + canUpload: true, + canManageByUUID: true + }; +}; + +/** + * Prüft, ob App auf public Host läuft + * @returns {boolean} True wenn public Host + */ +export const isPublicHost = () => { + return getHostConfig().isPublic; +}; + +/** + * Prüft, ob App auf internal Host läuft + * @returns {boolean} True wenn internal Host + */ +export const isInternalHost = () => { + return getHostConfig().isInternal; +}; + +/** + * Hole spezifisches Feature-Flag + * @param {string} featureName - Name des Features (z.B. 'canAccessAdmin') + * @returns {boolean} True wenn Feature erlaubt + */ +export const canAccessFeature = (featureName) => { + const config = getHostConfig(); + return config[featureName] || false; +}; + +/** + * Debug-Funktion: Logge Host-Config in Console + * Nur in Development + */ +export const logHostConfig = () => { + if (process.env.NODE_ENV === 'development') { + const config = getHostConfig(); + console.log('🔍 Host Configuration:', { + hostname: config.hostname, + isPublic: config.isPublic, + isInternal: config.isInternal, + features: { + admin: config.canAccessAdmin, + slideshow: config.canAccessSlideshow, + groups: config.canAccessGroups, + moderation: config.canAccessModeration + } + }); + } +};