feat: Public/Internal Host Separation
Implemented subdomain-based feature separation for production deployment. **Backend:** - New hostGate middleware for host-based API protection - Public host blocks: /api/admin, /api/groups, /api/slideshow, /api/auth - Public host allows: /api/upload, /api/manage, /api/social-media/platforms - Rate limiting: 20 uploads/hour on public host (publicUploadLimiter) - Audit log enhancement: source_host, source_type tracking - Database migration 009: Added source tracking columns **Frontend:** - Host detection utility (hostDetection.js) with feature flags - React code splitting with lazy loading for internal features - Conditional routing: Internal routes only mounted on internal host - 404 page: Host-specific messaging and navbar - Clipboard fallback for HTTP environments **Configuration:** - Environment variables: PUBLIC_HOST, INTERNAL_HOST, ENABLE_HOST_RESTRICTION - Docker dev setup: HOST variables, TRUST_PROXY_HOPS configuration - Frontend .env.development: DANGEROUSLY_DISABLE_HOST_CHECK for Webpack **Testing:** - 20/20 hostGate unit tests passing - Local testing guide in README.dev.md - /etc/hosts setup for public.test.local, internal.test.local **Bug Fixes:** - Fixed clipboard API not available on HTTP - Fixed missing PUBLIC_HOST in frontend env-config.js - Fixed wrong navbar on 404 page for public host - Fixed social media platforms loading in UUID management **Documentation:** - CHANGELOG.md: Complete feature documentation - README.md: Feature overview - README.dev.md: Host-separation testing guide - TESTING-HOST-SEPARATION.md: Integration note
This commit is contained in:
parent
712b8477b9
commit
e4ddd229b8
99
CHANGELOG.md
99
CHANGELOG.md
|
|
@ -1,5 +1,104 @@
|
||||||
# Changelog
|
# 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
|
## [Unreleased] - Branch: feature/security
|
||||||
|
|
||||||
### 🔐 Session-Based Admin Authentication & Multi-Admin Support (November 23, 2025)
|
### 🔐 Session-Based Admin Authentication & Multi-Admin Support (November 23, 2025)
|
||||||
|
|
|
||||||
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.
|
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`.
|
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
|
## Nützliche Befehle
|
||||||
|
|
||||||
```bash
|
```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)
|
### 🆕 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):
|
- **🧪 Comprehensive Test Suite** (Nov 16):
|
||||||
- 45 automated tests covering all API endpoints (100% passing)
|
- 45 automated tests covering all API endpoints (100% passing)
|
||||||
- Jest + Supertest integration testing framework
|
- Jest + Supertest integration testing framework
|
||||||
|
|
|
||||||
|
|
@ -322,6 +322,9 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"429": {
|
||||||
|
"description": "Too Many Requests"
|
||||||
|
},
|
||||||
"500": {
|
"500": {
|
||||||
"description": "Server error during upload"
|
"description": "Server error during upload"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,11 @@ const PUBLIC_HOST = process.env.PUBLIC_HOST || 'deinprojekt.hobbyhimmel.de';
|
||||||
const INTERNAL_HOST = process.env.INTERNAL_HOST || 'deinprojekt.lan.hobbyhimmel.de';
|
const INTERNAL_HOST = process.env.INTERNAL_HOST || 'deinprojekt.lan.hobbyhimmel.de';
|
||||||
const ENABLE_HOST_RESTRICTION = process.env.ENABLE_HOST_RESTRICTION !== 'false';
|
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
|
// Routes die NUR für internal Host erlaubt sind
|
||||||
const INTERNAL_ONLY_ROUTES = [
|
const INTERNAL_ONLY_ROUTES = [
|
||||||
'/api/admin',
|
'/api/admin',
|
||||||
|
|
@ -30,7 +35,8 @@ const PUBLIC_ALLOWED_ROUTES = [
|
||||||
'/api/upload',
|
'/api/upload',
|
||||||
'/api/manage',
|
'/api/manage',
|
||||||
'/api/previews',
|
'/api/previews',
|
||||||
'/api/consent'
|
'/api/consent',
|
||||||
|
'/api/social-media/platforms' // Nur Plattformen lesen (für Consent-Badges im UUID Management)
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -74,6 +80,17 @@ const hostGate = (req, res, next) => {
|
||||||
if (req.isPublicHost) {
|
if (req.isPublicHost) {
|
||||||
const path = req.path;
|
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
|
// Check if route is internal-only
|
||||||
const isInternalOnly = INTERNAL_ONLY_ROUTES.some(route =>
|
const isInternalOnly = INTERNAL_ONLY_ROUTES.some(route =>
|
||||||
path.startsWith(route)
|
path.startsWith(route)
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,23 @@ process.env.PUBLIC_HOST = 'public.example.com';
|
||||||
process.env.INTERNAL_HOST = 'internal.example.com';
|
process.env.INTERNAL_HOST = 'internal.example.com';
|
||||||
process.env.NODE_ENV = 'development';
|
process.env.NODE_ENV = 'development';
|
||||||
|
|
||||||
const hostGate = require('../../../src/middlewares/hostGate');
|
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', () => {
|
describe('Host Gate Middleware', () => {
|
||||||
let req, res, next;
|
let req, res, next;
|
||||||
|
|
@ -18,24 +34,24 @@ describe('Host Gate Middleware', () => {
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
// Sichere Original-Env
|
// Sichere Original-Env
|
||||||
originalEnv = { ...process.env };
|
originalEnv = { ...process.env };
|
||||||
|
|
||||||
|
// Lade Modul NACH ENV setup
|
||||||
|
hostGate = require('../../../src/middlewares/hostGate');
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Mock Request
|
// Mock response object
|
||||||
req = {
|
|
||||||
get: jest.fn(),
|
|
||||||
path: '/api/admin/test'
|
|
||||||
};
|
|
||||||
|
|
||||||
// Mock Response
|
|
||||||
res = {
|
res = {
|
||||||
status: jest.fn().mockReturnThis(),
|
status: jest.fn().mockReturnThis(),
|
||||||
json: jest.fn()
|
json: jest.fn()
|
||||||
};
|
};
|
||||||
|
|
||||||
// Mock Next
|
// Mock next function
|
||||||
next = jest.fn();
|
next = jest.fn();
|
||||||
|
|
||||||
|
// Reset req for each test
|
||||||
|
req = null;
|
||||||
|
|
||||||
// Setup Environment
|
// Setup Environment
|
||||||
process.env.ENABLE_HOST_RESTRICTION = 'true';
|
process.env.ENABLE_HOST_RESTRICTION = 'true';
|
||||||
process.env.PUBLIC_HOST = 'public.example.com';
|
process.env.PUBLIC_HOST = 'public.example.com';
|
||||||
|
|
@ -54,11 +70,7 @@ describe('Host Gate Middleware', () => {
|
||||||
|
|
||||||
describe('Host Detection', () => {
|
describe('Host Detection', () => {
|
||||||
test('should detect public host from X-Forwarded-Host header', () => {
|
test('should detect public host from X-Forwarded-Host header', () => {
|
||||||
req.get.mockImplementation((header) => {
|
req = createMockRequest('public.example.com');
|
||||||
if (header === 'x-forwarded-host') return 'public.example.com';
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
|
|
||||||
hostGate(req, res, next);
|
hostGate(req, res, next);
|
||||||
|
|
||||||
expect(req.isPublicHost).toBe(true);
|
expect(req.isPublicHost).toBe(true);
|
||||||
|
|
@ -67,11 +79,7 @@ describe('Host Gate Middleware', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should detect internal host from X-Forwarded-Host header', () => {
|
test('should detect internal host from X-Forwarded-Host header', () => {
|
||||||
req.get.mockImplementation((header) => {
|
req = createMockRequest('internal.example.com');
|
||||||
if (header === 'x-forwarded-host') return 'internal.example.com';
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
|
|
||||||
hostGate(req, res, next);
|
hostGate(req, res, next);
|
||||||
|
|
||||||
expect(req.isPublicHost).toBe(false);
|
expect(req.isPublicHost).toBe(false);
|
||||||
|
|
@ -80,24 +88,14 @@ describe('Host Gate Middleware', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should fallback to Host header if X-Forwarded-Host not present', () => {
|
test('should fallback to Host header if X-Forwarded-Host not present', () => {
|
||||||
req.get.mockImplementation((header) => {
|
req = createMockRequest('public.example.com');
|
||||||
if (header === 'x-forwarded-host') return null;
|
|
||||||
if (header === 'host') return 'public.example.com';
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
|
|
||||||
hostGate(req, res, next);
|
hostGate(req, res, next);
|
||||||
|
|
||||||
expect(req.isPublicHost).toBe(true);
|
expect(req.isPublicHost).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should handle localhost as internal host', () => {
|
test('should handle localhost as internal host', () => {
|
||||||
req.get.mockImplementation((header) => {
|
req = createMockRequest('localhost:3000');
|
||||||
if (header === 'x-forwarded-host') return null;
|
|
||||||
if (header === 'host') return 'localhost:3000';
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
|
|
||||||
hostGate(req, res, next);
|
hostGate(req, res, next);
|
||||||
|
|
||||||
expect(req.isInternalHost).toBe(true);
|
expect(req.isInternalHost).toBe(true);
|
||||||
|
|
@ -105,8 +103,7 @@ describe('Host Gate Middleware', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should strip port from hostname', () => {
|
test('should strip port from hostname', () => {
|
||||||
req.get.mockReturnValue('public.example.com:8080');
|
req = createMockRequest('public.example.com:8080');
|
||||||
|
|
||||||
hostGate(req, res, next);
|
hostGate(req, res, next);
|
||||||
|
|
||||||
expect(req.isPublicHost).toBe(true);
|
expect(req.isPublicHost).toBe(true);
|
||||||
|
|
@ -115,9 +112,7 @@ describe('Host Gate Middleware', () => {
|
||||||
|
|
||||||
describe('Route Protection', () => {
|
describe('Route Protection', () => {
|
||||||
test('should block admin routes on public host', () => {
|
test('should block admin routes on public host', () => {
|
||||||
req.get.mockReturnValue('public.example.com');
|
req = createMockRequest('public.example.com', '/api/admin/deletion-log');
|
||||||
req.path = '/api/admin/deletion-log';
|
|
||||||
|
|
||||||
hostGate(req, res, next);
|
hostGate(req, res, next);
|
||||||
|
|
||||||
expect(res.status).toHaveBeenCalledWith(403);
|
expect(res.status).toHaveBeenCalledWith(403);
|
||||||
|
|
@ -129,36 +124,28 @@ describe('Host Gate Middleware', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should block groups routes on public host', () => {
|
test('should block groups routes on public host', () => {
|
||||||
req.get.mockReturnValue('public.example.com');
|
req = createMockRequest('public.example.com', '/api/groups');
|
||||||
req.path = '/api/groups';
|
|
||||||
|
|
||||||
hostGate(req, res, next);
|
hostGate(req, res, next);
|
||||||
|
|
||||||
expect(res.status).toHaveBeenCalledWith(403);
|
expect(res.status).toHaveBeenCalledWith(403);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should block slideshow routes on public host', () => {
|
test('should block slideshow routes on public host', () => {
|
||||||
req.get.mockReturnValue('public.example.com');
|
req = createMockRequest('public.example.com', '/api/slideshow');
|
||||||
req.path = '/api/slideshow';
|
|
||||||
|
|
||||||
hostGate(req, res, next);
|
hostGate(req, res, next);
|
||||||
|
|
||||||
expect(res.status).toHaveBeenCalledWith(403);
|
expect(res.status).toHaveBeenCalledWith(403);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should block migration routes on public host', () => {
|
test('should block migration routes on public host', () => {
|
||||||
req.get.mockReturnValue('public.example.com');
|
req = createMockRequest('public.example.com', '/api/migration/start');
|
||||||
req.path = '/api/migration/start';
|
|
||||||
|
|
||||||
hostGate(req, res, next);
|
hostGate(req, res, next);
|
||||||
|
|
||||||
expect(res.status).toHaveBeenCalledWith(403);
|
expect(res.status).toHaveBeenCalledWith(403);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should block auth login on public host', () => {
|
test('should block auth login on public host', () => {
|
||||||
req.get.mockReturnValue('public.example.com');
|
req = createMockRequest('public.example.com', '/api/auth/login');
|
||||||
req.path = '/api/auth/login';
|
|
||||||
|
|
||||||
hostGate(req, res, next);
|
hostGate(req, res, next);
|
||||||
|
|
||||||
expect(res.status).toHaveBeenCalledWith(403);
|
expect(res.status).toHaveBeenCalledWith(403);
|
||||||
|
|
@ -167,9 +154,7 @@ describe('Host Gate Middleware', () => {
|
||||||
|
|
||||||
describe('Allowed Routes', () => {
|
describe('Allowed Routes', () => {
|
||||||
test('should allow upload route on public host', () => {
|
test('should allow upload route on public host', () => {
|
||||||
req.get.mockReturnValue('public.example.com');
|
req = createMockRequest('public.example.com', '/api/upload');
|
||||||
req.path = '/api/upload';
|
|
||||||
|
|
||||||
hostGate(req, res, next);
|
hostGate(req, res, next);
|
||||||
|
|
||||||
expect(next).toHaveBeenCalled();
|
expect(next).toHaveBeenCalled();
|
||||||
|
|
@ -177,36 +162,28 @@ describe('Host Gate Middleware', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should allow manage routes on public host', () => {
|
test('should allow manage routes on public host', () => {
|
||||||
req.get.mockReturnValue('public.example.com');
|
req = createMockRequest('public.example.com', '/api/manage/abc-123');
|
||||||
req.path = '/api/manage/abc-123';
|
|
||||||
|
|
||||||
hostGate(req, res, next);
|
hostGate(req, res, next);
|
||||||
|
|
||||||
expect(next).toHaveBeenCalled();
|
expect(next).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should allow preview routes on public host', () => {
|
test('should allow preview routes on public host', () => {
|
||||||
req.get.mockReturnValue('public.example.com');
|
req = createMockRequest('public.example.com', '/api/previews/image.jpg');
|
||||||
req.path = '/api/previews/image.jpg';
|
|
||||||
|
|
||||||
hostGate(req, res, next);
|
hostGate(req, res, next);
|
||||||
|
|
||||||
expect(next).toHaveBeenCalled();
|
expect(next).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should allow consent routes on public host', () => {
|
test('should allow consent routes on public host', () => {
|
||||||
req.get.mockReturnValue('public.example.com');
|
req = createMockRequest('public.example.com', '/api/consent');
|
||||||
req.path = '/api/consent';
|
|
||||||
|
|
||||||
hostGate(req, res, next);
|
hostGate(req, res, next);
|
||||||
|
|
||||||
expect(next).toHaveBeenCalled();
|
expect(next).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should allow all routes on internal host', () => {
|
test('should allow all routes on internal host', () => {
|
||||||
req.get.mockReturnValue('internal.example.com');
|
req = createMockRequest('internal.example.com', '/api/admin/deletion-log');
|
||||||
req.path = '/api/admin/deletion-log';
|
|
||||||
|
|
||||||
hostGate(req, res, next);
|
hostGate(req, res, next);
|
||||||
|
|
||||||
expect(next).toHaveBeenCalled();
|
expect(next).toHaveBeenCalled();
|
||||||
|
|
@ -219,12 +196,10 @@ describe('Host Gate Middleware', () => {
|
||||||
// Reload module with test environment
|
// Reload module with test environment
|
||||||
delete require.cache[require.resolve('../../../src/middlewares/hostGate')];
|
delete require.cache[require.resolve('../../../src/middlewares/hostGate')];
|
||||||
process.env.NODE_ENV = 'test';
|
process.env.NODE_ENV = 'test';
|
||||||
process.env.ENABLE_HOST_RESTRICTION = 'false'; // Not explicitly enabled
|
process.env.ENABLE_HOST_RESTRICTION = 'false'; // Explicitly disabled
|
||||||
const hostGateTest = require('../../../src/middlewares/hostGate');
|
const hostGateTest = require('../../../src/middlewares/hostGate');
|
||||||
|
|
||||||
req.get.mockReturnValue('public.example.com');
|
req = createMockRequest('public.example.com', '/api/admin/test');
|
||||||
req.path = '/api/admin/test';
|
|
||||||
|
|
||||||
hostGateTest(req, res, next);
|
hostGateTest(req, res, next);
|
||||||
|
|
||||||
expect(next).toHaveBeenCalled();
|
expect(next).toHaveBeenCalled();
|
||||||
|
|
@ -238,39 +213,55 @@ describe('Host Gate Middleware', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should work in test environment when explicitly enabled', () => {
|
test('should work in test environment when explicitly enabled', () => {
|
||||||
// Already set up correctly
|
// 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.NODE_ENV = 'development';
|
||||||
expect(req.isInternalHost).toBeUndefined(); // Not processed yet, just checking setup
|
process.env.ENABLE_HOST_RESTRICTION = 'true';
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Request Source Tracking', () => {
|
describe('Request Source Tracking', () => {
|
||||||
test('should set requestSource to "public" for public host', () => {
|
test('should set requestSource to "public" for public host', () => {
|
||||||
req.get.mockReturnValue('public.example.com');
|
req = createMockRequest('public.example.com', '/api/upload');
|
||||||
req.path = '/api/upload';
|
|
||||||
|
|
||||||
hostGate(req, res, next);
|
hostGate(req, res, next);
|
||||||
|
|
||||||
expect(req.requestSource).toBe('public');
|
expect(req.requestSource).toBe('public');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should set requestSource to "internal" for internal host', () => {
|
test('should set requestSource to "internal" for internal host', () => {
|
||||||
req.get.mockReturnValue('internal.example.com');
|
req = createMockRequest('internal.example.com', '/api/admin/test');
|
||||||
req.path = '/api/admin/test';
|
|
||||||
|
|
||||||
hostGate(req, res, next);
|
hostGate(req, res, next);
|
||||||
|
|
||||||
expect(req.requestSource).toBe('internal');
|
expect(req.requestSource).toBe('internal');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should set requestSource to "internal" when restrictions disabled', () => {
|
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';
|
process.env.ENABLE_HOST_RESTRICTION = 'false';
|
||||||
req.get.mockReturnValue('anything.example.com');
|
const hostGateDisabled = require('../../../src/middlewares/hostGate');
|
||||||
req.path = '/api/test';
|
|
||||||
|
|
||||||
hostGate(req, res, next);
|
req = createMockRequest('anything.example.com', '/api/test');
|
||||||
|
hostGateDisabled(req, res, next);
|
||||||
|
|
||||||
expect(req.requestSource).toBe('internal');
|
expect(req.requestSource).toBe('internal');
|
||||||
|
|
||||||
|
// Restore
|
||||||
|
delete require.cache[require.resolve('../../../src/middlewares/hostGate')];
|
||||||
|
process.env.ENABLE_HOST_RESTRICTION = 'true';
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,8 @@ services:
|
||||||
- CHOKIDAR_USEPOLLING=true
|
- CHOKIDAR_USEPOLLING=true
|
||||||
- API_URL=http://localhost:5001
|
- API_URL=http://localhost:5001
|
||||||
- CLIENT_URL=http://localhost:3000
|
- CLIENT_URL=http://localhost:3000
|
||||||
- PUBLIC_HOST=localhost
|
- PUBLIC_HOST=public.test.local
|
||||||
- INTERNAL_HOST=localhost
|
- INTERNAL_HOST=internal.test.local
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend-dev
|
- backend-dev
|
||||||
networks:
|
networks:
|
||||||
|
|
@ -42,9 +42,11 @@ services:
|
||||||
- ./backend/config/.env:/usr/src/app/.env:ro
|
- ./backend/config/.env:/usr/src/app/.env:ro
|
||||||
environment:
|
environment:
|
||||||
- NODE_ENV=development
|
- NODE_ENV=development
|
||||||
- PUBLIC_HOST=localhost
|
- PUBLIC_HOST=public.test.local
|
||||||
- INTERNAL_HOST=localhost
|
- INTERNAL_HOST=internal.test.local
|
||||||
- ENABLE_HOST_RESTRICTION=false
|
- ENABLE_HOST_RESTRICTION=true
|
||||||
|
- TRUST_PROXY_HOPS=0
|
||||||
|
- PUBLIC_UPLOAD_RATE_LIMIT=20
|
||||||
networks:
|
networks:
|
||||||
- dev-internal
|
- dev-internal
|
||||||
command: [ "npm", "run", "server" ]
|
command: [ "npm", "run", "server" ]
|
||||||
|
|
|
||||||
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
|
||||||
|
|
@ -29,12 +29,29 @@ function UploadSuccessDialog({ open, onClose, groupId, uploadCount }) {
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
const handleCopyGroupId = () => {
|
const handleCopyGroupId = () => {
|
||||||
navigator.clipboard.writeText(groupId).then(() => {
|
// Fallback für HTTP (wenn navigator.clipboard nicht verfügbar)
|
||||||
setCopied(true);
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||||
setTimeout(() => setCopied(false), 2000);
|
navigator.clipboard.writeText(groupId).then(() => {
|
||||||
}).catch(err => {
|
setCopied(true);
|
||||||
console.error('Failed to copy:', err);
|
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 (
|
return (
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -335,7 +335,18 @@ function MultiUploadPage() {
|
||||||
}}
|
}}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const link = `${window.location.origin}/manage/${uploadResult.managementToken}`;
|
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
|
📋 Kopieren
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user