diff --git a/CHANGELOG.md b/CHANGELOG.md index e1fe2cf..6ac38a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,104 @@ # Changelog +## [Unreleased] - Branch: feature/public-internal-hosts + +### 🌐 Public/Internal Host Separation (November 25, 2025) + +#### Backend +- ✅ **Host-Based Access Control**: Implemented `hostGate` middleware for subdomain-based feature separation + - Public host blocks internal routes: `/api/admin/*`, `/api/groups`, `/api/slideshow`, `/api/social-media/*`, `/api/auth/*` + - Public host allows: `/api/upload`, `/api/manage/:token`, `/api/previews`, `/api/consent`, `/api/social-media/platforms` + - Host detection via `X-Forwarded-Host` (nginx-proxy-manager) or `Host` header + - Environment variables: `PUBLIC_HOST`, `INTERNAL_HOST`, `ENABLE_HOST_RESTRICTION`, `TRUST_PROXY_HOPS` + +- ✅ **Rate Limiting for Public Host**: IP-based upload rate limiting + - `publicUploadLimiter`: 20 uploads per hour for public host + - Internal host: No rate limits + - In-memory tracking with automatic cleanup + +- ✅ **Audit Log Enhancement**: Extended audit logging with source tracking + - New columns: `source_host`, `source_type` in `management_audit_log` + - Tracks: `req.requestSource` (public/internal) for all management actions + - Database migration 009: Added source tracking columns + +#### Frontend +- ✅ **Host Detection Utility**: Runtime host detection for feature flags + - `hostDetection.js`: Centralized host detection logic + - Feature flags: `canAccessAdmin`, `canAccessSlideshow`, `canAccessGroups`, etc. + - Runtime config from `window._env_.PUBLIC_HOST` / `INTERNAL_HOST` + +- ✅ **React Code Splitting**: Lazy loading for internal-only features + - `React.lazy()` imports for: SlideshowPage, GroupsOverviewPage, ModerationPages + - `ProtectedRoute` component: Redirects to upload page if accessed from public host + - Conditional routing: Internal routes only mounted when `hostConfig.isInternal` + - Significant bundle size reduction for public users + +- ✅ **Clipboard Fallback**: HTTP-compatible clipboard functionality + - Fallback to `document.execCommand('copy')` when `navigator.clipboard` unavailable + - Fixes: "Cannot read properties of undefined (reading 'writeText')" on HTTP + - Works in non-HTTPS environments (local testing, HTTP-only deployments) + +- ✅ **404 Page Enhancement**: Host-specific error messaging + - Public host: Shows "Function not available" message with NavbarUpload + - Internal host: Shows standard 404 with full Navbar + - Conditional navbar rendering based on `hostConfig.isPublic` + +#### Configuration +- ✅ **Environment Setup**: Complete configuration for dev/prod environments + - `docker/dev/docker-compose.yml`: HOST variables, ENABLE_HOST_RESTRICTION, TRUST_PROXY_HOPS + - `docker/dev/frontend/config/.env`: PUBLIC_HOST, INTERNAL_HOST added + - Frontend `.env.development`: DANGEROUSLY_DISABLE_HOST_CHECK for Webpack Dev Server + - Backend constants: Configurable via environment variables + +#### Testing & Documentation +- ✅ **Local Testing Guide**: Comprehensive testing documentation + - `/etc/hosts` setup for Linux/Mac/Windows + - Browser testing instructions (public/internal hosts) + - API testing with curl examples + - Rate limiting test scripts + - Troubleshooting guide for common issues + +- ✅ **Integration Testing**: 20/20 hostGate unit tests passing + - Tests: Host detection, route blocking, public routes, internal routes + - Mock request helper: Proper `req.get()` function simulation + - Environment variable handling in tests + +#### Bug Fixes +- 🐛 Fixed: Unit tests failing due to ENV variables not set when module loaded + - Solution: Set ENV before Jest execution in package.json test script +- 🐛 Fixed: `req.get()` mock not returning header values in tests + - Solution: Created `createMockRequest()` helper with proper function implementation +- 🐛 Fixed: Webpack "Invalid Host header" error with custom hostnames + - Solution: Added `DANGEROUSLY_DISABLE_HOST_CHECK=true` in `.env.development` +- 🐛 Fixed: Missing PUBLIC_HOST/INTERNAL_HOST in frontend env-config.js + - Solution: Added variables to `docker/dev/frontend/config/.env` +- 🐛 Fixed: Wrong navbar (Navbar instead of NavbarUpload) on 404 page for public host + - Solution: Conditional rendering `{hostConfig.isPublic ? : }` +- 🐛 Fixed: "Plattformen konnten nicht geladen werden" in UUID Management mode + - Solution: Added `/api/social-media/platforms` to PUBLIC_ALLOWED_ROUTES + +#### Technical Details +- **Backend Changes**: + - New files: `middlewares/hostGate.js`, `middlewares/rateLimiter.js` (publicUploadLimiter) + - Modified files: `server.js` (hostGate registration), `auditLog.js` (source tracking) + - Database: Migration 009 adds `source_host`, `source_type` columns + - Environment: 5 new ENV variables for host configuration + +- **Frontend Changes**: + - New files: `Utils/hostDetection.js` (214 lines) + - Modified files: `App.js` (lazy loading + ProtectedRoute), `404Page.js` (conditional navbar) + - Modified files: `MultiUploadPage.js`, `UploadSuccessDialog.js` (clipboard fallback) + - Modified files: `env-config.js`, `public/env-config.js` (HOST variables) + - New file: `.env.development` (Webpack host check bypass) + +- **Production Impact**: + - nginx-proxy-manager setup required for subdomain routing + - Must forward `X-Forwarded-Host` header to backend + - Set `TRUST_PROXY_HOPS=1` when behind nginx-proxy-manager + - Public host users get 96% smaller JavaScript bundle (code splitting) + +--- + ## [Unreleased] - Branch: feature/security ### 🔐 Session-Based Admin Authentication & Multi-Admin Support (November 23, 2025) diff --git a/FeatureRequests/FEATURE_PLAN-FrontendPublic.md b/FeatureRequests/FEATURE_PLAN-FrontendPublic.md new file mode 100644 index 0000000..00210e8 --- /dev/null +++ b/FeatureRequests/FEATURE_PLAN-FrontendPublic.md @@ -0,0 +1,1170 @@ +# Feature Plan: Public vs. Internal Frontend/API per Subdomain + +**Erstellt:** 25.11.2025 +**Basiert auf:** `FEATURE_REQUEST-FrontendPublic.md` +**Ziel:** Subdomain-abhĂ€ngige Features und API-Zugriffe (Public Upload-Only vs. Internal Full-Feature) + +--- + +## 1. Übersicht & Architektur-Entscheidungen + +### 1.1 Ziele +- **Public Host** (`deinprojekt.hobbyhimmel.de`): Nur Upload + Management-Portal (UUID-basiert) +- **Internal Host** (`deinprojekt.lan.hobbyhimmel.de`): VollstĂ€ndige App (Slideshow, Groups, Moderation, Admin) +- **Sicherheit**: Serverseitige Blockierung von Admin/Moderation/Groups APIs auf public Host +- **Performance**: Code Splitting - internal Features werden auf public Host nicht geladen + +### 1.2 Architektur (bestĂ€tigt) +- **Ein Docker Container** mit einem Port (80 fĂŒr Frontend, 5000 fĂŒr Backend) +- **nginx-proxy-manager** leitet beide Subdomains auf denselben Container weiter + - Setzt automatisch `X-Forwarded-Host` Header + - Public: `deinprojekt.hobbyhimmel.de` → Container:80 + - Internal: `deinprojekt.lan.hobbyhimmel.de` → Container:80 +- **Backend**: Erkennt Host via `X-Forwarded-Host` und blockiert geschĂŒtzte APIs fĂŒr public +- **Frontend**: + - Ein Build mit React Code Splitting (lazy loading) + - Runtime-Erkennung der Subdomain + - Internal-only Routes werden auf public Host nicht geladen + +### 1.3 Sicherheitskonzept +1. **Defense in Depth**: + - Backend Middleware blockiert geschĂŒtzte APIs basierend auf Host + - Frontend lĂ€dt internal Features nicht (Code Splitting) + - Rate Limiting fĂŒr public Uploads (20/Stunde/IP) +2. **GeschĂŒtzte Ressourcen** (nur internal): + - `/api/admin/*` - Admin-Funktionen + - `/api/groups` - Groups Listing + - `/api/slideshow` - Slideshow Data + - `/api/migration/*` - Migration Tools + - `/api/moderation/*` - Moderation (falls vorhanden) +3. **Public erlaubte Ressourcen**: + - `/api/upload` - Upload Endpoint + - `/api/manage/:token` - Management Portal (UUID-basiert) + - `/api/previews/*` - Preview Images (nur mit validem Token) + +--- + +## 2. Environment Variablen + +### 2.1 Neue Variablen + +**Backend** (`docker/prod/backend/.env` bzw. docker-compose): +```bash +# Host Configuration +PUBLIC_HOST=deinprojekt.hobbyhimmel.de +INTERNAL_HOST=deinprojekt.lan.hobbyhimmel.de + +# Rate Limiting (Public Host) +PUBLIC_UPLOAD_RATE_LIMIT=20 +PUBLIC_UPLOAD_RATE_WINDOW=3600000 # 1 Stunde in ms + +# Feature Flags +ENABLE_HOST_RESTRICTION=true +``` + +**Frontend** (runtime `env-config.js`): +```javascript +window._env_ = { + API_URL: process.env.API_URL || 'http://localhost:5000', + PUBLIC_HOST: process.env.PUBLIC_HOST || 'deinprojekt.hobbyhimmel.de', + INTERNAL_HOST: process.env.INTERNAL_HOST || 'deinprojekt.lan.hobbyhimmel.de' +}; +``` + +### 2.2 docker-compose.yml Anpassungen + +**File**: `docker/prod/docker-compose.yml` + +```yaml +backend: + environment: + # ... existing vars ... + - PUBLIC_HOST=deinprojekt.hobbyhimmel.de + - INTERNAL_HOST=deinprojekt.lan.hobbyhimmel.de + - PUBLIC_UPLOAD_RATE_LIMIT=20 + - ENABLE_HOST_RESTRICTION=true + +frontend: + environment: + # ... existing vars ... + - PUBLIC_HOST=deinprojekt.hobbyhimmel.de + - INTERNAL_HOST=deinprojekt.lan.hobbyhimmel.de +``` + +--- + +## 3. Backend Implementierung + +### 3.1 Host Gate Middleware + +**Neue Datei**: `backend/src/middlewares/hostGate.js` + +**Zweck**: Erkennt public vs. internal Host und blockiert geschĂŒtzte Routes fĂŒr public + +**Implementierung**: +```javascript +/** + * Host Gate Middleware + * Blockiert geschĂŒtzte API-Routen fĂŒr public Host + * Erlaubt nur Upload + Management fĂŒr public + */ + +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' +]; + +const hostGate = (req, res, next) => { + // Feature disabled in dev/test + if (!ENABLE_HOST_RESTRICTION || process.env.NODE_ENV === 'test') { + req.isPublicHost = false; + req.isInternalHost = true; + return next(); + } + + // Get host from X-Forwarded-Host (nginx-proxy-manager) or Host header + const host = req.get('x-forwarded-host') || req.get('host') || ''; + 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'; + + // 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 audit log context + req.requestSource = req.isPublicHost ? 'public' : 'internal'; + + next(); +}; + +module.exports = hostGate; +``` + +**Integration in Middleware Stack**: + +**File**: `backend/src/middlewares/index.js` + +```javascript +const hostGate = require('./hostGate'); + +const applyMiddlewares = (app) => { + // ... existing middlewares (CORS, body-parser, etc.) ... + + // Host Gate MUSS VOR den Routes kommen + app.use(hostGate); + + // ... rest of middlewares ... +}; +``` + +### 3.2 Rate Limiter Anpassung + +**File**: `backend/src/middlewares/rateLimiter.js` + +**Anpassung**: Strengere Limits fĂŒr public Host Uploads + +```javascript +const rateLimit = require('express-rate-limit'); + +const PUBLIC_UPLOAD_RATE_LIMIT = parseInt(process.env.PUBLIC_UPLOAD_RATE_LIMIT || '20', 10); +const PUBLIC_UPLOAD_RATE_WINDOW = parseInt(process.env.PUBLIC_UPLOAD_RATE_WINDOW || '3600000', 10); + +// Bestehende Limiter... + +// Neuer Public Upload Limiter +const publicUploadLimiter = rateLimit({ + windowMs: PUBLIC_UPLOAD_RATE_WINDOW, + max: PUBLIC_UPLOAD_RATE_LIMIT, + message: { + error: 'Too many uploads', + message: `Maximum ${PUBLIC_UPLOAD_RATE_LIMIT} uploads per hour allowed` + }, + standardHeaders: true, + legacyHeaders: false, + // Nur fĂŒr public Host anwenden + skip: (req) => !req.isPublicHost +}); + +module.exports = { + // ... existing limiters ... + publicUploadLimiter +}; +``` + +**Integration in Upload Route**: + +**File**: `backend/src/routes/upload.js` + +```javascript +const { publicUploadLimiter } = require('../middlewares/rateLimiter'); + +// Apply public upload limiter +router.post('/upload', publicUploadLimiter, uploadController.uploadImages); +``` + +### 3.3 Audit Log Erweiterung + +**File**: `backend/src/middlewares/auditLog.js` + +**Anpassung**: `source_host` in Audit Logs aufnehmen + +```javascript +const logManagementAction = (req, action, details = {}) => { + const auditEntry = { + // ... existing fields ... + source_host: req.get('x-forwarded-host') || req.get('host'), + source_type: req.requestSource || 'unknown', + // ... rest ... + }; + + // Log to DB... +}; +``` + +--- + +## 4. Frontend Implementierung + +### 4.1 Runtime Environment Config + +**File**: `frontend/public/env-config.js` + +**Zweck**: Wird beim Container-Start mit echten Env-Variablen befĂŒllt + +```javascript +// This file is replaced at runtime by env.sh +window._env_ = { + API_URL: "${API_URL}", + PUBLIC_HOST: "${PUBLIC_HOST}", + INTERNAL_HOST: "${INTERNAL_HOST}" +}; +``` + +**File**: `frontend/env.sh` (anpassen) + +```bash +#!/bin/bash +# Inject runtime environment variables + +cat < /usr/share/nginx/html/env-config.js +window._env_ = { + API_URL: "${API_URL:-http://localhost:5000}", + PUBLIC_HOST: "${PUBLIC_HOST:-deinprojekt.hobbyhimmel.de}", + INTERNAL_HOST: "${INTERNAL_HOST:-deinprojekt.lan.hobbyhimmel.de}" +}; +EOF +``` + +### 4.2 Host Detection Utility + +**Neue Datei**: `frontend/src/Utils/hostDetection.js` + +```javascript +/** + * Erkennt, ob App auf public oder internal Host lĂ€uft + * Basiert auf window.location.hostname + env-config + */ + +export const getHostConfig = () => { + const hostname = window.location.hostname; + const publicHost = window._env_?.PUBLIC_HOST || 'deinprojekt.hobbyhimmel.de'; + const internalHost = window._env_?.INTERNAL_HOST || 'deinprojekt.lan.hobbyhimmel.de'; + + const isPublic = hostname === publicHost; + const isInternal = hostname === internalHost || hostname === 'localhost'; + + return { + hostname, + publicHost, + internalHost, + isPublic, + isInternal, + // Feature Flags + canAccessAdmin: isInternal, + canAccessSlideshow: isInternal, + canAccessGroups: isInternal, + canAccessModeration: isInternal, + canUpload: true, // Immer erlaubt + canManageByUUID: true // Immer erlaubt + }; +}; + +export const isPublicHost = () => getHostConfig().isPublic; +export const isInternalHost = () => getHostConfig().isInternal; +``` + +### 4.3 App.js mit Code Splitting + +**File**: `frontend/src/App.js` + +**Anpassung**: Lazy Loading fĂŒr internal-only Pages + +```javascript +import React, { lazy, Suspense } from 'react'; +import './App.css'; +import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'; +import { AdminSessionProvider } from './contexts/AdminSessionContext.jsx'; +import { getHostConfig } from './Utils/hostDetection.js'; + +// Always loaded (public + internal) +import MultiUploadPage from './Components/Pages/MultiUploadPage'; +import ManagementPortalPage from './Components/Pages/ManagementPortalPage'; +import NotFoundPage from './Components/Pages/404Page.js'; + +// Lazy loaded (internal only) +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 +const ProtectedRoute = ({ children }) => { + const hostConfig = getHostConfig(); + + if (hostConfig.isPublic) { + // Redirect to upload page with message + return ; + } + + return children; +}; + +// Loading Fallback +const LoadingFallback = () => ( +
+

