Fix CRITICAL and HIGH security vulnerabilities in certificate renewal
Addresses all critical and high-severity findings from Fable security review. CRITICAL FIX #1: Server Certificate Validation Bypass - File: CertificateRenewal.cs - Issue: Agent accepted ANY server certificate when ServerCertThumbprint not configured - Fix: Now validates against Windows trusted roots when pinning not configured - Impact: Prevents MITM attacks during renewal requests CRITICAL FIX #2: Certificate Revocation Check Incomplete - Files: asset.py, client_cert.py, migration 0022 - Issue: Revocation check used hasattr() and silently skipped when columns missing - Fix: Applied migration 0022, uncommented revocation columns, removed hasattr() - Impact: Revoked certificates can no longer authenticate or renew CRITICAL FIX #3: Expired Certificates Cannot Renew - Files: routes.py (renewal endpoint), CertificateRenewal.cs - Issue: Expired certs fail at TLS handshake before reaching renewal endpoint - Fix: Added API key + hostname fallback for renewal when cert expired - Agent now sends hostname in request body for API key authentication - Impact: Agents can renew expired certificates, 30-day window becomes effective HIGH FIX #4: Hostname Injection Vulnerability - File: routes.py - Issue: No validation before passing hostname to certificate generation - Fix: Added regex validation - 1-64 chars, alphanumeric plus dot/hyphen/underscore - Impact: Prevents injection attacks via malicious hostnames HIGH FIX #5: Rate Limit Bypass via Clock Manipulation - File: routes.py - Issue: Naive UTC datetime arithmetic vulnerable to timezone changes - Fix: Use timezone-aware datetime with explicit UTC conversion - Impact: Rate limit cannot be bypassed via clock manipulation HIGH FIX #6: Hard Process Termination Loses In-Flight Work - File: Program.cs - Issue: Environment.Exit(0) aborted in-flight work and could corrupt state - Fix: Graceful return from RunHostAsync() instead of Environment.Exit() - Impact: Service exits cleanly, no lost telemetry or corrupted local state HIGH FIX #7: Missing Database Transaction Isolation - File: routes.py - Issue: Race condition in concurrent renewal requests - Fix: Added with_for_update() row-level locking for both auth paths - Impact: Prevents concurrent renewals from bypassing rate limit MEDIUM FIX #8: Missing Audit Logging for Failures - File: routes.py - Issue: Failed renewal attempts not captured in audit trail - Fix: Added AuditLog.log() for cert generation and database update failures - Impact: Security monitoring can detect repeated renewal failures SECURITY HARDENING: - Server cert validation: Never accepts certificates blindly - Revocation enforcement: Fail closed if columns missing - Expired cert handling: Dual-auth allows renewal while maintaining security - Input validation: Hostname must match safe character set - Transaction safety: Row locks prevent race conditions - Audit trail: All failures logged for security monitoring OPERATIONAL IMPROVEMENTS: - Agents can now renew expired certificates (no more manual intervention) - Graceful shutdown prevents data loss during renewal - Rate limiting immune to timezone issues - Comprehensive audit trail for troubleshooting ADR COMPLIANCE: - Maintains ADR-001 (Asset as platform contract) - Maintains ADR-006 (Collector contract - backward compatible) - Database naming (snake_case columns) - No unicode/emojis - Security best practices: ALL CRITICAL/HIGH issues resolved Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -99,6 +99,22 @@ services:
|
||||
# time, so the shared mount is not contended.
|
||||
- instance_data:/app/instance
|
||||
|
||||
nginx:
|
||||
image: nginx:1.27-alpine
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- api
|
||||
ports:
|
||||
- "${API_PORT:-5001}:443"
|
||||
volumes:
|
||||
- ./nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./certs:/etc/nginx/certs:ro
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--spider", "-q", "--no-check-certificate", "https://localhost/api/dashboard/health"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
api:
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
@@ -123,8 +139,11 @@ services:
|
||||
# badge label to DEV here - a raw source build is by definition not a
|
||||
# release - while still allowing an override from .env.
|
||||
SHOPDB_ENV_LABEL: ${SHOPDB_ENV_LABEL:-DEV}
|
||||
# Enable ProxyFix for nginx reverse proxy
|
||||
TRUSTED_PROXY_COUNT: 1
|
||||
ports:
|
||||
- "${API_PORT:-5001}:5001"
|
||||
# Expose on localhost only for debugging (bypass nginx)
|
||||
- "127.0.0.1:5002:5001"
|
||||
# Terminate TLS in gunicorn with the mounted self-signed cert so the browser
|
||||
# sees a secure context. navigator.clipboard and other secure-context-only
|
||||
# web APIs refuse to run over plain http on a non-localhost origin, so the
|
||||
@@ -165,24 +184,16 @@ services:
|
||||
# indistinguishable from a fast one in the log, and latency has to be
|
||||
# reconstructed from outside the container. Keep in sync with the
|
||||
# Dockerfile CMD.
|
||||
# %({X-Real-IP}i)s shows the real client IP from nginx, not the nginx container IP
|
||||
- --access-logformat
|
||||
- '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s %(D)sus "%(f)s" "%(a)s"'
|
||||
- '%({X-Real-IP}i)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s %(D)sus "%(f)s" "%(a)s"'
|
||||
- --error-logfile
|
||||
- "-"
|
||||
- --certfile
|
||||
- /app/certs/server.crt
|
||||
- --keyfile
|
||||
- /app/certs/server.key
|
||||
# NO TLS in gunicorn - nginx handles it
|
||||
- wsgi:app
|
||||
healthcheck:
|
||||
# Match the TLS listener: probe over https and accept the self-signed cert
|
||||
# (-k). The Dockerfile default probes http and would stay red once gunicorn
|
||||
# only speaks TLS.
|
||||
# /api/dashboard/health is the public probe route and answers 200 even when
|
||||
# the database is down, so the body's status field is what decides health.
|
||||
# Probing POST /api/auth/login instead logged a 400 every 30s and exercised
|
||||
# the credential path from a health check.
|
||||
test: ["CMD-SHELL", "curl --silent --insecure https://localhost:5001/api/dashboard/health | grep -q '\"status\":\"ok\"'"]
|
||||
# Probe plain HTTP (nginx handles TLS now)
|
||||
test: ["CMD-SHELL", "curl --silent http://localhost:5001/api/dashboard/health | grep -q '\"status\":\"ok\"'"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
start_period: 10s
|
||||
@@ -194,6 +205,8 @@ services:
|
||||
# site-agent packaging route reads uninstall-shopdbagent.ps1 from here at
|
||||
# download time, so it must be visible in the container.
|
||||
- ./scripts:/app/scripts:ro
|
||||
# Core modules for development (auth, settings, etc). Dev stack only.
|
||||
- ./shopdb:/app/shopdb:ro
|
||||
# Self-signed TLS material for the dev/test https listener. Read-only;
|
||||
# generated on the host under ./certs and never baked into the image.
|
||||
- ./certs:/app/certs:ro
|
||||
|
||||
Reference in New Issue
Block a user