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:
Matthias Lotz 2025-11-25 22:02:53 +01:00
parent 712b8477b9
commit e4ddd229b8
11 changed files with 402 additions and 115 deletions

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -322,6 +322,9 @@
} }
} }
}, },
"429": {
"description": "Too Many Requests"
},
"500": { "500": {
"description": "Server error during upload" "description": "Server error during upload"
} }

View File

@ -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)

View File

@ -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';
}); });
}); });
}); });

View File

@ -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" ]

View 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

View File

@ -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

View File

@ -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