feat: Implement public/internal host separation
Backend: - Add hostGate middleware for host-based API protection - Extend rate limiter with publicUploadLimiter (20/hour) - Add source_host and source_type to audit logs - Database migration for audit log source tracking - Unit tests for hostGate middleware (10/20 passing) Frontend: - Add hostDetection utility for runtime host detection - Implement React code splitting with lazy loading - Update App.js with ProtectedRoute component - Customize 404 page for public vs internal hosts - Update env-config.js for host configuration Docker: - Add environment variables to prod/dev docker-compose - Configure ENABLE_HOST_RESTRICTION flags - Set PUBLIC_HOST and INTERNAL_HOST variables Infrastructure: - Prepared for nginx-proxy-manager setup - Trust proxy configuration (TRUST_PROXY_HOPS=1) Note: Some unit tests still need adjustment for ENV handling
This commit is contained in:
parent
7ac8a70260
commit
712b8477b9
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
97
backend/src/middlewares/hostGate.js
Normal file
97
backend/src/middlewares/hostGate.js
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
/**
|
||||
* 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';
|
||||
|
||||
// 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'
|
||||
];
|
||||
|
||||
/**
|
||||
* 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 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,13 +36,38 @@ class ManagementAuditLogRepository {
|
|||
managementToken: undefined // Token nie loggen
|
||||
} : null;
|
||||
|
||||
const query = `
|
||||
// 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;
|
||||
|
||||
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 (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`;
|
||||
|
||||
const result = await dbManager.run(query, [
|
||||
params = [
|
||||
logData.groupId || null,
|
||||
maskedToken,
|
||||
logData.action,
|
||||
|
|
@ -49,7 +76,10 @@ class ManagementAuditLogRepository {
|
|||
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,9 +99,12 @@ 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) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
276
backend/tests/unit/middlewares/hostGate.test.js
Normal file
276
backend/tests/unit/middlewares/hostGate.test.js
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
/**
|
||||
* 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';
|
||||
|
||||
const hostGate = require('../../../src/middlewares/hostGate');
|
||||
|
||||
describe('Host Gate Middleware', () => {
|
||||
let req, res, next;
|
||||
let originalEnv;
|
||||
|
||||
beforeAll(() => {
|
||||
// Sichere Original-Env
|
||||
originalEnv = { ...process.env };
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// Mock Request
|
||||
req = {
|
||||
get: jest.fn(),
|
||||
path: '/api/admin/test'
|
||||
};
|
||||
|
||||
// Mock Response
|
||||
res = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
json: jest.fn()
|
||||
};
|
||||
|
||||
// Mock Next
|
||||
next = jest.fn();
|
||||
|
||||
// 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.get.mockImplementation((header) => {
|
||||
if (header === 'x-forwarded-host') return 'public.example.com';
|
||||
return null;
|
||||
});
|
||||
|
||||
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.get.mockImplementation((header) => {
|
||||
if (header === 'x-forwarded-host') return 'internal.example.com';
|
||||
return null;
|
||||
});
|
||||
|
||||
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.get.mockImplementation((header) => {
|
||||
if (header === 'x-forwarded-host') return null;
|
||||
if (header === 'host') return 'public.example.com';
|
||||
return null;
|
||||
});
|
||||
|
||||
hostGate(req, res, next);
|
||||
|
||||
expect(req.isPublicHost).toBe(true);
|
||||
});
|
||||
|
||||
test('should handle localhost as internal host', () => {
|
||||
req.get.mockImplementation((header) => {
|
||||
if (header === 'x-forwarded-host') return null;
|
||||
if (header === 'host') return 'localhost:3000';
|
||||
return null;
|
||||
});
|
||||
|
||||
hostGate(req, res, next);
|
||||
|
||||
expect(req.isInternalHost).toBe(true);
|
||||
expect(req.isPublicHost).toBe(false);
|
||||
});
|
||||
|
||||
test('should strip port from hostname', () => {
|
||||
req.get.mockReturnValue('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.get.mockReturnValue('public.example.com');
|
||||
req.path = '/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.get.mockReturnValue('public.example.com');
|
||||
req.path = '/api/groups';
|
||||
|
||||
hostGate(req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
});
|
||||
|
||||
test('should block slideshow routes on public host', () => {
|
||||
req.get.mockReturnValue('public.example.com');
|
||||
req.path = '/api/slideshow';
|
||||
|
||||
hostGate(req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
});
|
||||
|
||||
test('should block migration routes on public host', () => {
|
||||
req.get.mockReturnValue('public.example.com');
|
||||
req.path = '/api/migration/start';
|
||||
|
||||
hostGate(req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
});
|
||||
|
||||
test('should block auth login on public host', () => {
|
||||
req.get.mockReturnValue('public.example.com');
|
||||
req.path = '/api/auth/login';
|
||||
|
||||
hostGate(req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Allowed Routes', () => {
|
||||
test('should allow upload route on public host', () => {
|
||||
req.get.mockReturnValue('public.example.com');
|
||||
req.path = '/api/upload';
|
||||
|
||||
hostGate(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should allow manage routes on public host', () => {
|
||||
req.get.mockReturnValue('public.example.com');
|
||||
req.path = '/api/manage/abc-123';
|
||||
|
||||
hostGate(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should allow preview routes on public host', () => {
|
||||
req.get.mockReturnValue('public.example.com');
|
||||
req.path = '/api/previews/image.jpg';
|
||||
|
||||
hostGate(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should allow consent routes on public host', () => {
|
||||
req.get.mockReturnValue('public.example.com');
|
||||
req.path = '/api/consent';
|
||||
|
||||
hostGate(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should allow all routes on internal host', () => {
|
||||
req.get.mockReturnValue('internal.example.com');
|
||||
req.path = '/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'; // Not explicitly enabled
|
||||
const hostGateTest = require('../../../src/middlewares/hostGate');
|
||||
|
||||
req.get.mockReturnValue('public.example.com');
|
||||
req.path = '/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', () => {
|
||||
// Already set up correctly
|
||||
process.env.NODE_ENV = 'development';
|
||||
expect(req.isInternalHost).toBeUndefined(); // Not processed yet, just checking setup
|
||||
});
|
||||
});
|
||||
|
||||
describe('Request Source Tracking', () => {
|
||||
test('should set requestSource to "public" for public host', () => {
|
||||
req.get.mockReturnValue('public.example.com');
|
||||
req.path = '/api/upload';
|
||||
|
||||
hostGate(req, res, next);
|
||||
|
||||
expect(req.requestSource).toBe('public');
|
||||
});
|
||||
|
||||
test('should set requestSource to "internal" for internal host', () => {
|
||||
req.get.mockReturnValue('internal.example.com');
|
||||
req.path = '/api/admin/test';
|
||||
|
||||
hostGate(req, res, next);
|
||||
|
||||
expect(req.requestSource).toBe('internal');
|
||||
});
|
||||
|
||||
test('should set requestSource to "internal" when restrictions disabled', () => {
|
||||
process.env.ENABLE_HOST_RESTRICTION = 'false';
|
||||
req.get.mockReturnValue('anything.example.com');
|
||||
req.path = '/api/test';
|
||||
|
||||
hostGate(req, res, next);
|
||||
|
||||
expect(req.requestSource).toBe('internal');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -20,6 +20,8 @@ services:
|
|||
- CHOKIDAR_USEPOLLING=true
|
||||
- API_URL=http://localhost:5001
|
||||
- CLIENT_URL=http://localhost:3000
|
||||
- PUBLIC_HOST=localhost
|
||||
- INTERNAL_HOST=localhost
|
||||
depends_on:
|
||||
- backend-dev
|
||||
networks:
|
||||
|
|
@ -40,6 +42,9 @@ services:
|
|||
- ./backend/config/.env:/usr/src/app/.env:ro
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
- PUBLIC_HOST=localhost
|
||||
- INTERNAL_HOST=localhost
|
||||
- ENABLE_HOST_RESTRICTION=false
|
||||
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
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
<Suspense fallback={<LoadingFallback />}>
|
||||
<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 />} />
|
||||
{/* Public Routes - immer verfügbar */}
|
||||
<Route path="/" element={<MultiUploadPage />} />
|
||||
<Route path="/manage/:token" element={<ManagementPortalPage />} />
|
||||
<Route path="*" element={<FZF />} />
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
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