1171 lines
33 KiB
Markdown
1171 lines
33 KiB
Markdown
# 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 <<EOF > /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 <Navigate to="/" replace />;
|
|
}
|
|
|
|
return children;
|
|
};
|
|
|
|
// Loading Fallback
|
|
const LoadingFallback = () => (
|
|
<div style={{
|
|
display: 'flex',
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
height: '100vh'
|
|
}}>
|
|
<p>Loading...</p>
|
|
</div>
|
|
);
|
|
|
|
function App() {
|
|
const hostConfig = getHostConfig();
|
|
|
|
return (
|
|
<AdminSessionProvider>
|
|
<Router>
|
|
<Suspense fallback={<LoadingFallback />}>
|
|
<Routes>
|
|
{/* Public Routes - immer verfügbar */}
|
|
<Route path="/" element={<MultiUploadPage />} />
|
|
<Route path="/manage/:token" element={<ManagementPortalPage />} />
|
|
|
|
{/* Internal Only Routes */}
|
|
{hostConfig.isInternal && (
|
|
<>
|
|
<Route
|
|
path="/slideshow"
|
|
element={
|
|
<ProtectedRoute>
|
|
<SlideshowPage />
|
|
</ProtectedRoute>
|
|
}
|
|
/>
|
|
<Route
|
|
path="/groups/:groupId"
|
|
element={
|
|
<ProtectedRoute>
|
|
<PublicGroupImagesPage />
|
|
</ProtectedRoute>
|
|
}
|
|
/>
|
|
<Route
|
|
path="/groups"
|
|
element={
|
|
<ProtectedRoute>
|
|
<GroupsOverviewPage />
|
|
</ProtectedRoute>
|
|
}
|
|
/>
|
|
<Route
|
|
path="/moderation"
|
|
element={
|
|
<ProtectedRoute>
|
|
<ModerationGroupsPage />
|
|
</ProtectedRoute>
|
|
}
|
|
/>
|
|
<Route
|
|
path="/moderation/groups/:groupId"
|
|
element={
|
|
<ProtectedRoute>
|
|
<ModerationGroupImagesPage />
|
|
</ProtectedRoute>
|
|
}
|
|
/>
|
|
</>
|
|
)}
|
|
|
|
{/* 404 / Not Found */}
|
|
<Route path="*" element={<NotFoundPage />} />
|
|
</Routes>
|
|
</Suspense>
|
|
</Router>
|
|
</AdminSessionProvider>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div>
|
|
{/* Nur auf internal Host Navigation anzeigen */}
|
|
{hostConfig.isInternal && (
|
|
<nav>
|
|
<a href="/slideshow">Slideshow</a>
|
|
<a href="/groups">Groups</a>
|
|
<a href="/moderation">Moderation</a>
|
|
</nav>
|
|
)}
|
|
|
|
{/* Upload Form - immer sichtbar */}
|
|
<UploadForm />
|
|
|
|
{/* Optional: Hinweis für public users */}
|
|
{hostConfig.isPublic && (
|
|
<div className="public-notice">
|
|
<p>Sie nutzen den öffentlichen Upload-Bereich.</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
```
|
|
|
|
**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 (
|
|
<div className="not-found">
|
|
<h1>404 - Seite nicht gefunden</h1>
|
|
|
|
{hostConfig.isPublic ? (
|
|
<>
|
|
<p>Diese Funktion ist nicht öffentlich verfügbar.</p>
|
|
<a href="/">Zurück zum Upload</a>
|
|
</>
|
|
) : (
|
|
<>
|
|
<p>Die angeforderte Seite existiert nicht.</p>
|
|
<a href="/">Zurück zur Startseite</a>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
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)
|
|
|
|
<!-- Ende Feature Plan -->
|