From e76bf539861507f69303fcbadcb5cd9ab1463f33 Mon Sep 17 00:00:00 2001 From: Daniel Volz Date: Mon, 22 Dec 2025 11:51:56 +0100 Subject: [PATCH] feat: enhance Docker and Nginx configurations for security hardening and improved directory management --- backend/Dockerfile | 67 +++++++++++++++++++++++++++++--------- backend/package-lock.json | 1 - backend/src/db/client.ts | 13 ++++++++ docker-compose.prod.yml | 29 +++++++++++++++-- docker-compose.yml | 15 +++++++-- frontend/Dockerfile | 49 +++++++++++++++++++++++----- frontend/nginx.conf | 9 ++++- frontend/package-lock.json | 9 ----- 8 files changed, 153 insertions(+), 39 deletions(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index 9d01efa..a799091 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,19 +1,56 @@ -# Backend build -FROM node:25-slim AS builder -WORKDIR /app -COPY package.json tsconfig.json ./ -COPY src ./src -RUN npm install -RUN npm run build -RUN npm prune --omit=dev +# ============================================================================= +# BACKEND DOCKERFILE - Security Hardened +# ============================================================================= +# Security measures applied: +# - Non-root user execution +# - Multi-stage build (minimal runtime image) +# - No shell in final image (distroless) +# - Read-only filesystem compatible +# - No unnecessary packages +# - Specific image versions pinned +# ============================================================================= + +# ----------------------------------------------------------------------------- +# Stage 1: Builder +# ----------------------------------------------------------------------------- +FROM node:22-slim AS builder -# Runtime -FROM node:25-slim AS runner WORKDIR /app + +# Install dependencies first (better layer caching) +COPY package.json package-lock.json* ./ +RUN npm ci --ignore-scripts + +# Copy source and build +COPY tsconfig.json ./ +COPY src ./src +RUN npm run build + +# Remove dev dependencies for smaller image +RUN npm ci --omit=dev --ignore-scripts && \ + npm cache clean --force + +# ----------------------------------------------------------------------------- +# Stage 2: Production Runtime (Distroless - no shell, minimal attack surface) +# ----------------------------------------------------------------------------- +FROM gcr.io/distroless/nodejs22-debian12 AS runner + +WORKDIR /app + +# Copy built application with correct ownership (nonroot = uid 65532) +COPY --from=builder --chown=65532:65532 /app/node_modules ./node_modules +COPY --from=builder --chown=65532:65532 /app/dist ./dist +COPY --from=builder --chown=65532:65532 /app/package.json ./ + +# Environment configuration ENV NODE_ENV=production -COPY --from=builder /app/node_modules ./node_modules -COPY --from=builder /app/dist ./dist -COPY package.json . +ENV PORT=3000 + +# Expose application port EXPOSE 3000 -# Run database setup before starting the server -CMD ["sh", "-c", "mkdir -p /app/data && node dist/db/migrate.js && node dist/index.js"] + +# Run as non-root user (distroless default user) +USER nonroot + +# Start application - migrations handled in index.ts +CMD ["dist/index.js"] diff --git a/backend/package-lock.json b/backend/package-lock.json index 57c64f3..b08f939 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1542,7 +1542,6 @@ "resolved": "https://registry.npmjs.org/@libsql/client/-/client-0.10.0.tgz", "integrity": "sha512-2ERn08T4XOVx34yBtUPq0RDjAdd9TJ5qNH/izugr208ml2F94mk92qC64kXyDVQINodWJvp3kAdq6P4zTtCZ7g==", "license": "MIT", - "peer": true, "dependencies": { "@libsql/core": "^0.10.0", "@libsql/hrana-client": "^0.6.2", diff --git a/backend/src/db/client.ts b/backend/src/db/client.ts index c4a2ad3..2713de9 100644 --- a/backend/src/db/client.ts +++ b/backend/src/db/client.ts @@ -1,10 +1,23 @@ import { createClient } from "@libsql/client"; import { drizzle } from "drizzle-orm/libsql"; +import { existsSync, mkdirSync } from "fs"; +import { dirname } from "path"; import dotenv from "dotenv"; dotenv.config({ path: process.env.DOTENV_PATH || ".env" }); const url = process.env.DATABASE_URL || "file:./data/medassist.db"; + +// Ensure data directory exists before creating database +if (url.startsWith("file:")) { + const dbPath = url.replace("file:", ""); + const dataDir = dirname(dbPath); + if (!existsSync(dataDir)) { + mkdirSync(dataDir, { recursive: true }); + console.log(`[DB] Created data directory: ${dataDir}`); + } +} + const client = createClient({ url }); export const db = drizzle(client); diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index a01962d..426380b 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -1,3 +1,7 @@ +# ============================================================================= +# PRODUCTION DOCKER COMPOSE - Security Hardened +# ============================================================================= + services: backend: image: git.danielvolz.org/daniel/medassist/backend:0.0.1 @@ -7,8 +11,16 @@ services: - ./data:/app/data ports: - "4000:3000" + # Security options + security_opt: + - no-new-privileges:true + read_only: true + tmpfs: + - /tmp:noexec,nosuid,size=64m + cap_drop: + - ALL healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:3000/health"] + test: ["CMD", "/nodejs/bin/node", "-e", "require('http').get('http://localhost:3000/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))"] interval: 30s timeout: 5s retries: 3 @@ -17,6 +29,17 @@ services: frontend: image: git.danielvolz.org/daniel/medassist/frontend:0.0.1 ports: - - "4174:80" + - "4174:8080" depends_on: - - backend + backend: + condition: service_healthy + # Security options + security_opt: + - no-new-privileges:true + read_only: true + tmpfs: + - /tmp:noexec,nosuid,size=64m + - /var/cache/nginx:noexec,nosuid,size=64m + - /var/run:noexec,nosuid,size=64m + cap_drop: + - ALL diff --git a/docker-compose.yml b/docker-compose.yml index 3a57d33..4184926 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,13 @@ +# ============================================================================= +# DEVELOPMENT DOCKER COMPOSE - Security Hardened +# ============================================================================= +# Note: Dev containers need write access to volumes for hot-reload. +# Production containers run as non-root with read-only filesystem. +# ============================================================================= + services: backend-dev: - image: node:25-slim + image: node:22-slim working_dir: /app command: sh -c "npm install && npm run dev" volumes: @@ -11,6 +18,8 @@ services: - .env ports: - "3000:3000" + security_opt: + - no-new-privileges:true healthcheck: test: ["CMD-SHELL", "node -e \"require('http').get('http://localhost:3000/health', (r) => process.exit(r.statusCode === 200 ? 0 : 1)).on('error', () => process.exit(1))\""] interval: 30s @@ -19,7 +28,7 @@ services: start_period: 40s frontend-dev: - image: node:25-slim + image: node:22-slim working_dir: /app command: sh -c "npm install && npm run dev -- --host --port 5173" volumes: @@ -27,6 +36,8 @@ services: - frontend_node_modules:/app/node_modules ports: - "5173:5173" + security_opt: + - no-new-privileges:true depends_on: - backend-dev diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 495dd23..6c9da48 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,15 +1,48 @@ -# Frontend build -FROM node:25-slim AS builder +# ============================================================================= +# FRONTEND DOCKERFILE - Security Hardened +# ============================================================================= +# Security measures applied: +# - Non-root user execution (nginx user) +# - Multi-stage build (minimal runtime) +# - Read-only filesystem compatible +# - No unnecessary packages +# - Specific image versions pinned +# - Unprivileged nginx configuration +# ============================================================================= + +# ----------------------------------------------------------------------------- +# Stage 1: Builder +# ----------------------------------------------------------------------------- +FROM node:22-slim AS builder + WORKDIR /app -COPY package.json tsconfig.json tsconfig.node.json vite.config.ts index.html ./ + +# Install dependencies first (better layer caching) +COPY package.json package-lock.json* ./ +RUN npm ci --ignore-scripts + +# Copy source and build +COPY tsconfig.json tsconfig.node.json vite.config.ts index.html ./ COPY src ./src COPY public ./public -RUN npm install RUN npm run build -# Runtime -FROM nginx:1.27-alpine AS runner +# ----------------------------------------------------------------------------- +# Stage 2: Production Runtime (nginx unprivileged) +# ----------------------------------------------------------------------------- +FROM nginxinc/nginx-unprivileged:1.27-alpine AS runner + +# Copy custom nginx config (must listen on 8080, not 80) COPY nginx.conf /etc/nginx/conf.d/default.conf -COPY --from=builder /app/dist /usr/share/nginx/html -EXPOSE 80 + +# Copy built static files with correct ownership (nginx user = uid 101) +COPY --from=builder --chown=101:101 /app/dist /usr/share/nginx/html + +# nginx-unprivileged listens on 8080 by default +EXPOSE 8080 + +# Already runs as non-root (nginx user, uid 101) +USER nginx + +# Start nginx CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 7bfad8c..24b6963 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -1,10 +1,17 @@ server { - listen 80; + # Port 8080 for unprivileged nginx (non-root) + listen 8080; server_name _; root /usr/share/nginx/html; index index.html; + # Security headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + # Allow larger file uploads (for medication images) client_max_body_size 10M; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index ea29993..79c313b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -56,7 +56,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -1196,7 +1195,6 @@ "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -1286,7 +1284,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -1497,7 +1494,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@babel/runtime": "^7.26.10" }, @@ -1619,7 +1615,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -1661,7 +1656,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -1674,7 +1668,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -1857,7 +1850,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -1903,7 +1895,6 @@ "integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0",