feat: implement secure Docker setup with Nginx and Node API server running as non-root user

This commit is contained in:
greg 2025-05-24 08:45:22 +02:00
parent e406c7ffce
commit 90f4133606
6 changed files with 227 additions and 56 deletions

View File

@ -2,63 +2,75 @@
# Stage 1: Build the Astro application # Stage 1: Build the Astro application
FROM node:20-alpine as build FROM node:20-alpine as build
# Set working directory
WORKDIR /app WORKDIR /app
# Copy package files and install dependencies # Copy package files. If src/api has its own package.json, handle it here or in a dedicated API build stage.
COPY package.json package-lock.json ./ COPY package.json package-lock.json* ./
# Ensure src/api/package.json is copied if it exists and is used for API dependencies
# COPY src/api/package.json src/api/package-lock.json* ./src/api/
RUN npm ci RUN npm ci
# Copy the rest of the application code # Copy the rest of the application code
# Ensure .dockerignore is properly set up to exclude node_modules etc. from host
COPY . . COPY . .
# Build the Astro project # Build the Astro project (generates into /app/dist)
RUN npm run build RUN npm run build
# If API has separate build step, include it here.
# Example: RUN npm run build --workspace=api (if using npm workspaces)
# Stage 2: Serve with NGINX and Node API # Stage 2: Serve with NGINX and Node API
FROM nginx:alpine FROM nginx:alpine
# Install Node.js for the API server # Create a non-root user and group
RUN apk add --update nodejs npm RUN addgroup -S appgroup && adduser -S -G appgroup appuser
# Set up directory structure # Install Node.js for the API server and su-exec for user switching
RUN apk add --no-cache nodejs npm su-exec
# Set base working directory
WORKDIR /app WORKDIR /app
# Copy built static files from build stage to NGINX html directory # Copy built static files from build stage to NGINX html directory
COPY --from=build /app/dist /usr/share/nginx/html COPY --from=build /app/dist /usr/share/nginx/html
RUN chown -R appuser:appgroup /usr/share/nginx/html && chmod -R 755 /usr/share/nginx/html
# Copy API files # Copy API files from build stage
# Ensure that node_modules for the API are correctly copied or installed here.
COPY --from=build /app/src/api /app/api COPY --from=build /app/src/api /app/api
# If API node_modules were part of the build stage under /app/api/node_modules, copy them:
# COPY --from=build --chown=appuser:appgroup /app/api/node_modules /app/api/node_modules
# Or, if API has its own package.json and it was copied to /app/api in build stage:
COPY --from=build /app/api/package.json /app/api/package-lock.json* /app/api/
WORKDIR /app/api WORKDIR /app/api
RUN npm install --production RUN npm ci --omit=dev --ignore-scripts # Install production API dependencies
RUN chown -R appuser:appgroup /app/api
# Add custom NGINX configuration with API proxy # Copy custom NGINX configuration
RUN echo 'server {\ # This replaces the RUN echo '...' command
listen 80;\ COPY nginx.conf /etc/nginx/conf.d/default.conf
server_name _;\ RUN chown appuser:appgroup /etc/nginx/conf.d/default.conf && chmod 644 /etc/nginx/conf.d/default.conf
root /usr/share/nginx/html;\ # Ensure Nginx can write to its log directory (usually /var/log/nginx)
index index.html;\ # Alpine Nginx base image usually sets this up correctly. If not, create and chown /var/log/nginx.
location / {\ RUN mkdir -p /var/log/nginx && chown -R appuser:appgroup /var/log/nginx
try_files $uri $uri/ /index.html;\ RUN mkdir -p /run/nginx && chown -R appuser:appgroup /run/nginx # For PID file
}\
location /api/ {\
proxy_pass http://localhost:3000/api/;\
proxy_http_version 1.1;\
proxy_set_header Upgrade $http_upgrade;\
proxy_set_header Connection "upgrade";\
proxy_set_header Host $host;\
}\
}' > /etc/nginx/conf.d/default.conf
# Copy start script # Copy start script from build stage
COPY --from=build /app/start.sh /app/start.sh COPY --from=build /app/start.sh /app/start.sh
RUN chmod +x /app/start.sh RUN chmod +x /app/start.sh && chown appuser:appgroup /app/start.sh
# Create a directory for content if it doesn't exist # Create and set permissions for content directory
RUN mkdir -p /app/content/books RUN mkdir -p /app/content/books && chown -R appuser:appgroup /app/content
# Expose port 80 # Expose port 80 (Nginx will listen on this port)
EXPOSE 80 EXPOSE 80
# Start NGINX and API server # Switch to non-root user for running the application
USER appuser
# Start NGINX and API server using the start script
# The start.sh script will use su-exec for the Node API part if needed,
# but since we USER appuser, nginx and node will run as appuser.
CMD ["/app/start.sh"] CMD ["/app/start.sh"]

View File

@ -4,5 +4,6 @@ import { defineConfig } from 'astro/config';
import tailwind from '@astrojs/tailwind'; import tailwind from '@astrojs/tailwind';
export default defineConfig({ export default defineConfig({
output: 'static',
integrations: [tailwind()] integrations: [tailwind()]
}); });

