Files
shopdb-flask/docker-compose.yml
Proudlock 702eeb3e52 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>
2026-09-14 14:18:35 -04:00

225 lines
10 KiB
YAML

# shopdb-flask single-site docker-compose template.
#
# Per ADR-004, each adopting facility runs its own stack. This template
# brings up MySQL + the API container and exposes the API on port 5001.
# The Vue frontend is served separately by the API in production builds
# (see register_frontend_routes in shopdb/__init__.py); for dev, run
# `npm run dev` in frontend/ on a separate port.
#
# Usage:
# cp .env.example .env
# # edit .env with site-specific secrets and origins
# docker compose up -d
#
# Refresh after pulling new code:
# docker compose build api
# docker compose up -d api
services:
db:
image: mysql:8.4
# utf8mb4 server-wide so the auto-created MYSQL_DATABASE is utf8mb4, not the
# image default. Keeps every site's schema on the same charset/collation.
command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:?MYSQL_ROOT_PASSWORD must be set}
MYSQL_DATABASE: shopdb_flask
MYSQL_USER: shopdb
MYSQL_PASSWORD: ${MYSQL_PASSWORD:?MYSQL_PASSWORD must be set}
volumes:
- db_data:/var/lib/mysql
# Bind MySQL to loopback only. The api container reaches db over the
# compose network regardless of this mapping; the published port is just
# for local admin tools (mysqldump, a client on the host). Exposing 3306
# on all interfaces would put the database on the facility network.
ports:
- "127.0.0.1:${MYSQL_PORT:-3306}:3306"
healthcheck:
# 127.0.0.1, not localhost: localhost means the unix socket, and the
# entrypoint's init pass answers on the socket while running the server
# with --skip-networking. A socket ping therefore reports healthy DURING
# init, and the api/migrate services start against a server that is about
# to restart. Over TCP the probe stays red until the real server listens.
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-u", "root", "-p${MYSQL_ROOT_PASSWORD}"]
interval: 10s
timeout: 5s
retries: 5
# Schema, plugin bootstrap and seed data all happen here, not in the api
# container. One place, one ordering, and a failure stops the rollout instead
# of being retried forever: restart "no" means a bad migration exits once and
# leaves the error as the last thing in the log. The api service below will
# not start at all until this exits 0, so `up -d api` still migrates first -
# compose honours service_completed_successfully even when a single service
# is named on the command line.
migrate:
build: .
restart: "no"
depends_on:
db:
condition: service_healthy
environment:
FLASK_ENV: production
DATABASE_URL: mysql+pymysql://shopdb:${MYSQL_PASSWORD}@db:3306/shopdb_flask?charset=utf8mb4
SECRET_KEY: ${SECRET_KEY:?SECRET_KEY must be set}
JWT_SECRET_KEY: ${JWT_SECRET_KEY:?JWT_SECRET_KEY must be set}
# Required even though this service never serves a request: create_app()
# runs config validation before any CLI command dispatches, and the
# production config refuses to build without an explicit CORS allowlist.
CORS_ORIGINS: ${CORS_ORIGINS:?CORS_ORIGINS must be set}
LOG_LEVEL: ${LOG_LEVEL:-INFO}
# Chained with && so the first failure stops the rest. Ordering is
# load-bearing: plugin tables cannot be created before the plugins are
# installed, and seedscopes only links permissions that already exist as
# rows, so the seeds run last.
#
# The seeds are all create-if-missing (see shopdb/cli/__init__.py), so
# running them on every start is a no-op once the site is current. They run
# here rather than only on first deploy because a patch that adds a
# permission or a default setting has to reach an existing site on the
# upgrade it already performs - that is the whole point of this service.
# No entrypoint override: the image entrypoint now only waits for the
# database and execs, which is exactly the preamble this service wants.
command:
- sh
- -c
- >
flask db upgrade &&
/app/scripts/bootstrap-plugins.sh &&
flask plugin upgrade-all &&
flask seed permissions &&
flask seed settings &&
flask seed reference-data
volumes:
- ./plugins:/app/plugins:ro
- ./scripts:/app/scripts:ro
# plugins.json is written here by bootstrap-plugins.sh, so this service
# needs the same instance volume the api uses. They never run at the same
# 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
depends_on:
db:
condition: service_healthy
migrate:
condition: service_completed_successfully
environment:
FLASK_ENV: production
DATABASE_URL: mysql+pymysql://shopdb:${MYSQL_PASSWORD}@db:3306/shopdb_flask?charset=utf8mb4
SECRET_KEY: ${SECRET_KEY:?SECRET_KEY must be set}
JWT_SECRET_KEY: ${JWT_SECRET_KEY:?JWT_SECRET_KEY must be set}
CORS_ORIGINS: ${CORS_ORIGINS:?CORS_ORIGINS must be set}
LOG_LEVEL: ${LOG_LEVEL:-INFO}
ZABBIX_URL: ${ZABBIX_URL:-}
ZABBIX_TOKEN: ${ZABBIX_TOKEN:-}
COLLECTOR_API_KEY: ${COLLECTOR_API_KEY:-}
COLLECTOR_API_KEY_ENDPOINTS: ${COLLECTOR_API_KEY_ENDPOINTS:-}
# This file is the source-build dev stack (wilm/dev-be); stable and edge
# ship through the pull/airgap compose files, not this one. So default the
# 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:
# 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
# test box (accessed by FQDN) needs real https. A site swaps in its own cert
# under ./certs or fronts this with its IIS/nginx proxy.
command:
- gunicorn
- --bind
- 0.0.0.0:5001
# Async workers, not threads. The endpoint agent long-polls
# /api/endpoints/commands/next and re-polls immediately on a 204, so every
# agent holds a connection essentially continuously and fleet size IS the
# concurrency. Under gthread that meant 4 x 8 = 32 slots total, and a
# single worker's 8 threads could fill with sleeping agents while the CPU
# sat idle - measured: 8 of 9 threads in timed sleep, UI p95 24s.
#
# A held connection under gevent is a greenlet, not an OS thread, so idle
# is nearly free. Measured on this stack (devnotes/gevent-sizing-simulation.md):
# 700 simulated agents plus UI, integration and kiosk traffic served with
# zero errors at UI median 7.5ms, peak RSS 479MB. CPU, not memory or DB
# connections, is the binding resource.
- --worker-class
- gevent
- --workers
- "4"
# Per-worker greenlet ceiling. 4 x 2000 is far above any realistic fleet;
# it exists so a connection spike queues rather than being refused.
- --worker-connections
- "2000"
# 60, not 30: the timeout must exceed _CLAIM_HOLD_SECONDS in
# plugins/endpoints/api/routes.py or gunicorn kills a healthy long-poll
# mid-hold. Cheap here because a stuck connection costs a greenlet.
- --timeout
- "60"
- --access-logfile
- "-"
# %(D)s is request duration in microseconds. Without it a slow request is
# 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
- '%({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
- "-"
# NO TLS in gunicorn - nginx handles it
- wsgi:app
healthcheck:
# 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
retries: 3
volumes:
- ./plugins:/app/plugins:ro
# Standalone operator scripts shipped inside generated bundles (the
# endpoint-agent uninstaller). Read-only, same pattern as plugins: the
# 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
# /app/instance is WRITTEN state, not code: plugins.json (which plugins
# this site has enabled), uploaded floor plans, branding, model and
# application images, employee photos, warranty proofs, slides,
# printed-part files, and the Dell OAuth token. Without this volume a
# `docker compose build api && up -d api` recreates the container and
# takes all of it with it, so the site comes back with its plugins
# disabled and MySQL rows pointing at files that no longer exist.
- instance_data:/app/instance
volumes:
db_data:
instance_data: