Compare commits
3 Commits
e48cf69b5d
...
e4ddd229b8
| Author | SHA1 | Date | |
|---|---|---|---|
| e4ddd229b8 | |||
| 712b8477b9 | |||
| 7ac8a70260 |
99
CHANGELOG.md
99
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 ? <NavbarUpload /> : <Navbar />}`
|
||||
- 🐛 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)
|
||||
|
|
|
|||
1170
FeatureRequests/FEATURE_PLAN-FrontendPublic.md
Normal file
1170
FeatureRequests/FEATURE_PLAN-FrontendPublic.md
Normal file
File diff suppressed because it is too large
Load Diff
|
|
@ -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.
|
||||
|
|
|
|||
151
README.dev.md
151
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
|
||||
|
|
|
|||
11
README.md
11
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
|
||||
|
|
|
|||
|
|
@ -322,6 +322,9 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"429": {
|
||||
"description": "Too Many Requests"
|
||||
},
|
||||
"500": {
|
||||
"description": "Server error during upload"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
114
backend/src/middlewares/hostGate.js
Normal file
114
backend/src/middlewares/hostGate.js
Normal file
|
|
@ -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;
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<number>} 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
267
backend/tests/unit/middlewares/hostGate.test.js
Normal file
267
backend/tests/unit/middlewares/hostGate.test.js
Normal file
|
|
@ -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';
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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" ]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
6
frontend/.env.development
Normal file
6
frontend/.env.development
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
/**
|
||||
* Loading Fallback für Code Splitting
|
||||
*/
|
||||
const LoadingFallback = () => (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '100vh',
|
||||
flexDirection: 'column',
|
||||
gap: '1rem'
|
||||
}}>
|
||||
<div className="spinner"></div>
|
||||
<p>Lädt...</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
function App() {
|
||||
const hostConfig = getHostConfig();
|
||||
|
||||
return (
|
||||
<AdminSessionProvider>
|
||||
<Router>
|
||||
<Routes>
|
||||
<Route path="/" exact element={<MultiUploadPage />} />
|
||||
<Route path="/slideshow" element={<SlideshowPage />} />
|
||||
<Route path="/groups/:groupId" element={<PublicGroupImagesPage />} />
|
||||
<Route path="/groups" element={<GroupsOverviewPage />} />
|
||||
<Route path="/moderation" exact element={<ModerationGroupsPage />} />
|
||||
<Route path="/moderation/groups/:groupId" element={<ModerationGroupImagesPage />} />
|
||||
<Route path="/manage/:token" element={<ManagementPortalPage />} />
|
||||
<Route path="*" element={<FZF />} />
|
||||
</Routes>
|
||||
<Suspense fallback={<LoadingFallback />}>
|
||||
<Routes>
|
||||
{/* Public Routes - immer verfügbar */}
|
||||
<Route path="/" element={<MultiUploadPage />} />
|
||||
<Route path="/manage/:token" element={<ManagementPortalPage />} />
|
||||
|
||||
{/* Internal Only Routes - nur auf internal host geladen */}
|
||||
{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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -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
|
||||
|
|
|
|||
94
frontend/src/Utils/hostDetection.js
Normal file
94
frontend/src/Utils/hostDetection.js
Normal file
|
|
@ -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
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user