47
nginx.conf Normal file
View File

@ -0,0 +1,47 @@
user appuser; # Run Nginx worker processes as appuser
worker_processes auto;
daemon off; # Run in the foreground, managed by Docker/start.sh
# Ensure appuser can write to pid path if it's in a restricted location.
# Default for alpine nginx is /run/nginx/nginx.pid, which should be fine.
# If issues, uncomment and adjust:
# pid /tmp/nginx.pid;
error_log /var/log/nginx/error.log warn; # Ensure appuser can write here or adjust
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout 65;
# gzip on; # Consider enabling gzip for better performance
server {
listen 80 default_server;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://localhost:3000/api/; # Node API listens on 3000
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
}

View File

@ -4,79 +4,160 @@ const path = require('path');
const cors = require('cors'); const cors = require('cors');
const bodyParser = require('body-parser'); const bodyParser = require('body-parser');
const basicAuth = require('express-basic-auth'); const basicAuth = require('express-basic-auth');
const helmet = require('helmet'); // Added helmet
const app = express(); const app = express();
const PORT = process.env.PORT || 3000; const PORT = process.env.PORT || 3000;
const CONTENT_DIR = process.env.CONTENT_DIR || path.join(__dirname, '../../content'); const CONTENT_DIR = process.env.CONTENT_DIR || path.join(__dirname, '../../content');
const USERNAME = process.env.ADMIN_USERNAME || 'admin'; const BOOKS_DIR = path.join(CONTENT_DIR, 'books'); // Define books directory
const PASSWORD = process.env.ADMIN_PASSWORD || 'password';
const USERNAME = process.env.ADMIN_USERNAME;
const PASSWORD = process.env.ADMIN_PASSWORD;
// Middleware // Middleware
app.use(helmet({ contentSecurityPolicy: false })); // Added helmet, disable its CSP if Astro handles it
// For CORS, consider restricting the origin in production:
// app.use(cors({ origin: 'https://your-admin-domain.com' }));
app.use(cors()); app.use(cors());
app.use(bodyParser.json()); app.use(bodyParser.json({ limit: '1mb' })); // Added body parser size limit
// Basic authentication // Basic authentication - only if credentials are set
app.use(basicAuth({ if (USERNAME && PASSWORD) {
app.use(basicAuth({
users: { [USERNAME]: PASSWORD }, users: { [USERNAME]: PASSWORD },
challenge: true, challenge: true,
realm: 'Content Admin' realm: 'Content Admin'
})); }));
} else {
console.error('CRITICAL: ADMIN_USERNAME and/or ADMIN_PASSWORD environment variables are not set. API will not be secured.');
// Optionally, you could prevent the API routes from being registered or exit.
// For now, it will run unsecured if no credentials, which is a major risk.
// A better approach for production would be to throw an error and exit:
// throw new Error('Admin credentials not configured');
}
// Helper function for filename validation and path construction
function getSafeFilePath(filename) {
if (!filename || typeof filename !== 'string' || !/^[a-zA-Z0-9_-]+\.md$/.test(filename)) {
return { error: 'Invalid filename format. Must be alphanumeric, underscores, or hyphens, and end with .md.', path: null };
}
const cleanFilename = path.basename(filename); // Ensures no path characters are in the filename itself
if (cleanFilename !== filename) {
return { error: 'Invalid filename (contains path characters).', path: null };
}
const resolvedPath = path.join(BOOKS_DIR, cleanFilename);
// Final check to prevent escaping BOOKS_DIR
if (!resolvedPath.startsWith(BOOKS_DIR + path.sep)) {
return { error: 'Invalid path (attempt to escape content directory).', path: null };
}
return { error: null, path: resolvedPath };
}
// List all content files // List all content files
app.get('/api/content', async (req, res) => { app.get('/api/content', async (req, res) => {
try { try {
const files = await fs.readdir(path.join(CONTENT_DIR, 'books')); await fs.mkdir(BOOKS_DIR, { recursive: true }); // Ensure directory exists
const files = await fs.readdir(BOOKS_DIR);
const contentFiles = files.filter(file => file.endsWith('.md')); const contentFiles = files.filter(file => file.endsWith('.md'));
res.json({ files: contentFiles }); res.json({ files: contentFiles });
} catch (error) { } catch (error) {
res.status(500).json({ error: error.message }); console.error('Error listing content:', error);
res.status(500).json({ error: 'Failed to list content.' });
} }
}); });
// Get content by filename // Get content by filename
app.get('/api/content/:filename', async (req, res) => { app.get('/api/content/:filename', async (req, res) => {
const { error: filePathError, path: filePath } = getSafeFilePath(req.params.filename);
if (filePathError) {
return res.status(400).json({ error: filePathError });
}
try { try {
const filePath = path.join(CONTENT_DIR, 'books', req.params.filename);
const content = await fs.readFile(filePath, 'utf-8'); const content = await fs.readFile(filePath, 'utf-8');
res.json({ content }); res.json({ content });
} catch (error) { } catch (error) {
res.status(404).json({ error: 'File not found' }); if (error.code === 'ENOENT') {
res.status(404).json({ error: 'File not found.' });
} else {
console.error(`Error reading file ${req.params.filename}:`, error);
res.status(500).json({ error: 'Failed to read file.' });
}
} }
}); });
// Update content // Update content
app.put('/api/content/:filename', async (req, res) => { app.put('/api/content/:filename', async (req, res) => {
const { error: filePathError, path: filePath } = getSafeFilePath(req.params.filename);
if (filePathError) {
return res.status(400).json({ error: filePathError });
}
if (typeof req.body.content !== 'string') {
return res.status(400).json({ error: 'Invalid content.'});
}
try { try {
const filePath = path.join(CONTENT_DIR, 'books', req.params.filename); await fs.mkdir(BOOKS_DIR, { recursive: true }); // Ensure directory exists
await fs.writeFile(filePath, req.body.content); await fs.writeFile(filePath, req.body.content);
res.json({ success: true }); res.json({ success: true });
} catch (error) { } catch (error) {
res.status(500).json({ error: error.message }); console.error(`Error writing file ${req.params.filename}:`, error);
res.status(500).json({ error: 'Failed to update file.' });
} }
}); });
// Create new content file // Create new content file
app.post('/api/content', async (req, res) => { app.post('/api/content', async (req, res) => {
const { error: filePathError, path: filePath } = getSafeFilePath(req.body.filename);
if (filePathError) {
return res.status(400).json({ error: filePathError });
}
if (typeof req.body.content !== 'string') {
return res.status(400).json({ error: 'Invalid content.'});
}
try { try {
const filePath = path.join(CONTENT_DIR, 'books', req.body.filename); await fs.mkdir(BOOKS_DIR, { recursive: true }); // Ensure directory exists
// Check if file already exists to prevent accidental overwrite via POST
try {
await fs.access(filePath);
return res.status(409).json({ error: 'File already exists. Use PUT to update.' });
} catch (accessError) {
// File does not exist, proceed to write
}
await fs.writeFile(filePath, req.body.content); await fs.writeFile(filePath, req.body.content);
res.json({ success: true }); res.json({ success: true });
} catch (error) { } catch (error) {
res.status(500).json({ error: error.message }); console.error(`Error creating file ${req.body.filename}:`, error);
res.status(500).json({ error: 'Failed to create file.' });
} }
}); });
// Delete content file // Delete content file
app.delete('/api/content/:filename', async (req, res) => { app.delete('/api/content/:filename', async (req, res) => {
const { error: filePathError, path: filePath } = getSafeFilePath(req.params.filename);
if (filePathError) {
return res.status(400).json({ error: filePathError });
}
try { try {
const filePath = path.join(CONTENT_DIR, 'books', req.params.filename);
await fs.unlink(filePath); await fs.unlink(filePath);
res.json({ success: true }); res.json({ success: true });
} catch (error) { } catch (error) {
res.status(500).json({ error: error.message }); if (error.code === 'ENOENT') {
res.status(404).json({ error: 'File not found.' });
} else {
console.error(`Error deleting file ${req.params.filename}:`, error);
res.status(500).json({ error: 'Failed to delete file.' });
}
} }
}); });
app.listen(PORT, () => { app.listen(PORT, () => {
console.log(`API server running on port ${PORT}`); console.log(`API server running on port ${PORT}`);
if (!USERNAME || !PASSWORD) {
console.warn('WARNING: API is running WITHOUT authentication due to missing ADMIN_USERNAME or ADMIN_PASSWORD environment variables. This is a major security risk.');
}
}); });

