|
|
@@ -47,14 +47,30 @@ class NtfyLogHandler(logging.Handler):
|
|
|
# Don't fail the application if logging notification fails
|
|
|
pass
|
|
|
|
|
|
-# Configure logging
|
|
|
+# Configure logging with secure-file fallback
|
|
|
+log_path = '/var/log/emergency-access.log'
|
|
|
+try:
|
|
|
+ os.makedirs(os.path.dirname(log_path), exist_ok=True)
|
|
|
+ # Ensure the log file exists with restrictive permissions
|
|
|
+ if not os.path.exists(log_path):
|
|
|
+ open(log_path, 'a').close()
|
|
|
+ try:
|
|
|
+ os.chmod(log_path, 0o600)
|
|
|
+ except Exception:
|
|
|
+ # Best-effort; if this fails we continue with stream logging
|
|
|
+ pass
|
|
|
+ log_handlers = [
|
|
|
+ logging.FileHandler(log_path),
|
|
|
+ logging.StreamHandler()
|
|
|
+ ]
|
|
|
+except Exception:
|
|
|
+ # If any issue creating the file (permissions, etc.), fall back to stream-only logging
|
|
|
+ log_handlers = [logging.StreamHandler()]
|
|
|
+
|
|
|
logging.basicConfig(
|
|
|
level=logging.INFO,
|
|
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
|
|
- handlers=[
|
|
|
- logging.FileHandler('/var/log/emergency-access.log'),
|
|
|
- logging.StreamHandler()
|
|
|
- ]
|
|
|
+ handlers=log_handlers
|
|
|
)
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@@ -66,7 +82,33 @@ config = None
|
|
|
|
|
|
|
|
|
def require_auth(key_config=None, is_health=False):
|
|
|
- """Decorator for HTTP Basic Authentication"""
|
|
|
+ """Decorator for HTTP Basic Authentication with simple in-memory rate limiting.
|
|
|
+
|
|
|
+ This implements a small, best-effort per-(IP,username) rate limiter and lockout
|
|
|
+ to mitigate brute-force attempts. It is intentionally simple and in-memory:
|
|
|
+ - MAX_ATTEMPTS: number of failed attempts within WINDOW before lockout
|
|
|
+ - WINDOW: window (seconds) to count attempts
|
|
|
+ - LOCKOUT: lockout duration (seconds) after exceeding MAX_ATTEMPTS
|
|
|
+
|
|
|
+ Notes:
|
|
|
+ - Because this is in-memory, it resets on process restart.
|
|
|
+ - For multi-process deployments, offload rate limiting to the reverse proxy (Caddy)
|
|
|
+ or a shared store (redis).
|
|
|
+ - On repeated lockouts a notification is sent (best-effort) to configured health backends
|
|
|
+ and, when applicable, to the key's backends.
|
|
|
+ """
|
|
|
+ # Initialize shared state on the require_auth function object (module-global alternative)
|
|
|
+ if not hasattr(require_auth, "_state"):
|
|
|
+ require_auth._state = {
|
|
|
+ 'attempts': {}, # (ip, username) -> list[timestamps]
|
|
|
+ 'blocked': {}, # (ip, username) -> blocked_until_timestamp
|
|
|
+ 'lock': threading.Lock()
|
|
|
+ }
|
|
|
+
|
|
|
+ MAX_ATTEMPTS = 5
|
|
|
+ WINDOW = 300 # seconds
|
|
|
+ LOCKOUT = 900 # seconds
|
|
|
+
|
|
|
def decorator(f):
|
|
|
@wraps(f)
|
|
|
def decorated_function(*args, **kwargs):
|
|
|
@@ -75,8 +117,54 @@ def require_auth(key_config=None, is_health=False):
|
|
|
return jsonify({'error': 'Server configuration error'}), 500
|
|
|
|
|
|
auth = request.authorization
|
|
|
+ client_ip = request.remote_addr or 'unknown'
|
|
|
+
|
|
|
+ # determine username for rate limit key; use placeholder if missing
|
|
|
+ username_for_key = auth.username if auth and auth.username else "<no_auth>"
|
|
|
+
|
|
|
+ key = (client_ip, username_for_key)
|
|
|
+
|
|
|
+ now_ts = time.time()
|
|
|
+ state = require_auth._state
|
|
|
+
|
|
|
+ # Check block list
|
|
|
+ with state['lock']:
|
|
|
+ blocked_until = state['blocked'].get(key)
|
|
|
+ if blocked_until and now_ts < blocked_until:
|
|
|
+ retry_after = int(blocked_until - now_ts)
|
|
|
+ logger.warning(f"Blocked auth attempt from {client_ip} for user '{username_for_key}' (locked until {blocked_until})")
|
|
|
+ # Return 429 Too Many Requests
|
|
|
+ return jsonify({'error': 'Too many authentication attempts, temporarily locked'}), 429, {
|
|
|
+ 'Retry-After': str(retry_after)
|
|
|
+ }
|
|
|
|
|
|
if not auth:
|
|
|
+ # For missing auth, still account for attempts keyed by ip and '<no_auth>'
|
|
|
+ # Increment attempt count and possibly lock
|
|
|
+ with state['lock']:
|
|
|
+ attempts = state['attempts'].setdefault(key, [])
|
|
|
+ # prune old timestamps
|
|
|
+ attempts = [t for t in attempts if now_ts - t <= WINDOW]
|
|
|
+ attempts.append(now_ts)
|
|
|
+ state['attempts'][key] = attempts
|
|
|
+ if len(attempts) > MAX_ATTEMPTS:
|
|
|
+ # Lock out
|
|
|
+ state['blocked'][key] = now_ts + LOCKOUT
|
|
|
+ logger.error(f"Locking out {client_ip} for missing/invalid auth (no credentials) after repeated attempts")
|
|
|
+ # Try to notify about brute force (best-effort)
|
|
|
+ try:
|
|
|
+ if config and hasattr(config, 'ntfy_backends_health'):
|
|
|
+ send_ntfy_notification(
|
|
|
+ config.ntfy_backends_health,
|
|
|
+ f"🚨 Brute-force lockout: {client_ip} (no credentials) on server",
|
|
|
+ "Emergency Access ALERT"
|
|
|
+ )
|
|
|
+ except Exception:
|
|
|
+ pass
|
|
|
+ return jsonify({'error': 'Too many authentication attempts, temporarily locked'}), 429, {
|
|
|
+ 'Retry-After': str(LOCKOUT)
|
|
|
+ }
|
|
|
+
|
|
|
return jsonify({'error': 'Authentication required'}), 401, {
|
|
|
'WWW-Authenticate': 'Basic realm="Emergency Access"'
|
|
|
}
|
|
|
@@ -95,13 +183,59 @@ def require_auth(key_config=None, is_health=False):
|
|
|
auth_type = f"key '{key_config.key_id}'"
|
|
|
|
|
|
# Verify credentials
|
|
|
- if (auth.username != expected_username or
|
|
|
- not Config.verify_password(auth.password, expected_password_hash)):
|
|
|
- logger.warning(f"Authentication failed for {auth_type}: invalid credentials from {request.remote_addr}")
|
|
|
+ credential_ok = (auth.username == expected_username and
|
|
|
+ Config.verify_password(auth.password, expected_password_hash))
|
|
|
+
|
|
|
+ if not credential_ok:
|
|
|
+ # record failed attempt and check for lockout
|
|
|
+ with state['lock']:
|
|
|
+ attempts = state['attempts'].setdefault(key, [])
|
|
|
+ attempts = [t for t in attempts if now_ts - t <= WINDOW]
|
|
|
+ attempts.append(now_ts)
|
|
|
+ state['attempts'][key] = attempts
|
|
|
+
|
|
|
+ if len(attempts) > MAX_ATTEMPTS:
|
|
|
+ state['blocked'][key] = now_ts + LOCKOUT
|
|
|
+ logger.error(f"Locking out {client_ip} for user '{username_for_key}' after repeated failed attempts")
|
|
|
+ # Notify about brute-force lockout (best-effort)
|
|
|
+ try:
|
|
|
+ notify_backends = []
|
|
|
+ if config and hasattr(config, 'ntfy_backends_health'):
|
|
|
+ notify_backends.extend(config.ntfy_backends_health)
|
|
|
+ # Include key-specific backends when available
|
|
|
+ if key_config and getattr(key_config, 'backends', None):
|
|
|
+ notify_backends.extend(key_config.backends)
|
|
|
+ if notify_backends:
|
|
|
+ send_ntfy_notification(
|
|
|
+ list(dict.fromkeys(notify_backends)), # deduplicate
|
|
|
+ f"🚨 Brute-force lockout: {client_ip} user='{username_for_key}' on {auth_type}",
|
|
|
+ "Emergency Access ALERT"
|
|
|
+ )
|
|
|
+ except Exception:
|
|
|
+ pass
|
|
|
+
|
|
|
+ return jsonify({'error': 'Too many authentication attempts, temporarily locked'}), 429, {
|
|
|
+ 'Retry-After': str(LOCKOUT)
|
|
|
+ }
|
|
|
+
|
|
|
+ logger.warning(f"Authentication failed for {auth_type}: invalid credentials from {client_ip}")
|
|
|
return jsonify({'error': 'Invalid credentials'}), 401, {
|
|
|
'WWW-Authenticate': 'Basic realm="Emergency Access"'
|
|
|
}
|
|
|
|
|
|
+ # On success, clear recorded failed attempts for this key
|
|
|
+ with state['lock']:
|
|
|
+ if key in state['attempts']:
|
|
|
+ try:
|
|
|
+ del state['attempts'][key]
|
|
|
+ except KeyError:
|
|
|
+ pass
|
|
|
+ if key in state['blocked']:
|
|
|
+ try:
|
|
|
+ del state['blocked'][key]
|
|
|
+ except KeyError:
|
|
|
+ pass
|
|
|
+
|
|
|
logger.info(f"Authentication successful for {auth_type}")
|
|
|
return f(*args, **kwargs)
|
|
|
return decorated_function
|
|
|
@@ -258,13 +392,21 @@ def create_key_handler(key_config):
|
|
|
}), 500
|
|
|
|
|
|
logger.warning(f"EMERGENCY: Key part successfully retrieved and sent for key '{key_config.key_id}'")
|
|
|
- return jsonify({
|
|
|
+ resp = jsonify({
|
|
|
'success': True,
|
|
|
'key_id': key_config.key_id,
|
|
|
'key_part': content,
|
|
|
'timestamp': time.time(),
|
|
|
'notified_backends': successful_backends
|
|
|
})
|
|
|
+ # Ensure responses with secret material are never cached by clients or intermediaries
|
|
|
+ resp.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, private'
|
|
|
+ resp.headers['Pragma'] = 'no-cache'
|
|
|
+ resp.headers['Expires'] = '0'
|
|
|
+ # Additional defensive headers
|
|
|
+ resp.headers['X-Content-Type-Options'] = 'nosniff'
|
|
|
+ resp.headers['Content-Security-Policy'] = "default-src 'none'; frame-ancestors 'none'; sandbox"
|
|
|
+ return resp
|
|
|
|
|
|
except Exception as e:
|
|
|
logger.error(f"CRITICAL: Unexpected error in key access for '{key_config.key_id}': {str(e)}")
|
|
|
@@ -538,14 +680,19 @@ def main():
|
|
|
channel_timeout=120
|
|
|
)
|
|
|
except ImportError:
|
|
|
- logger.warning("Waitress not available, falling back to Flask dev server")
|
|
|
- app.run(
|
|
|
- host=config.server_host,
|
|
|
- port=config.server_port,
|
|
|
- debug=False,
|
|
|
- threaded=True,
|
|
|
- request_handler=ProductionRequestHandler
|
|
|
- )
|
|
|
+ logger.critical("Waitress WSGI server not installed. This application must run under a production WSGI server such as 'waitress' or 'gunicorn'. Aborting startup.")
|
|
|
+ # Attempt to notify configured health backends about startup failure (best-effort)
|
|
|
+ try:
|
|
|
+ if config and hasattr(config, 'ntfy_backends_health'):
|
|
|
+ send_ntfy_notification(
|
|
|
+ config.ntfy_backends_health,
|
|
|
+ "🚨 Emergency Access server failed to start: waitress not installed",
|
|
|
+ "Emergency Access CRITICAL"
|
|
|
+ )
|
|
|
+ except Exception:
|
|
|
+ # Do not raise further - we are already aborting startup
|
|
|
+ pass
|
|
|
+ sys.exit(1)
|
|
|
|
|
|
except KeyboardInterrupt:
|
|
|
logger.info("Received keyboard interrupt, shutting down gracefully")
|