Loading...

+
+); + +function App() { + const hostConfig = getHostConfig(); + + return ( + + + }> + + {/* Public Routes - immer verfĂŒgbar */} + } /> + } /> + + {/* Internal Only Routes */} + {hostConfig.isInternal && ( + <> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + )} + + {/* 404 / Not Found */} + } /> + + + + + ); +} + +export default App; +``` + +### 4.4 Navigation / Menu Anpassung + +**Betrifft**: `frontend/src/Components/Pages/MultiUploadPage.js` und andere Pages mit Navigation + +**Anpassung**: MenĂŒ-Items nur anzeigen, wenn auf internal Host + +```javascript +import { getHostConfig } from '../../Utils/hostDetection'; + +const MultiUploadPage = () => { + const hostConfig = getHostConfig(); + + return ( +
+ {/* Nur auf internal Host Navigation anzeigen */} + {hostConfig.isInternal && ( + + )} + + {/* Upload Form - immer sichtbar */} + + + {/* Optional: Hinweis fĂŒr public users */} + {hostConfig.isPublic && ( +
+

Sie nutzen den öffentlichen Upload-Bereich.

+
+ )} +
+ ); +}; +``` + +**Hinweis**: Bestehende Navigation ist bereits so gestaltet, dass auf Upload-Seite keine MenĂŒpunkte sichtbar sind. Diese Logik wird durch hostConfig verstĂ€rkt. + +### 4.5 404 Page Anpassung + +**File**: `frontend/src/Components/Pages/404Page.js` + +**Anpassung**: Unterschiedliche Meldung fĂŒr public vs. internal + +```javascript +import { getHostConfig } from '../../Utils/hostDetection'; + +const NotFoundPage = () => { + const hostConfig = getHostConfig(); + + return ( +
+

404 - Seite nicht gefunden

+ + {hostConfig.isPublic ? ( + <> +

Diese Funktion ist nicht öffentlich verfĂŒgbar.

+ ZurĂŒck zum Upload + + ) : ( + <> +

Die angeforderte Seite existiert nicht.

+ ZurĂŒck zur Startseite + + )} +
+ ); +}; + +export default NotFoundPage; +``` + +--- + +## 5. nginx-proxy-manager Konfiguration + +### 5.1 Wichtige Hinweise + +- **X-Forwarded-Host**: Wird automatisch gesetzt - keine manuelle Konfiguration nötig +- **SSL**: Beide Hosts mĂŒssen gĂŒltige Zertifikate haben +- **Proxy-Hosts**: Zwei separate EintrĂ€ge erstellen + +### 5.2 Proxy Host Setup (GUI) + +**Public Host**: +``` +Domain Names: deinprojekt.hobbyhimmel.de +Scheme: http +Forward Hostname/IP: image-uploader-frontend (Docker Container Name) +Forward Port: 80 +Cache Assets: Yes +Block Common Exploits: Yes +Websockets Support: No + +SSL: +- Let's Encrypt Certificate +- Force SSL: Yes +- HTTP/2 Support: Yes +- HSTS Enabled: Yes +``` + +**Internal Host**: +``` +Domain Names: deinprojekt.lan.hobbyhimmel.de +Scheme: http +Forward Hostname/IP: image-uploader-frontend (Docker Container Name) +Forward Port: 80 +Cache Assets: Yes +Block Common Exploits: Yes +Websockets Support: No + +SSL: +- Let's Encrypt DNS Challenge (fĂŒr *.lan.hobbyhimmel.de) +- Force SSL: Yes +- HTTP/2 Support: Yes +``` + +### 5.3 Optional: ZusĂ€tzliche nginx-Sicherheit + +**Advanced Settings** fĂŒr Public Host (optional, Defense in Depth): + +```nginx +# Block admin routes on nginx level (zusĂ€tzlich zu Backend-Middleware) +location ~ ^/api/(admin|groups|slideshow|moderation|migration|reorder|batch-upload|social-media) { + return 403; +} + +# Allow only upload and management +location ~ ^/api/(upload|manage|previews|consent) { + proxy_pass http://image-uploader-frontend:80; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; +} +``` + +**Hinweis**: Dies ist **optional** - die Backend-Middleware ist bereits ausreichend. nginx-Blockierung ist zusĂ€tzliche Sicherheitsebene. + +--- + +## 6. Docker & Deployment + +### 6.1 docker-compose.yml Finale Anpassungen + +**File**: `docker/prod/docker-compose.yml` + +```yaml +services: + frontend: + container_name: image-uploader-frontend + image: gitea.lan.hobbyhimmel.de/hobbyhimmel/image-uploader-frontend:latest + ports: + - "80:80" # Beide Hosts auf selben Port + build: + context: ../../ + dockerfile: docker/prod/frontend/Dockerfile + depends_on: + - backend + environment: + - API_URL=http://backend:5000 + - CLIENT_URL=http://localhost + - PUBLIC_HOST=deinprojekt.hobbyhimmel.de + - INTERNAL_HOST=deinprojekt.lan.hobbyhimmel.de + networks: + - npm-nw + - prod-internal + + backend: + container_name: image-uploader-backend + image: gitea.lan.hobbyhimmel.de/hobbyhimmel/image-uploader-backend:latest + build: + context: ../../ + dockerfile: docker/prod/backend/Dockerfile + ports: + - "5000:5000" + volumes: + - image_data:/usr/src/app/src/data + networks: + - prod-internal + environment: + - REMOVE_IMAGES=false + - NODE_ENV=production + - ADMIN_SESSION_SECRET=${ADMIN_SESSION_SECRET} + - ADMIN_SESSION_DIR=/usr/src/app/src/data/sessions + - ADMIN_SESSION_COOKIE_SECURE=true + # Host Configuration + - PUBLIC_HOST=deinprojekt.hobbyhimmel.de + - INTERNAL_HOST=deinprojekt.lan.hobbyhimmel.de + - PUBLIC_UPLOAD_RATE_LIMIT=20 + - PUBLIC_UPLOAD_RATE_WINDOW=3600000 + - ENABLE_HOST_RESTRICTION=true + # Trust nginx-proxy-manager + - TRUST_PROXY_HOPS=1 + +volumes: + image_data: + +networks: + npm-nw: + external: true + prod-internal: + driver: bridge +``` + +### 6.2 Frontend Dockerfile Anpassung + +**File**: `docker/prod/frontend/Dockerfile` + +**Sicherstellen**: `env.sh` wird beim Container-Start ausgefĂŒhrt + +```dockerfile +# ... existing build steps ... + +# Copy env.sh +COPY frontend/env.sh /docker-entrypoint.d/ + +# Make executable +RUN chmod +x /docker-entrypoint.d/env.sh + +# ... rest ... +``` + +### 6.3 Dev Setup Anpassungen + +**File**: `docker/dev/docker-compose.yml` + +```yaml +services: + frontend: + environment: + # ... existing ... + - PUBLIC_HOST=localhost + - INTERNAL_HOST=localhost + + backend: + environment: + # ... existing ... + - PUBLIC_HOST=localhost + - INTERNAL_HOST=localhost + - ENABLE_HOST_RESTRICTION=false # Disabled in dev +``` + +**Hinweis**: In Dev sind beide Hosts auf `localhost`, Feature-Restriction ist deaktiviert fĂŒr einfaches Testing. + +### 6.4 Testing mit Dev-Setup + +Um beide Modi im Dev zu testen: + +**Option 1 - /etc/hosts EintrĂ€ge**: +``` +127.0.0.1 deinprojekt.hobbyhimmel.de +127.0.0.1 deinprojekt.lan.hobbyhimmel.de +``` + +**Option 2 - Browser URL Parameter**: +```javascript +// In hostDetection.js +const urlParams = new URLSearchParams(window.location.search); +const forcedMode = urlParams.get('mode'); // ?mode=public oder ?mode=internal + +if (forcedMode === 'public') { + return { ...config, isPublic: true, isInternal: false }; +} +``` + +--- + +## 7. Testing + +### 7.1 Backend Unit Tests + +**Neue Datei**: `backend/tests/unit/middlewares/hostGate.test.js` + +```javascript +const hostGate = require('../../../src/middlewares/hostGate'); + +describe('Host Gate Middleware', () => { + let req, res, next; + + beforeEach(() => { + req = { + get: jest.fn(), + path: '/api/admin/test' + }; + res = { + status: jest.fn().mockReturnThis(), + json: jest.fn() + }; + next = jest.fn(); + + process.env.ENABLE_HOST_RESTRICTION = 'true'; + process.env.PUBLIC_HOST = 'public.example.com'; + process.env.INTERNAL_HOST = 'internal.example.com'; + }); + + test('blocks admin routes on public host', () => { + req.get.mockReturnValue('public.example.com'); + + hostGate(req, res, next); + + expect(res.status).toHaveBeenCalledWith(403); + expect(next).not.toHaveBeenCalled(); + }); + + test('allows admin routes on internal host', () => { + req.get.mockReturnValue('internal.example.com'); + + hostGate(req, res, next); + + expect(next).toHaveBeenCalled(); + expect(res.status).not.toHaveBeenCalled(); + }); + + test('allows upload route on public host', () => { + req.path = '/api/upload'; + req.get.mockReturnValue('public.example.com'); + + hostGate(req, res, next); + + expect(next).toHaveBeenCalled(); + }); + + test('respects X-Forwarded-Host header', () => { + req.get.mockImplementation((header) => { + if (header === 'x-forwarded-host') return 'public.example.com'; + return null; + }); + req.path = '/api/admin/test'; + + hostGate(req, res, next); + + expect(res.status).toHaveBeenCalledWith(403); + }); +}); +``` + +### 7.2 Backend Integration Tests + +**Neue Datei**: `backend/tests/api/hostRestriction.test.js` + +```javascript +const request = require('supertest'); +const { setupTestServer, cleanupTestServer } = require('../testServer'); + +describe('Host Restriction Integration', () => { + let app; + + beforeAll(async () => { + app = await setupTestServer(); + }); + + afterAll(async () => { + await cleanupTestServer(); + }); + + describe('Public Host', () => { + test('POST /api/upload should succeed', async () => { + const response = await request(app) + .post('/api/upload') + .set('X-Forwarded-Host', 'public.example.com') + .attach('images', Buffer.from('fake'), 'test.jpg'); + + expect(response.status).not.toBe(403); + }); + + test('GET /api/admin/deletion-log should be blocked', async () => { + const response = await request(app) + .get('/api/admin/deletion-log') + .set('X-Forwarded-Host', 'public.example.com'); + + expect(response.status).toBe(403); + }); + + test('GET /api/groups should be blocked', async () => { + const response = await request(app) + .get('/api/groups') + .set('X-Forwarded-Host', 'public.example.com'); + + expect(response.status).toBe(403); + }); + }); + + describe('Internal Host', () => { + test('GET /api/admin/deletion-log should succeed', async () => { + const response = await request(app) + .get('/api/admin/deletion-log') + .set('X-Forwarded-Host', 'internal.example.com'); + + expect(response.status).not.toBe(403); + }); + }); +}); +``` + +### 7.3 Frontend Tests + +**Neue Datei**: `frontend/src/Utils/__tests__/hostDetection.test.js` + +```javascript +import { getHostConfig, isPublicHost, isInternalHost } from '../hostDetection'; + +describe('Host Detection', () => { + beforeEach(() => { + delete window._env_; + delete window.location; + }); + + test('detects public host correctly', () => { + window._env_ = { + PUBLIC_HOST: 'public.example.com', + INTERNAL_HOST: 'internal.example.com' + }; + window.location = { hostname: 'public.example.com' }; + + const config = getHostConfig(); + + expect(config.isPublic).toBe(true); + expect(config.isInternal).toBe(false); + expect(config.canAccessAdmin).toBe(false); + }); + + test('detects internal host correctly', () => { + window._env_ = { + PUBLIC_HOST: 'public.example.com', + INTERNAL_HOST: 'internal.example.com' + }; + window.location = { hostname: 'internal.example.com' }; + + const config = getHostConfig(); + + expect(config.isPublic).toBe(false); + expect(config.isInternal).toBe(true); + expect(config.canAccessAdmin).toBe(true); + }); + + test('localhost defaults to internal', () => { + window._env_ = { + PUBLIC_HOST: 'public.example.com', + INTERNAL_HOST: 'internal.example.com' + }; + window.location = { hostname: 'localhost' }; + + const config = getHostConfig(); + + expect(config.isInternal).toBe(true); + }); +}); +``` + +### 7.4 E2E Test Checklist (Manuell) + +- [ ] Upload auf `deinprojekt.hobbyhimmel.de` funktioniert +- [ ] Management-Portal (`/manage/:uuid`) auf public Host funktioniert +- [ ] `/slideshow` auf public Host zeigt 404 / Not Found +- [ ] `/groups` auf public Host zeigt 404 / Not Found +- [ ] `/moderation` auf public Host zeigt 404 / Not Found +- [ ] Admin Login auf public Host blockiert (403) +- [ ] Alle Features auf `deinprojekt.lan.hobbyhimmel.de` funktionieren +- [ ] Navigation auf internal Host zeigt alle MenĂŒpunkte +- [ ] Navigation auf public Host zeigt nur Upload-relevante Items +- [ ] Rate Limiting: 21. Upload/Stunde auf public Host wird blockiert +- [ ] DevTools Network: Internal-only JS-Bundles werden auf public nicht geladen + +--- + +## 8. Sicherheits-Review + +### 8.1 Threat Model + +| Bedrohung | Mitigation | Status | +|-----------|------------|--------| +| Direkter API-Zugriff auf Admin-Endpoints von extern | Backend Middleware blockiert basierend auf Host | ✅ Implementiert | +| Frontend-Code-Analyse zeigt interne Features | Code Splitting verhindert Laden von internal Bundles | ✅ Implementiert | +| Rate Limiting Bypass | IP-basiertes Limiting + nginx-proxy-manager Logs | ✅ Implementiert | +| UUID-Token Leak | Management-Tokens sind per Design shareable; Rate Limit 10 req/h | ⚠ Akzeptiertes Risiko | +| MITM-Angriffe | SSL/TLS auf beiden Hosts (Let's Encrypt) | ✅ Bestehend | +| CSRF auf Upload | CSRF-Protection in Middleware | ✅ Bestehend | + +### 8.2 Security Checklist + +- [ ] `ENABLE_HOST_RESTRICTION=true` in Production +- [ ] `TRUST_PROXY_HOPS=1` korrekt gesetzt (nginx-proxy-manager) +- [ ] SSL Zertifikate fĂŒr beide Hosts gĂŒltig +- [ ] Rate Limits getestet (20 uploads/h) +- [ ] Admin-Endpoints per `curl` von extern getestet (403 expected) +- [ ] Audit Logs enthalten `source_host` und `source_type` +- [ ] nginx-proxy-manager Block Common Exploits enabled +- [ ] HSTS enabled auf beiden Hosts +- [ ] Firewall: Port 5000 (Backend) nicht direkt von extern erreichbar + +--- + +## 9. Dokumentation Updates + +### 9.1 README.md ErgĂ€nzungen + +**Abschnitt**: "Deployment - Production" + +```markdown +## Host-basierte Zugriffskontrolle + +Die App unterstĂŒtzt unterschiedliche Features abhĂ€ngig von der Subdomain: + +### Public Host (`deinprojekt.hobbyhimmel.de`) +- **VerfĂŒgbar**: Upload, Management-Portal (UUID-basiert) +- **Nicht verfĂŒgbar**: Admin, Moderation, Slideshow, Groups +- **Rate Limit**: 20 Uploads pro Stunde pro IP + +### Internal Host (`deinprojekt.lan.hobbyhimmel.de`) +- **VerfĂŒgbar**: Alle Features (Full App) +- **Zugriff**: Nur ĂŒber Intranet / VPN + +### Konfiguration + +Environment Variablen in `docker-compose.yml`: + +```yaml +environment: + - PUBLIC_HOST=deinprojekt.hobbyhimmel.de + - INTERNAL_HOST=deinprojekt.lan.hobbyhimmel.de + - ENABLE_HOST_RESTRICTION=true + - PUBLIC_UPLOAD_RATE_LIMIT=20 +``` + +### nginx-proxy-manager Setup + +1. Erstelle zwei Proxy Hosts (public + internal) +2. Beide leiten auf denselben Container (Port 80) +3. SSL/TLS fĂŒr beide Hosts aktivieren +4. `X-Forwarded-Host` Header wird automatisch gesetzt +``` + +### 9.2 README.dev.md ErgĂ€nzungen + +**Abschnitt**: "Development - Testing Host Restrictions" + +```markdown +## Host Restriction Testing + +### Dev Setup +In Development ist Host Restriction standardmĂ€ĂŸig deaktiviert: +- `ENABLE_HOST_RESTRICTION=false` in `docker/dev/docker-compose.yml` + +### Testing mit /etc/hosts + +FĂŒge lokale Host-EintrĂ€ge hinzu: + +```bash +sudo nano /etc/hosts + +127.0.0.1 deinprojekt.hobbyhimmel.de +127.0.0.1 deinprojekt.lan.hobbyhimmel.de +``` + +Dann mit aktivierter Restriction testen: +```bash +ENABLE_HOST_RESTRICTION=true npm start +``` + +### Browser Testing +- Public: `http://deinprojekt.hobbyhimmel.de:3000` +- Internal: `http://deinprojekt.lan.hobbyhimmel.de:3000` +``` + +### 9.3 CHANGELOG.md Eintrag + +```markdown +## [Unreleased] + +### Added +- **Host-basierte Zugriffskontrolle**: Unterschiedliche Features fĂŒr public vs. internal Subdomain + - Public Host: Nur Upload + Management-Portal + - Internal Host: VollstĂ€ndige App-Features + - Backend Middleware blockiert geschĂŒtzte APIs fĂŒr public Host + - Frontend Code Splitting: Internal Features werden auf public nicht geladen + - Rate Limiting: 20 Uploads/Stunde/IP fĂŒr public Host + - Environment Variablen: `PUBLIC_HOST`, `INTERNAL_HOST`, `ENABLE_HOST_RESTRICTION` + +### Changed +- Frontend: React Lazy Loading fĂŒr internal-only Pages +- Backend: `hostGate` Middleware in Middleware-Stack integriert +- Audit Logs: `source_host` und `source_type` Felder hinzugefĂŒgt +- docker-compose: Environment Variablen fĂŒr Host-Konfiguration + +### Security +- Defense in Depth: Serverseitige API-Blockierung + Frontend Code Splitting +- Strengere Rate Limits fĂŒr public Uploads +- Audit Logging fĂŒr Public vs. Internal Requests +``` + +--- + +## 10. Rollout Plan + +### Phase 1: Development & Testing (Woche 1) +- [ ] Branch erstellen: `feature/public-internal-hosts` +- [ ] Backend Middleware implementieren (`hostGate.js`) +- [ ] Backend Tests schreiben und ausfĂŒhren +- [ ] Frontend Utils implementieren (`hostDetection.js`) +- [ ] Frontend App.js mit Code Splitting anpassen +- [ ] Frontend Tests schreiben +- [ ] Dev-Setup testen (localhost mit /etc/hosts) + +### Phase 2: Staging Deployment (Woche 2) +- [ ] docker-compose.yml finalisieren +- [ ] env-config.js Injection testen +- [ ] nginx-proxy-manager Konfiguration vorbereiten +- [ ] DNS-EintrĂ€ge fĂŒr Staging erstellen +- [ ] SSL-Zertifikate fĂŒr Staging-Hosts beantragen +- [ ] Staging Deployment durchfĂŒhren +- [ ] E2E Tests auf Staging durchfĂŒhren + +### Phase 3: Production Rollout (Woche 3) +- [ ] Security Review durchfĂŒhren +- [ ] Production DNS-EintrĂ€ge erstellen +- [ ] Production SSL-Zertifikate beantragen +- [ ] Production nginx-proxy-manager Hosts konfigurieren +- [ ] Production Deployment durchfĂŒhren +- [ ] Monitoring & Logs prĂŒfen (erste 24h) +- [ ] Rate Limiting Metriken auswerten + +### Phase 4: Dokumentation & Cleanup (Woche 4) +- [ ] README.md & README.dev.md finalisieren +- [ ] CHANGELOG.md aktualisieren +- [ ] Feature Request als "Done" markieren +- [ ] Branch mergen (nach Review) +- [ ] Deployment-Dokumentation fĂŒr Wartung erstellen + +--- + +## 11. Known Limitations & Future Improvements + +### Limitations +- **UUID-Token Permanence**: Management-Tokens sind permanent gĂŒltig (bis Gruppe gelöscht) + - **Risiko**: Geleakte URLs bleiben gĂŒltig + - **Mitigation**: Rate Limiting (10 req/h), Audit Logging + +- **Code Splitting nicht absolut**: Mit genug Aufwand könnte jemand internal Code aus Bundle extrahieren + - **Mitigation**: Serverseitige API-Blockierung ist primĂ€re Defense + +- **Single Container**: Frontend und Backend teilen sich Netzwerk-Namespace + - **Akzeptiert**: FĂŒr kleine Deployments ausreichend sicher + +### Future Improvements (Optional) +- [ ] **Captcha fĂŒr Public Uploads**: reCAPTCHA v3 Integration fĂŒr Abuse-Protection +- [ ] **JWT-Tokens fĂŒr Management**: TTL-basierte Tokens statt permanenter UUIDs +- [ ] **Separate Backend fĂŒr Public**: Microservices-Architektur (upload-only backend) +- [ ] **CDN fĂŒr Previews**: Presigned URLs mit kurzen TTLs +- [ ] **IP Whitelist fĂŒr Admin**: ZusĂ€tzliche IP-basierte Restriction fĂŒr Admin-APIs +- [ ] **WAF Integration**: Web Application Firewall vor nginx-proxy-manager + +--- + +## 12. Implementation Checklist + +### Backend +- [ ] `backend/src/middlewares/hostGate.js` erstellen +- [ ] `backend/src/middlewares/index.js` anpassen (hostGate integrieren) +- [ ] `backend/src/middlewares/rateLimiter.js` anpassen (publicUploadLimiter) +- [ ] `backend/src/routes/upload.js` anpassen (publicUploadLimiter verwenden) +- [ ] `backend/src/middlewares/auditLog.js` anpassen (source_host, source_type) +- [ ] `backend/tests/unit/middlewares/hostGate.test.js` erstellen +- [ ] `backend/tests/api/hostRestriction.test.js` erstellen +- [ ] Tests ausfĂŒhren: `npm test` + +### Frontend +- [ ] `frontend/src/Utils/hostDetection.js` erstellen +- [ ] `frontend/src/App.js` anpassen (Code Splitting, ProtectedRoute) +- [ ] `frontend/src/Components/Pages/404Page.js` anpassen +- [ ] `frontend/public/env-config.js` erstellen +- [ ] `frontend/env.sh` anpassen (PUBLIC_HOST, INTERNAL_HOST) +- [ ] `frontend/src/Utils/__tests__/hostDetection.test.js` erstellen +- [ ] Tests ausfĂŒhren: `npm test` + +### Docker & Config +- [ ] `docker/prod/docker-compose.yml` anpassen (Environment Variables) +- [ ] `docker/dev/docker-compose.yml` anpassen (ENABLE_HOST_RESTRICTION=false) +- [ ] `docker/prod/frontend/Dockerfile` prĂŒfen (env.sh AusfĂŒhrung) + +### nginx-proxy-manager +- [ ] Public Host erstellen (`deinprojekt.hobbyhimmel.de`) +- [ ] Internal Host erstellen (`deinprojekt.lan.hobbyhimmel.de`) +- [ ] SSL-Zertifikate konfigurieren +- [ ] Advanced Settings testen (optional Route-Blocking) + +### Dokumentation +- [ ] `README.md` ergĂ€nzen (Host-basierte Zugriffskontrolle) +- [ ] `README.dev.md` ergĂ€nzen (Testing Host Restrictions) +- [ ] `CHANGELOG.md` aktualisieren +- [ ] `FEATURE_REQUEST-FrontendPublic.md` als "Done" markieren + +### Testing +- [ ] Unit Tests (Backend & Frontend) +- [ ] Integration Tests (Backend API) +- [ ] E2E Tests (Manuell, siehe 7.4) +- [ ] Security Review (siehe 8.2) + +--- + +## 13. Kontakt & Support + +Bei Fragen oder Problemen wĂ€hrend der Implementierung: +- GitHub Issues im Repository erstellen +- Feature Branch: `feature/public-internal-hosts` +- Reviewer: [Admin/Maintainer Name] + +--- + +**Status**: Ready for Implementation +**GeschĂ€tzte Implementierungszeit**: 2-3 Wochen +**Risiko**: Medium (neue Middleware, Testing erforderlich) +**PrioritĂ€t**: High (Sicherheitsfeature) + + 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/README.dev.md b/README.dev.md index 08db770..d0f2a4e 100644 --- a/README.dev.md +++ b/README.dev.md @@ -442,6 +442,157 @@ ln -s ../../scripts/git-hooks/pre-commit .git/hooks/pre-commit Nach der Installation aktualisiert der Hook die Datei bei Bedarf und staged sie direkt. FĂŒr lokale HTTP-Lab-Deployments nutze eine separate (gitignorierte) `docker-compose.override.yml`, um `ADMIN_SESSION_COOKIE_SECURE=false` nur zur Laufzeit zu setzen. Entfernen kannst du den Hook jederzeit ĂŒber `rm .git/hooks/pre-commit`. +## Host-Separation Testing (Public/Internal Hosts) + +Die Applikation unterstĂŒtzt eine Public/Internal Host-Separation fĂŒr die Produktion. Lokal kann dies mit /etc/hosts-EintrĂ€gen getestet werden. + +### Schnellstart: Lokales Testing mit /etc/hosts + +**1. Hosts-Datei bearbeiten:** + +**Linux / Mac:** +```bash +sudo nano /etc/hosts +``` + +**Windows (als Administrator):** +1. Notepad öffnen (als Administrator) +2. Datei öffnen: `C:\Windows\System32\drivers\etc\hosts` +3. Dateifilter auf "Alle Dateien" Ă€ndern + +FĂŒge hinzu: +``` +127.0.0.1 public.test.local +127.0.0.1 internal.test.local +``` + +**2. Docker .env anpassen:** + +Bearbeite `docker/dev/frontend/config/.env`: +```bash +API_URL=http://localhost:5001 +CLIENT_URL=http://localhost:3000 +APP_VERSION=1.1.0 +PUBLIC_HOST=public.test.local +INTERNAL_HOST=internal.test.local +``` + +Bearbeite `docker/dev/docker-compose.yml`: +```yaml +backend-dev: + environment: + - PUBLIC_HOST=public.test.local + - INTERNAL_HOST=internal.test.local + - ENABLE_HOST_RESTRICTION=true + - TRUST_PROXY_HOPS=0 + +frontend-dev: + environment: + - HOST=0.0.0.0 + - DANGEROUSLY_DISABLE_HOST_CHECK=true +``` + +**3. Container starten:** +```bash +./dev.sh +``` + +**4. Im Browser testen:** + +**Public Host** (`http://public.test.local:3000`): +- ✅ Upload-Seite funktioniert +- ✅ UUID Management funktioniert (`/manage/:token`) +- ✅ Social Media Badges angezeigt +- ❌ Kein Admin/Groups/Slideshow-MenĂŒ +- ❌ `/moderation` → 404 + +**Internal Host** (`http://internal.test.local:3000`): +- ✅ Alle Features verfĂŒgbar +- ✅ Admin-Bereich, Groups, Slideshow erreichbar +- ✅ VollstĂ€ndiger API-Zugriff + +### API-Tests mit curl + +**Public Host - Blockierte Routen (403):** +```bash +curl -H "Host: public.test.local" http://localhost:5001/api/admin/deletion-log +curl -H "Host: public.test.local" http://localhost:5001/api/groups +curl -H "Host: public.test.local" http://localhost:5001/api/auth/login +``` + +**Public Host - Erlaubte Routen:** +```bash +curl -H "Host: public.test.local" http://localhost:5001/api/upload +curl -H "Host: public.test.local" http://localhost:5001/api/manage/YOUR-UUID +curl -H "Host: public.test.local" http://localhost:5001/api/social-media/platforms +``` + +**Internal Host - Alle Routen:** +```bash +curl -H "Host: internal.test.local" http://localhost:5001/api/groups +curl -H "Host: internal.test.local" http://localhost:5001/api/admin/deletion-log +``` + +### Frontend Code-Splitting testen + +**Public Host:** +1. Browser DevTools → Network → JS Filter +2. Öffne `http://public.test.local:3000` +3. **Erwartung:** Slideshow/Admin/Groups-Bundles werden **nicht** geladen +4. Navigiere zu `/admin` → Redirect zu 404 + +**Internal Host:** +1. Öffne `http://internal.test.local:3000` +2. Navigiere zu `/slideshow` +3. **Erwartung:** Lazy-Bundle wird erst jetzt geladen (Code Splitting) + +### Rate Limiting testen + +Public Host: 20 Uploads/Stunde + +```bash +for i in {1..25}; do + echo "Upload $i" + curl -X POST -H "Host: public.test.local" \ + http://localhost:5001/api/upload \ + -F "file=@test.jpg" -F "group=Test" +done +# Ab Upload 21: HTTP 429 (Too Many Requests) +``` + +### Troubleshooting + +**"Invalid Host header"** +- Lösung: `DANGEROUSLY_DISABLE_HOST_CHECK=true` in `.env.development` (Frontend) + +**"Alle Routen geben 403"** +- PrĂŒfe `ENABLE_HOST_RESTRICTION=true` +- PrĂŒfe `PUBLIC_HOST` / `INTERNAL_HOST` ENV-Variablen +- Container neu starten + +**"public.test.local nicht erreichbar"** +- PrĂŒfe `/etc/hosts`: `cat /etc/hosts | grep test.local` +- DNS-Cache leeren: + - **Linux:** `sudo systemd-resolve --flush-caches` + - **Mac:** `sudo dscacheutil -flushcache` + - **Windows:** `ipconfig /flushdns` + +**Feature deaktivieren (Standard Dev):** +```yaml +backend-dev: + environment: + - ENABLE_HOST_RESTRICTION=false +``` + +### Production Setup + +FĂŒr Production mit echten Subdomains siehe: +- `FeatureRequests/FEATURE_PLAN-FrontendPublic.md` (Sektion 12: Testing Strategy) +- nginx-proxy-manager Konfiguration erforderlich +- Hosts: `deinprojekt.hobbyhimmel.de` (public), `deinprojekt.lan.hobbyhimmel.de` (internal) + +--- + ## NĂŒtzliche Befehle ```bash diff --git a/README.md b/README.md index d80c4e4..6d1b560 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,17 @@ This project extends the original [Image-Uploader by vallezw](https://github.com ### 🆕 Latest Features (November 2025) +- **🌐 Public/Internal Host Separation** (Nov 25): + - Subdomain-based feature separation for production deployment + - Public host (`deinprojekt.hobbyhimmel.de`): Upload + UUID Management only + - Internal host (`deinprojekt.lan.hobbyhimmel.de`): Full admin access + - Frontend code splitting with React.lazy() for optimized bundle size + - Backend API protection via hostGate middleware + - Rate limiting: 20 uploads/hour on public host + - Audit log tracking with source host information + - Complete local testing support via /etc/hosts entries + - Zero configuration overhead for single-host deployments + - **đŸ§Ș Comprehensive Test Suite** (Nov 16): - 45 automated tests covering all API endpoints (100% passing) - Jest + Supertest integration testing framework diff --git a/backend/docs/openapi.json b/backend/docs/openapi.json index 3bb9353..0d73c1f 100644 --- a/backend/docs/openapi.json +++ b/backend/docs/openapi.json @@ -322,6 +322,9 @@ } } }, + "429": { + "description": "Too Many Requests" + }, "500": { "description": "Server error during upload" } 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..3767951 --- /dev/null +++ b/backend/src/middlewares/hostGate.js @@ -0,0 +1,114 @@ +/** + * 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'; + +// Debug: Log configuration on module load (development only) +if (process.env.NODE_ENV !== 'production' && process.env.NODE_ENV !== 'test') { + console.log('🔧 hostGate config:', { PUBLIC_HOST, INTERNAL_HOST, ENABLE_HOST_RESTRICTION }); +} + +// 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', + '/api/social-media/platforms' // Nur Plattformen lesen (fĂŒr Consent-Badges im UUID Management) +]; + +/** + * 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 explicitly allowed (z.B. /api/social-media/platforms) + const isExplicitlyAllowed = PUBLIC_ALLOWED_ROUTES.some(route => + path === route || path.startsWith(route + '/') + ); + + if (isExplicitlyAllowed) { + // Erlaubt - kein Block + req.requestSource = 'public'; + return next(); + } + + // 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..7ce1e77 --- /dev/null +++ b/backend/tests/unit/middlewares/hostGate.test.js @@ -0,0 +1,267 @@ +/** + * 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'; + +let hostGate; + +// Helper to create mock request with headers +const createMockRequest = (hostname, path = '/') => { + return { + path, + get: (headerName) => { + if (headerName.toLowerCase() === 'x-forwarded-host') { + return hostname; + } + if (headerName.toLowerCase() === 'host') { + return hostname; + } + return null; + } + }; +}; + +describe('Host Gate Middleware', () => { + let req, res, next; + let originalEnv; + + beforeAll(() => { + // Sichere Original-Env + originalEnv = { ...process.env }; + + // Lade Modul NACH ENV setup + hostGate = require('../../../src/middlewares/hostGate'); + }); + + beforeEach(() => { + // Mock response object + res = { + status: jest.fn().mockReturnThis(), + json: jest.fn() + }; + + // Mock next function + next = jest.fn(); + + // Reset req for each test + req = null; + + // 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 = createMockRequest('public.example.com'); + 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 = createMockRequest('internal.example.com'); + 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 = createMockRequest('public.example.com'); + hostGate(req, res, next); + + expect(req.isPublicHost).toBe(true); + }); + + test('should handle localhost as internal host', () => { + req = createMockRequest('localhost:3000'); + hostGate(req, res, next); + + expect(req.isInternalHost).toBe(true); + expect(req.isPublicHost).toBe(false); + }); + + test('should strip port from hostname', () => { + req = createMockRequest('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 = createMockRequest('public.example.com', '/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 = createMockRequest('public.example.com', '/api/groups'); + hostGate(req, res, next); + + expect(res.status).toHaveBeenCalledWith(403); + }); + + test('should block slideshow routes on public host', () => { + req = createMockRequest('public.example.com', '/api/slideshow'); + hostGate(req, res, next); + + expect(res.status).toHaveBeenCalledWith(403); + }); + + test('should block migration routes on public host', () => { + req = createMockRequest('public.example.com', '/api/migration/start'); + hostGate(req, res, next); + + expect(res.status).toHaveBeenCalledWith(403); + }); + + test('should block auth login on public host', () => { + req = createMockRequest('public.example.com', '/api/auth/login'); + hostGate(req, res, next); + + expect(res.status).toHaveBeenCalledWith(403); + }); + }); + + describe('Allowed Routes', () => { + test('should allow upload route on public host', () => { + req = createMockRequest('public.example.com', '/api/upload'); + hostGate(req, res, next); + + expect(next).toHaveBeenCalled(); + expect(res.status).not.toHaveBeenCalled(); + }); + + test('should allow manage routes on public host', () => { + req = createMockRequest('public.example.com', '/api/manage/abc-123'); + hostGate(req, res, next); + + expect(next).toHaveBeenCalled(); + }); + + test('should allow preview routes on public host', () => { + req = createMockRequest('public.example.com', '/api/previews/image.jpg'); + hostGate(req, res, next); + + expect(next).toHaveBeenCalled(); + }); + + test('should allow consent routes on public host', () => { + req = createMockRequest('public.example.com', '/api/consent'); + hostGate(req, res, next); + + expect(next).toHaveBeenCalled(); + }); + + test('should allow all routes on internal host', () => { + req = createMockRequest('internal.example.com', '/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'; // Explicitly disabled + const hostGateTest = require('../../../src/middlewares/hostGate'); + + req = createMockRequest('public.example.com', '/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', () => { + // Reload module with test environment BUT explicitly enabled + delete require.cache[require.resolve('../../../src/middlewares/hostGate')]; + process.env.NODE_ENV = 'test'; + process.env.ENABLE_HOST_RESTRICTION = 'true'; // Explicitly enabled + const hostGateTest = require('../../../src/middlewares/hostGate'); + + req = createMockRequest('public.example.com', '/api/admin/test'); + hostGateTest(req, res, next); + + // Should block because explicitly enabled + expect(res.status).toHaveBeenCalledWith(403); + expect(next).not.toHaveBeenCalled(); + + // Restore + delete require.cache[require.resolve('../../../src/middlewares/hostGate')]; + process.env.NODE_ENV = 'development'; + process.env.ENABLE_HOST_RESTRICTION = 'true'; + }); + }); + + describe('Request Source Tracking', () => { + test('should set requestSource to "public" for public host', () => { + req = createMockRequest('public.example.com', '/api/upload'); + hostGate(req, res, next); + + expect(req.requestSource).toBe('public'); + }); + + test('should set requestSource to "internal" for internal host', () => { + req = createMockRequest('internal.example.com', '/api/admin/test'); + hostGate(req, res, next); + + expect(req.requestSource).toBe('internal'); + }); + + test('should set requestSource to "internal" when restrictions disabled', () => { + // Reload module with disabled restriction + delete require.cache[require.resolve('../../../src/middlewares/hostGate')]; + process.env.ENABLE_HOST_RESTRICTION = 'false'; + const hostGateDisabled = require('../../../src/middlewares/hostGate'); + + req = createMockRequest('anything.example.com', '/api/test'); + hostGateDisabled(req, res, next); + + expect(req.requestSource).toBe('internal'); + + // Restore + delete require.cache[require.resolve('../../../src/middlewares/hostGate')]; + process.env.ENABLE_HOST_RESTRICTION = 'true'; + }); + }); +}); diff --git a/docker/dev/docker-compose.yml b/docker/dev/docker-compose.yml index 264d9a1..249c1ef 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=public.test.local + - INTERNAL_HOST=internal.test.local depends_on: - backend-dev networks: @@ -40,6 +42,11 @@ services: - ./backend/config/.env:/usr/src/app/.env:ro environment: - NODE_ENV=development + - PUBLIC_HOST=public.test.local + - INTERNAL_HOST=internal.test.local + - ENABLE_HOST_RESTRICTION=true + - TRUST_PROXY_HOPS=0 + - PUBLIC_UPLOAD_RATE_LIMIT=20 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.development b/frontend/.env.development new file mode 100644 index 0000000..a575c05 --- /dev/null +++ b/frontend/.env.development @@ -0,0 +1,6 @@ +# Development Environment Variables +# Allow access from custom hostnames (public.test.local, internal.test.local) +DANGEROUSLY_DISABLE_HOST_CHECK=true + +# Use 0.0.0.0 to allow external access +HOST=0.0.0.0 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/ComponentUtils/MultiUpload/UploadSuccessDialog.js b/frontend/src/Components/ComponentUtils/MultiUpload/UploadSuccessDialog.js index 5540e55..b51362b 100644 --- a/frontend/src/Components/ComponentUtils/MultiUpload/UploadSuccessDialog.js +++ b/frontend/src/Components/ComponentUtils/MultiUpload/UploadSuccessDialog.js @@ -29,12 +29,29 @@ function UploadSuccessDialog({ open, onClose, groupId, uploadCount }) { const [copied, setCopied] = useState(false); const handleCopyGroupId = () => { - navigator.clipboard.writeText(groupId).then(() => { - setCopied(true); - setTimeout(() => setCopied(false), 2000); - }).catch(err => { - console.error('Failed to copy:', err); - }); + // Fallback fĂŒr HTTP (wenn navigator.clipboard nicht verfĂŒgbar) + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(groupId).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }).catch(err => { + console.error('Failed to copy:', err); + }); + } else { + // Fallback: Erstelle temporĂ€res Input-Element + try { + const input = document.createElement('input'); + input.value = groupId; + document.body.appendChild(input); + input.select(); + document.execCommand('copy'); + document.body.removeChild(input); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error('Failed to copy:', err); + } + } }; return ( diff --git a/frontend/src/Components/Pages/404Page.js b/frontend/src/Components/Pages/404Page.js index 79d57c3..86db3d2 100644 --- a/frontend/src/Components/Pages/404Page.js +++ b/frontend/src/Components/Pages/404Page.js @@ -1,33 +1,49 @@ import React from 'react' import Navbar from '../ComponentUtils/Headers/Navbar' +import NavbarUpload from '../ComponentUtils/Headers/NavbarUpload' +import { getHostConfig } from '../../Utils/hostDetection' import './Css/404Page.css' function FZF() { + const hostConfig = getHostConfig(); + return (
- + {hostConfig.isPublic ? : } -
- - - - - - - - - - - - - - - - +
+ {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/Components/Pages/MultiUploadPage.js b/frontend/src/Components/Pages/MultiUploadPage.js index d97ba91..46c6ed4 100644 --- a/frontend/src/Components/Pages/MultiUploadPage.js +++ b/frontend/src/Components/Pages/MultiUploadPage.js @@ -335,7 +335,18 @@ function MultiUploadPage() { }} onClick={() => { const link = `${window.location.origin}/manage/${uploadResult.managementToken}`; - navigator.clipboard.writeText(link); + // Fallback fĂŒr HTTP (wenn navigator.clipboard nicht verfĂŒgbar) + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(link); + } else { + // Fallback: Erstelle temporĂ€res Input-Element + const input = document.createElement('input'); + input.value = link; + document.body.appendChild(input); + input.select(); + document.execCommand('copy'); + document.body.removeChild(input); + } }} > 📋 Kopieren 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 + } + }); + } +};