From da9d08c149ffd65be840bae7fa52d530afe8469a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Reinhard=20X=2E=20F=C3=BCrst?= Date: Thu, 25 Sep 2025 14:08:45 +0000 Subject: [PATCH] Noch ein paar Hardening-Sachen --- .env.example | 10 ++++++++ README.md | 33 ++++++++++++++++----------- backend/src/app.ts | 47 ++++++++++++++++++++++++++------------ docker-compose.yml | 57 +++++++++++++++------------------------------- 4 files changed, 80 insertions(+), 67 deletions(-) diff --git a/.env.example b/.env.example index 604a323..f3a26f7 100644 --- a/.env.example +++ b/.env.example @@ -7,10 +7,18 @@ ACME_EMAIL=admin@example.com # --- Database --- MYSQL_PASSWORD=change_this_password MYSQL_ROOT_PASSWORD=change_this_root_password +MYSQL_DATABASE=rezepte +MYSQL_USER=rezepte_user +MYSQL_PORT=3307 # --- Auth / Security --- JWT_SECRET=please_change_to_secure_32_char_min CORS_ORIGIN=https://rezepte.${DOMAIN} +ALLOW_INSECURE_CORS=0 +BACKEND_PORT=3001 +FRONTEND_PORT=3000 +MAX_FILE_SIZE=5242880 +UPLOAD_PATH=/app/uploads # --- Images from Registry (override if pushing to GHCR or custom registry) --- BACKEND_IMAGE=docker.citysensor.de/rezepte-backend:latest @@ -18,6 +26,8 @@ FRONTEND_IMAGE=docker.citysensor.de/rezepte-frontend:latest # --- Frontend API URL (if building locally outside compose) --- PRODUCTION_API_URL=https://rezepte.${DOMAIN}/api +DATABASE_URL=mysql://${MYSQL_USER}:${MYSQL_PASSWORD}@mysql:3306/${MYSQL_DATABASE} +VITE_API_URL=https://rezepte.${DOMAIN}/api # --- Optional HOST_IP for local network override builds --- HOST_IP=192.168.1.100 diff --git a/README.md b/README.md index 2cbb3e0..3b1800b 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,6 @@ Dieses Repository enthält zwei Welten: den modernen Node.js / React Stack sowie | mysql | default | 3307 -> 3306 | MySQL 8 Datenbank | | backend | default | 3001 -> 3001 | Node.js API (Express + Prisma) | | frontend | default | 3000 -> 80 | React Build via nginx | -| php-app | legacy | 8082 -> 80 | Altes PHP Frontend | | phpmyadmin | admin | 8083 -> 80 | DB Verwaltung | ## Node / Modern Stack @@ -30,23 +29,12 @@ docker compose up -d ``` Öffnen: http://localhost:3000 -### Mit Legacy PHP zusätzlich -```bash -docker compose --profile legacy up -d -``` -Öffnen: http://localhost:8082 - -### Mit phpMyAdmin zusätzlich +### Mit phpMyAdmin ```bash docker compose --profile admin up -d ``` Öffnen: http://localhost:8083 -### Beide Zusatz-Profile -```bash -docker compose --profile legacy --profile admin up -d -``` - ### Alles stoppen ```bash docker compose down @@ -79,6 +67,25 @@ Danach Backend neu bauen. ## Datenbankzugang +## Environment (.env) +Beispiel siehe `.env.example`. Wichtigste Variablen: +``` +DOMAIN=example.com +MYSQL_DATABASE=rezepte +MYSQL_USER=rezepte_user +MYSQL_PASSWORD=change_this_password +MYSQL_ROOT_PASSWORD=change_this_root_password +BACKEND_PORT=3001 +FRONTEND_PORT=3000 +DATABASE_URL=mysql://${MYSQL_USER}:${MYSQL_PASSWORD}@mysql:3306/${MYSQL_DATABASE} +JWT_SECRET=please_change_to_secure_32_char_min +CORS_ORIGIN=http://localhost:3000 +VITE_API_URL=http://localhost:3001/api +UPLOAD_PATH=/app/uploads +MAX_FILE_SIZE=5242880 +``` +Nicht gesetzte Variablen fallen auf sichere Defaults (oder Dummy Werte) zurück; vor Produktion immer mit echten Secrets überschreiben. + ## Datenbankzugang ### Für die Anwendung: diff --git a/backend/src/app.ts b/backend/src/app.ts index c25054b..c78c8ab 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -30,28 +30,44 @@ const limiter = rateLimit({ }); app.use(limiter); -// CORS configuration - support comma separated origins in CORS_ORIGIN -// NOTE: In docker-compose we temporarily set CORS_ORIGIN="*" for troubleshooting. -// Narrow this down for production: e.g. CORS_ORIGIN="http://esprimo:3000,http://localhost:3000". +// CORS Hardening +// Supports comma separated origins. Wildcard '*' only allowed if ALLOW_INSECURE_CORS=1 and not in production. +const insecureOverride = process.env.ALLOW_INSECURE_CORS === '1'; +const isProd = process.env.NODE_ENV === 'production'; + let allowedOrigins: string[] = []; -if (config.cors.origin === '*') { - allowedOrigins = ['*']; -} else if (config.cors.origin.includes(',')) { +if (config.cors.origin.includes(',')) { allowedOrigins = config.cors.origin.split(',').map(o => o.trim()).filter(Boolean); +} else if (config.cors.origin === '*' && (!isProd || insecureOverride)) { + allowedOrigins = ['*']; } else { allowedOrigins = [config.cors.origin]; } -// Always add defaults if not already present -['http://localhost:5173','http://localhost:3000'].forEach(def => { - if (!allowedOrigins.includes(def) && !allowedOrigins.includes('*')) allowedOrigins.push(def); -}); +// De-dupe & normalize trailing slashes +allowedOrigins = Array.from(new Set(allowedOrigins.map(o => o.replace(/\/$/, '')))); + +// Auto-add common localhost dev origins if not prod and not wildcard +if (!isProd && !allowedOrigins.includes('*')) { + ['http://localhost:5173','http://localhost:3000'].forEach(def => { + if (!allowedOrigins.includes(def)) allowedOrigins.push(def); + }); +} + +// If in production and wildcard attempted without override, remove it +if (isProd && allowedOrigins.includes('*') && !insecureOverride) { + console.warn('[CORS] Wildcard removed in production. Set CORS_ORIGIN explicitly or ALLOW_INSECURE_CORS=1 (NOT RECOMMENDED).'); + allowedOrigins = allowedOrigins.filter(o => o !== '*'); +} app.use(cors({ origin: (origin, callback) => { - if (!origin) return callback(null, true); // non-browser (curl, server-side) - if (allowedOrigins.includes('*') || allowedOrigins.includes(origin)) return callback(null, true); - return callback(new Error(`CORS blocked for origin ${origin}`)); + if (!origin) return callback(null, true); // Non-browser / same-origin + if (allowedOrigins.includes('*') || allowedOrigins.includes(origin.replace(/\/$/, ''))) { + return callback(null, true); + } + console.warn(`[CORS] Blocked origin: ${origin}`); + return callback(new Error('CORS not allowed for this origin')); }, credentials: true, })); @@ -60,10 +76,11 @@ app.use(cors({ app.use((req, res, next) => { const origin = req.headers.origin; + const normalized = origin?.replace(/\/$/, ''); if (allowedOrigins.includes('*')) { res.header('Access-Control-Allow-Origin', origin || '*'); - } else if (origin && allowedOrigins.includes(origin)) { - res.header('Access-Control-Allow-Origin', origin); + } else if (normalized && allowedOrigins.includes(normalized)) { + res.header('Access-Control-Allow-Origin', normalized); } res.header('Access-Control-Allow-Credentials', 'true'); res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); diff --git a/docker-compose.yml b/docker-compose.yml index 5f1a999..bd5de9f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,38 +7,18 @@ services: profiles: - default environment: - MYSQL_ROOT_PASSWORD: rezepte123 - MYSQL_DATABASE: rezepte - MYSQL_USER: rezepte_user - MYSQL_PASSWORD: rezepte_pass + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-change_this_root_password} + MYSQL_DATABASE: ${MYSQL_DATABASE:-rezepte} + MYSQL_USER: ${MYSQL_USER:-rezepte_user} + MYSQL_PASSWORD: ${MYSQL_PASSWORD:-change_this_password} ports: - - "3307:3306" + - "${MYSQL_PORT:-3307}:3306" volumes: - mysql_data:/var/lib/mysql - ./sql-init:/docker-entrypoint-initdb.d networks: - rezepte_network - # PHP Application with Apache - php-app: - build: . - container_name: rezepte_app - restart: always - profiles: - - legacy - ports: - - "8082:80" - volumes: - - .:/var/www/html - depends_on: - - mysql - networks: - - rezepte_network - environment: - DB_HOST: mysql - DB_NAME: rezepte - DB_USER: rezepte_user - DB_PASS: rezepte_pass # phpMyAdmin phpmyadmin: @@ -48,12 +28,12 @@ services: profiles: - admin ports: - - "8083:80" + - "${PHPMYADMIN_PORT:-8083}:80" environment: PMA_HOST: mysql - PMA_USER: rezepte_user - PMA_PASSWORD: rezepte_pass - MYSQL_ROOT_PASSWORD: rezepte123 + PMA_USER: ${MYSQL_USER:-rezepte_user} + PMA_PASSWORD: ${MYSQL_PASSWORD:-change_this_password} + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-change_this_root_password} depends_on: - mysql networks: @@ -68,16 +48,15 @@ services: profiles: - default environment: - NODE_ENV: production - PORT: 3001 - DATABASE_URL: mysql://rezepte_user:rezepte_pass@mysql:3306/rezepte - JWT_SECRET: your-super-secret-jwt-key-change-in-production - UPLOAD_PATH: /app/uploads - MAX_FILE_SIZE: 5242880 - # CORS_ORIGIN: Restrict in production (example: http://esprimo:3000,http://localhost:3000) - CORS_ORIGIN: "*" + NODE_ENV: ${NODE_ENV:-production} + PORT: ${BACKEND_PORT:-3001} + DATABASE_URL: ${DATABASE_URL:-mysql://${MYSQL_USER:-rezepte_user}:${MYSQL_PASSWORD:-change_this_password}@mysql:3306/${MYSQL_DATABASE:-rezepte}} + JWT_SECRET: ${JWT_SECRET:-please_change_to_secure_32_char_min} + UPLOAD_PATH: ${UPLOAD_PATH:-/app/uploads} + MAX_FILE_SIZE: ${MAX_FILE_SIZE:-5242880} + CORS_ORIGIN: ${CORS_ORIGIN:-http://localhost:3000} ports: - - "3001:3001" + - "${BACKEND_PORT:-3001}:${BACKEND_PORT:-3001}" volumes: - uploads_data:/app/uploads - ./uploads:/app/legacy-uploads:ro @@ -98,7 +77,7 @@ services: profiles: - default ports: - - "3000:80" + - "${FRONTEND_PORT:-3000}:80" networks: - rezepte_network depends_on: