Security Hardening Guide
System-Level Hardening
SSH Configuration
# /etc/ssh/sshd_config
PermitRootLogin no
PasswordAuthentication no
PubkeyAuthentication yes
X11Forwarding no
MaxAuthTries 3
ClientAliveInterval 300
ClientAliveCountMax 2
# Restart SSH
sudo systemctl restart sshd
Automatic Security Updates
# Ubuntu/Debian
sudo apt install unattended-upgrades
sudo dpkg-reconfigure -plow unattended-upgrades
# /etc/apt/apt.conf.d/50unattended-upgrades
Unattended-Upgrade::Allowed-Origins {
"${distro_id}:${distro_codename}-security";
};
Unattended-Upgrade::Automatic-Reboot "true";
Unattended-Upgrade::Automatic-Reboot-Time "02:00";
Kernel Hardening
# /etc/sysctl.d/99-hardening.conf
# Disable IP forwarding
net.ipv4.ip_forward = 0
net.ipv6.conf.all.forwarding = 0
# Disable source packet routing
net.ipv4.conf.all.send_redirects = 0
net.ipv4.conf.default.send_redirects = 0
# Enable TCP SYN cookie protection
net.ipv4.tcp_syncookies = 1
# Ignore ICMP redirects
net.ipv4.conf.all.accept_redirects = 0
net.ipv6.conf.all.accept_redirects = 0
# Ignore ICMP pings
net.ipv4.icmp_echo_ignore_all = 1
# Apply changes
sudo sysctl -p /etc/sysctl.d/99-hardening.conf
Docker Security Hardening
Docker Daemon Configuration
// /etc/docker/daemon.json
{
"live-restore": true,
"userland-proxy": false,
"no-new-privileges": true,
"icc": false,
"log-driver": "json-file",
"log-opts": {
"max-size": "10m",
"max-file": "3"
},
"userns-remap": "default"
}
Hardened docker-compose.yml
version: '3.8'
services:
backend:
image: solarlog-backend:latest
# Security options
security_opt:
- no-new-privileges:true
- apparmor=docker-default
# Drop all capabilities, add only needed ones
cap_drop:
- ALL
cap_add:
- NET_BIND_SERVICE
- CHOWN
- SETGID
- SETUID
# Read-only root filesystem
read_only: true
# Tmpfs for temporary files
tmpfs:
- /tmp:noexec,nosuid,nodev,size=100m
- /var/run:noexec,nosuid,nodev,size=10m
# Resource limits
deploy:
resources:
limits:
cpus: '2'
memory: 1G
pids: 100
reservations:
cpus: '0.5'
memory: 512M
# Restart policy
restart: unless-stopped
# Network mode
networks:
- backend_network
# Logging
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# Healthcheck
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
postgres:
image: postgres:16
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
cap_add:
- CHOWN
- DAC_OVERRIDE
- SETGID
- SETUID
read_only: true
tmpfs:
- /tmp:noexec,nosuid,nodev
- /var/run/postgresql:noexec,nosuid,nodev
volumes:
- postgres-data:/var/lib/postgresql/data:rw
environment:
POSTGRES_DB: solarlog
POSTGRES_USER: solarlog
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets:
- db_password
networks:
- backend_network
networks:
backend_network:
driver: bridge
driver_opts:
com.docker.network.bridge.name: br_solarlog
ipam:
config:
- subnet: 172.20.0.0/24
volumes:
postgres-data:
driver: local
secrets:
db_password:
file: ./secrets/db_password.txt
Application Security
# backend/app/middleware/security.py
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
class SecurityHeadersMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
response = await call_next(request)
# Security headers
response.headers['X-Content-Type-Options'] = 'nosniff'
response.headers['X-Frame-Options'] = 'DENY'
response.headers['X-XSS-Protection'] = '1; mode=block'
response.headers['Strict-Transport-Security'] = 'max-age=31536000; includeSubDomains'
response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'
response.headers['Permissions-Policy'] = 'geolocation=(), microphone=(), camera=()'
# CSP
response.headers['Content-Security-Policy'] = (
"default-src 'self'; "
"script-src 'self' 'unsafe-inline'; "
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data: https:; "
"font-src 'self'; "
"connect-src 'self' https://solarlog-api.karma.organic; "
"frame-ancestors 'none';"
)
return response
# Add to app
from app.middleware.security import SecurityHeadersMiddleware
app.add_middleware(SecurityHeadersMiddleware)
# backend/app/api/validators.py
from pydantic import BaseModel, validator, Field
import ipaddress
class InverterCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
ip_address: str
port: int = Field(..., ge=1, le=65535)
@validator('name')
def name_alphanumeric(cls, v):
if not v.replace(' ', '').replace('-', '').replace('_', '').isalnum():
raise ValueError('Name must be alphanumeric')
return v
@validator('ip_address')
def validate_ip(cls, v):
try:
ipaddress.ip_address(v)
except ValueError:
raise ValueError('Invalid IP address')
return v
SQL Injection Prevention
# Always use SQLAlchemy ORM, never raw SQL
from sqlalchemy import select
# Good - parameterized query
stmt = select(Inverter).where(Inverter.id == inverter_id)
result = await db.execute(stmt)
# Bad - never do this!
# result = await db.execute(f"SELECT * FROM inverters WHERE id = {inverter_id}")
PostgreSQL Hardening
postgresql.conf
# /var/lib/postgresql/data/postgresql.conf
# SSL
ssl = on
ssl_cert_file = '/etc/ssl/certs/server.crt'
ssl_key_file = '/etc/ssl/private/server.key'
# Authentication
password_encryption = scram-sha-256
# Logging
log_connections = on
log_disconnections = on
log_duration = on
log_statement = 'mod'
log_line_prefix = '%t [%p]: [%l-1] user=%u,db=%d,app=%a,client=%h '
# Connection limits
max_connections = 100
superuser_reserved_connections = 3
# Resource limits
shared_buffers = 256MB
effective_cache_size = 1GB
maintenance_work_mem = 64MB
work_mem = 4MB
# Security
row_security = on
pg_hba.conf
# /var/lib/postgresql/data/pg_hba.conf
# TYPE DATABASE USER ADDRESS METHOD
# Require SSL and SCRAM-SHA-256
hostssl all all 172.20.0.0/24 scram-sha-256
hostssl all all 0.0.0.0/0 scram-sha-256 clientcert=1
# Deny all other connections
host all all all reject
Nginx Hardening
nginx.conf
# /etc/nginx/nginx.conf
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
use epoll;
}
http {
# Basic settings
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
server_tokens off;
# Buffer overflow protection
client_body_buffer_size 1K;
client_header_buffer_size 1k;
client_max_body_size 10M;
large_client_header_buffers 2 1k;
# Timeouts
client_body_timeout 10;
client_header_timeout 10;
send_timeout 10;
# Rate limiting
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=login:10m rate=1r/s;
limit_conn_zone $binary_remote_addr zone=addr:10m;
# SSL/TLS
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384';
ssl_prefer_server_ciphers off;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
ssl_stapling on;
ssl_stapling_verify on;
# Security headers
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
add_header X-Frame-Options "DENY" 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;
# Gzip
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml application/json application/javascript;
include /etc/nginx/conf.d/*.conf;
}
Site configuration
# /etc/nginx/conf.d/solarlog.conf
# Redirect HTTP to HTTPS
server {
listen 80;
listen [::]:80;
server_name solarlog.karma.organic;
return 301 https://$server_name$request_uri;
}
# Main server
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name solarlog.karma.organic;
# SSL certificates
ssl_certificate /etc/letsencrypt/live/solarlog.karma.organic/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/solarlog.karma.organic/privkey.pem;
# Rate limiting
limit_req zone=api burst=20 nodelay;
limit_conn addr 10;
# Frontend
location / {
proxy_pass http://frontend:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
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;
proxy_cache_bypass $http_upgrade;
}
# API
location /api {
limit_req zone=api burst=5 nodelay;
proxy_pass http://backend:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
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;
# Disable buffering for SSE
proxy_buffering off;
proxy_cache off;
}
# Block common exploits
location ~* "(eval\()|(\.\./)|(<|>|%0A|%0D)" {
deny all;
}
# Block access to hidden files
location ~ /\. {
deny all;
}
}
Secrets Management
Generate strong secrets
#!/bin/bash
# scripts/generate-secrets.sh
mkdir -p secrets
# Database password
openssl rand -base64 32 > secrets/db_password.txt
# API secret key
openssl rand -hex 32 > secrets/secret_key.txt
# JWT secret
openssl rand -hex 32 > secrets/jwt_secret.txt
# Set permissions
chmod 600 secrets/*
chown 1000:1000 secrets/*
echo "β
Secrets generated in ./secrets/"
Use Docker secrets
# docker-compose.yml
secrets:
db_password:
file: ./secrets/db_password.txt
secret_key:
file: ./secrets/secret_key.txt
services:
backend:
secrets:
- db_password
- secret_key
environment:
DB_PASSWORD_FILE: /run/secrets/db_password
SECRET_KEY_FILE: /run/secrets/secret_key
Monitoring & Auditing
Audit logging
# backend/app/middleware/audit.py
import logging
from datetime import datetime
audit_logger = logging.getLogger("audit")
audit_logger.setLevel(logging.INFO)
handler = logging.FileHandler("/var/log/solarlog/audit.log")
handler.setFormatter(logging.Formatter(
'%(asctime)s - %(name)s - %(levelname)s - %(message)s'
))
audit_logger.addHandler(handler)
@app.middleware("http")
async def audit_middleware(request: Request, call_next):
start_time = datetime.utcnow()
response = await call_next(request)
duration = (datetime.utcnow() - start_time).total_seconds()
audit_logger.info({
"timestamp": start_time.isoformat(),
"method": request.method,
"path": request.url.path,
"client_ip": request.client.host,
"status_code": response.status_code,
"duration": duration,
"user_agent": request.headers.get("user-agent")
})
return response
Compliance Checklist
Resources