Files
Juan Denis 76c57c19d6 fix(security): harden nginx write path and certbot issuance trust bugs
- run wildcard/SAN certbot privileged and fail on non-zero exit instead
  of reporting unissued cert paths as success
- cloudflare DNS-01 credentials: unpredictable mkstemp file, 0600,
  always deleted (was predictable /tmp path, never cleaned up)
- route53 credentials via sudo env A=B (sudo env_reset strips env= kwarg)
- validate domains before they reach certbot argv or cert file paths
- validate reverse-proxy builder input (domains, upstreams, headers,
  locations, rate-limit/cache values) against nginx config injection
- write_vhost: refuse non-plain-filename site names and roll back the
  vhost when the reload itself fails, not only when nginx -t fails
- create_proxy API returns 400 on service errors instead of always 201
2026-08-27 23:54:01 -04:00

286 lines
12 KiB
Python

import json
import logging
import subprocess
import tempfile
from datetime import datetime
from app.utils.system import run_privileged, run_unprivileged
from app.services.nginx_service import _validate_domain
from app.services.ssl_service import resolve_certbot_bin
def _certbot_bin():
"""Resolved certbot binary, falling back to the bare name (PATH lookup)."""
return resolve_certbot_bin() or 'certbot'
logger = logging.getLogger(__name__)
class AdvancedSSLService:
"""Service for advanced SSL certificate features."""
SSL_PROFILES = {
'modern': {
'label': 'Modern (TLS 1.3 only)',
'protocols': 'TLSv1.3',
'ciphers': '',
'description': 'Best security. Supports only modern browsers.',
},
'intermediate': {
'label': 'Intermediate (TLS 1.2+)',
'protocols': 'TLSv1.2 TLSv1.3',
'ciphers': 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384',
'description': 'Recommended for most servers. Good compatibility.',
},
'legacy': {
'label': 'Legacy (TLS 1.0+)',
'protocols': 'TLSv1 TLSv1.1 TLSv1.2 TLSv1.3',
'ciphers': 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256',
'description': 'Maximum compatibility. Supports old clients.',
},
}
@staticmethod
def get_ssl_profiles():
return AdvancedSSLService.SSL_PROFILES
@staticmethod
def issue_wildcard_cert(domain, dns_provider, credentials, email=None):
"""Issue a wildcard cert (domain + *.domain) via Let's Encrypt DNS-01.
Supports cloudflare (API token) and route53 (AWS key/secret). The certbot
DNS plugin matching the system certbot is installed best-effort first.
Returns the on-disk cert/key paths on success.
"""
import os
from app.utils.system import PackageManager
if dns_provider not in ('cloudflare', 'route53'):
return {'success': False, 'error': f'Unsupported DNS provider: {dns_provider}'}
# The domain becomes certbot argv below — reject anything that is not a
# plain domain so a crafted value cannot be parsed as certbot flags.
if not _validate_domain(domain):
return {'success': False, 'error': f'Invalid domain: {domain}'}
wildcard = f'*.{domain}'
cmd = [_certbot_bin(), 'certonly', '--non-interactive', '--agree-tos',
'--dns-' + dns_provider, '-d', domain, '-d', wildcard]
cmd.extend(['--email', email] if email else ['--register-unsafely-without-email'])
# Best-effort: ensure the DNS plugin matching the system certbot exists.
if PackageManager.is_available():
try:
PackageManager.install([f'python3-certbot-dns-{dns_provider}'], timeout=300)
except Exception:
pass # certbot reports a clear error below if the plugin is truly missing
# certbot writes /etc/letsencrypt as root, so it runs through
# run_privileged — and the credentials must survive sudo. cloudflare
# gets an unpredictable root-readable file that is always deleted
# afterwards; route53 goes through `sudo env A=B …` because sudo's
# env_reset would silently strip a plain env= kwarg.
cred_file = None
if dns_provider == 'cloudflare':
fd, cred_file = tempfile.mkstemp(prefix='certbot-cloudflare-', suffix='.ini')
with os.fdopen(fd, 'w') as f:
f.write(f"dns_cloudflare_api_token = {credentials.get('api_token', '')}\n")
if os.name != 'nt':
os.chmod(cred_file, 0o600)
cmd.extend(['--dns-cloudflare-credentials', cred_file])
elif dns_provider == 'route53':
cmd = ['env',
f"AWS_ACCESS_KEY_ID={credentials.get('api_key', '')}",
f"AWS_SECRET_ACCESS_KEY={credentials.get('api_secret', '')}",
] + cmd
try:
result = run_privileged(cmd, timeout=600)
if result.returncode != 0:
# A failed certbot run is a failure, full stop — never report
# the not-issued cert paths as if issuance had succeeded.
return {'success': False,
'error': (result.stderr or result.stdout
or 'certbot failed').strip()}
return {
'success': True, 'domain': domain, 'type': 'wildcard',
'certificate_path': f'/etc/letsencrypt/live/{domain}/fullchain.pem',
'private_key_path': f'/etc/letsencrypt/live/{domain}/privkey.pem',
'output': result.stdout or '',
}
except subprocess.TimeoutExpired:
return {'success': False, 'error': 'Certificate request timed out'}
except Exception as e:
return {'success': False, 'error': str(e)}
finally:
if cred_file:
try:
os.remove(cred_file)
except OSError:
pass
@staticmethod
def issue_san_cert(domains):
"""Issue multi-domain (SAN) certificate."""
if not domains or len(domains) < 1:
raise ValueError('At least one domain required')
invalid = [d for d in domains if not isinstance(d, str)
or not _validate_domain(d)]
if invalid:
return {'success': False,
'error': f'Invalid domain(s): {", ".join(map(str, invalid))}'}
cmd = [_certbot_bin(), 'certonly', '--non-interactive', '--agree-tos',
'--webroot', '-w', '/var/www/html']
for d in domains:
cmd.extend(['-d', d])
try:
result = run_privileged(cmd, timeout=600)
if result.returncode != 0:
return {'success': False,
'error': (result.stderr or result.stdout
or 'certbot failed').strip()}
return {'success': True, 'domains': domains, 'type': 'san',
'output': result.stdout or ''}
except subprocess.TimeoutExpired:
return {'success': False, 'error': 'Certificate request timed out'}
except Exception as e:
return {'success': False, 'error': str(e)}
@staticmethod
def upload_custom_cert(domain, cert_pem, key_pem, chain_pem=None):
"""Upload custom certificate files."""
import os
# The domain is joined into the cert directory path below — refuse
# anything that is not a plain domain so it cannot traverse out of
# /etc/ssl/serverkit.
if not _validate_domain(domain):
raise ValueError(f'Invalid domain: {domain}')
cert_dir = f'/etc/ssl/serverkit/{domain}'
os.makedirs(cert_dir, exist_ok=True)
cert_path = os.path.join(cert_dir, 'cert.pem')
key_path = os.path.join(cert_dir, 'key.pem')
chain_path = os.path.join(cert_dir, 'chain.pem')
with open(cert_path, 'w') as f:
f.write(cert_pem)
with open(key_path, 'w') as f:
f.write(key_pem)
if os.name != 'nt':
os.chmod(key_path, 0o600)
if chain_pem:
with open(chain_path, 'w') as f:
f.write(chain_pem)
return {
'domain': domain,
'cert_path': cert_path,
'key_path': key_path,
'chain_path': chain_path if chain_pem else None,
}
@staticmethod
def get_cert_health(domain):
"""Check SSL health — grade, cipher suites, protocol versions."""
import ssl
import socket
from datetime import timezone
result = {
'domain': domain,
'valid': False,
'grade': 'F',
'protocols': [],
'cipher_suites': [],
'issuer': None,
'expires_at': None,
'days_remaining': None,
}
try:
ctx = ssl.create_default_context()
with socket.create_connection((domain, 443), timeout=10) as sock:
with ctx.wrap_socket(sock, server_hostname=domain) as ssock:
cert = ssock.getpeercert()
cipher = ssock.cipher()
result['valid'] = True
result['cipher_suites'] = [cipher[0]] if cipher else []
result['protocols'] = [ssock.version()]
# Parse expiry
not_after = cert.get('notAfter')
if not_after:
expiry = datetime.strptime(not_after, '%b %d %H:%M:%S %Y %Z')
result['expires_at'] = expiry.isoformat()
result['days_remaining'] = (expiry - datetime.utcnow()).days
# Issuer
issuer = cert.get('issuer', ())
for field in issuer:
for k, v in field:
if k == 'organizationName':
result['issuer'] = v
# Simple grading
version = ssock.version()
if version == 'TLSv1.3':
result['grade'] = 'A+'
elif version == 'TLSv1.2':
result['grade'] = 'A'
elif version == 'TLSv1.1':
result['grade'] = 'B'
else:
result['grade'] = 'C'
except Exception as e:
# The probe could not determine anything — report unknown, never
# the template's valid: False / grade: 'F' (a determined
# "terrible TLS config" for what may be a DNS timeout).
result['valid'] = None
result['grade'] = None
result['error'] = str(e)
return result
@staticmethod
def get_expiry_alerts(days_threshold=30):
"""Get certificates expiring within threshold days."""
import os
import glob
alerts = []
cert_paths = glob.glob('/etc/letsencrypt/live/*/cert.pem')
cert_paths += glob.glob('/etc/ssl/serverkit/*/cert.pem')
for cert_path in cert_paths:
domain = os.path.basename(os.path.dirname(cert_path))
try:
result = run_unprivileged(['openssl', 'x509', '-enddate', '-noout', '-in', cert_path])
stdout = result.get('stdout', '')
if 'notAfter=' in stdout:
date_str = stdout.split('notAfter=')[1].strip()
expiry = datetime.strptime(date_str, '%b %d %H:%M:%S %Y %Z')
days = (expiry - datetime.utcnow()).days
if days <= days_threshold:
alerts.append({
'domain': domain,
'expires_at': expiry.isoformat(),
'days_remaining': days,
'severity': 'critical' if days <= 7 else 'warning',
})
except Exception as e:
# An unprobeable cert is not "not expiring" — surface it as
# unknown instead of dropping it from the alert list.
alerts.append({
'domain': domain,
'expires_at': None,
'days_remaining': None,
'severity': 'unknown',
'error': str(e),
})
return sorted(alerts, key=lambda x: (
x['days_remaining'] if x.get('days_remaining') is not None else 999))