9
src/middleware.js Normal file
View File

@ -0,0 +1,9 @@
// src/middleware.js
export async function onRequest(context, next) {
const response = await next(); // Get the response from the next middleware or page
response.headers.set(
'Content-Security-Policy',
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; object-src 'none'; frame-ancestors 'none';"
);
return response; // Return the modified response
}

View File

@ -1,6 +1,27 @@
#!/bin/sh #!/bin/sh
# Start API server set -e # Exit immediately if a command exits with a non-zero status.
cd /app/api && node server.js &
# Start NGINX # Navigate to API directory and start Node.js server in the background
nginx -g "daemon off;" # It will run as 'appuser' because of USER appuser in Dockerfile
echo "Starting Node.js API server..."
cd /app/api
node server.js &
NODE_PID=$!
echo "Node.js API server started with PID $NODE_PID"
# Start NGINX in the foreground
# It will also run as 'appuser' (master and workers, due to USER appuser and nginx.conf user directive)
echo "Starting NGINX..."
nginx
NGINX_STATUS=$?
# If nginx exits, try to kill node process if it's still running
if ! kill -0 $NODE_PID > /dev/null 2>&1; then
echo "Node process already stopped."
else
echo "Nginx exited with status $NGINX_STATUS, stopping Node.js API server..."
kill $NODE_PID
wait $NODE_PID || true # Wait for node to stop, ignore error if already stopped
fi
exit $NGINX_STATUS