"""Configuration management for django-honeyguard."""
import logging
from typing import Any, Callable, Dict, List, Tuple
from django.conf import settings as dj_settings
from django.core.exceptions import ImproperlyConfigured
from django.core.signals import setting_changed
logger = logging.getLogger(__name__)
# Type definitions for validators
ValidatorFunc = Callable[[Any, str], Tuple[Any, str]]
# Valid log levels
VALID_LOG_LEVELS = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
DEFAULTS: Dict[str, Any] = {
# Email alerts configuration
"EMAIL_RECIPIENTS": [],
"EMAIL_SUBJECT_PREFIX": "🚨 Honeypot Alert",
"EMAIL_FROM": None, # Uses Django's DEFAULT_FROM_EMAIL if None
"EMAIL_FAIL_SILENTLY": True, # Don't raise exceptions on email send failure
# Timing detection thresholds (in seconds)
"TIMING_TOO_FAST_THRESHOLD": 2.0,
"TIMING_TOO_SLOW_THRESHOLD": 600.0, # 10 minutes
# Logging configuration
"ENABLE_CONSOLE_LOGGING": True,
"LOG_LEVEL": "WARNING",
# Honeypot behavior
"ENABLE_GET_METHOD_DETECTION": False,
# Security features
"MAX_USERNAME_LENGTH": 150, # Django default
"MAX_PASSWORD_LENGTH": 128, # Django default
# WordPress-specific settings
"WORDPRESS_USERNAME_MAX_LENGTH": 60,
"WORDPRESS_PASSWORD_MAX_LENGTH": 255,
# Error messages
"DJANGO_ERROR_MESSAGE": (
"Please enter a correct username and password. "
"Note that both fields may be case-sensitive."
),
"WORDPRESS_ERROR_MESSAGE": (
"<strong>Error:</strong> The password you entered for the username is incorrect."
),
}
[docs]
def validate_email_recipients(
value: Any, setting_name: str
) -> Tuple[List[str], str]:
"""
Validate EMAIL_RECIPIENTS setting.
Args:
value: Setting value to validate
setting_name: Name of the setting
Returns:
tuple: (validated_value, error_message)
Raises:
ImproperlyConfigured: If value is invalid and cannot be fixed
"""
if value is None:
return [], ""
if not isinstance(value, (list, tuple)):
raise ImproperlyConfigured(
f"{setting_name} must be a list or tuple of email addresses, "
f"got {type(value).__name__}: {value}"
)
validated = []
for email in value:
if not isinstance(email, str):
raise ImproperlyConfigured(
f"{setting_name} must contain only strings (email addresses), "
f"got {type(email).__name__}: {email}"
)
if "@" not in email: # Basic email validation
logger.warning(
f"{setting_name} contains potentially invalid email: {email}"
)
validated.append(email)
return validated, ""
[docs]
def validate_positive_number(
value: Any, setting_name: str, min_value: float = 0.0
) -> Tuple[float, str]:
"""
Validate a positive number setting.
Args:
value: Setting value to validate
setting_name: Name of the setting
min_value: Minimum allowed value
Returns:
tuple: (validated_value, error_message)
Raises:
ImproperlyConfigured: If value is invalid
"""
try:
num_value = float(value)
except (TypeError, ValueError):
raise ImproperlyConfigured(
f"{setting_name} must be a number, got {type(value).__name__}: {value}"
)
if num_value < min_value:
raise ImproperlyConfigured(
f"{setting_name} must be >= {min_value}, got {num_value}"
)
return num_value, ""
[docs]
def validate_positive_integer(
value: Any, setting_name: str, min_value: int = 1
) -> Tuple[int, str]:
"""
Validate a positive integer setting.
Args:
value: Setting value to validate
setting_name: Name of the setting
min_value: Minimum allowed value
Returns:
tuple: (validated_value, error_message)
Raises:
ImproperlyConfigured: If value is invalid
"""
try:
int_value = int(value)
except (TypeError, ValueError):
raise ImproperlyConfigured(
f"{setting_name} must be an integer, got {type(value).__name__}: {value}"
)
if int_value < min_value:
raise ImproperlyConfigured(
f"{setting_name} must be >= {min_value}, got {int_value}"
)
return int_value, ""
[docs]
def validate_boolean(value: Any, setting_name: str) -> Tuple[bool, str]:
"""
Validate a boolean setting.
Args:
value: Setting value to validate
setting_name: Name of the setting
Returns:
tuple: (validated_value, error_message)
"""
if isinstance(value, bool):
return value, ""
if isinstance(value, str):
lower = value.lower()
if lower in ("true", "1", "yes", "on"):
return True, ""
if lower in ("false", "0", "no", "off"):
return False, ""
# Try to convert, but warn
try:
bool_value = bool(value)
logger.warning(
f"{setting_name} should be a boolean, got {type(value).__name__}: {value}. "
f"Converted to {bool_value}."
)
return bool_value, ""
except Exception:
raise ImproperlyConfigured(
f"{setting_name} must be a boolean, got {type(value).__name__}: {value}"
)
[docs]
def validate_log_level(value: Any, setting_name: str) -> Tuple[str, str]:
"""
Validate LOG_LEVEL setting.
Args:
value: Setting value to validate
setting_name: Name of the setting
Returns:
tuple: (validated_value, error_message)
Raises:
ImproperlyConfigured: If value is invalid
"""
if not isinstance(value, str):
raise ImproperlyConfigured(
f"{setting_name} must be a string, got {type(value).__name__}: {value}"
)
upper_value = value.upper()
if upper_value not in VALID_LOG_LEVELS:
raise ImproperlyConfigured(
f"{setting_name} must be one of {VALID_LOG_LEVELS}, got: {value}"
)
return upper_value, ""
[docs]
def validate_string(value: Any, setting_name: str) -> Tuple[str, str]:
"""
Validate a string setting.
Args:
value: Setting value to validate
setting_name: Name of the setting
Returns:
tuple: (validated_value, error_message)
"""
if not isinstance(value, str):
logger.warning(
f"{setting_name} should be a string, got {type(value).__name__}. "
f"Converting to string."
)
return str(value), ""
return value, ""
[docs]
def validate_optional_string(value: Any, setting_name: str) -> Tuple[Any, str]:
"""
Validate an optional string setting (can be None or string).
Args:
value: Setting value to validate
setting_name: Name of the setting
Returns:
tuple: (validated_value, error_message)
"""
if value is None:
return None, ""
if not isinstance(value, str):
logger.warning(
f"{setting_name} should be None or a string, got {type(value).__name__}. "
f"Converting to string."
)
return str(value), ""
return value, ""
# Wrapper functions for validators that need additional parameters
[docs]
def validate_timing_fast(value: Any, setting_name: str) -> Tuple[float, str]:
"""Validate TIMING_TOO_FAST_THRESHOLD."""
return validate_positive_number(value, setting_name, min_value=0.1)
[docs]
def validate_timing_slow(value: Any, setting_name: str) -> Tuple[float, str]:
"""Validate TIMING_TOO_SLOW_THRESHOLD."""
return validate_positive_number(value, setting_name, min_value=1.0)
[docs]
def validate_username_length(value: Any, setting_name: str) -> Tuple[int, str]:
"""Validate username length settings."""
return validate_positive_integer(value, setting_name, min_value=1)
# Validators for each setting
VALIDATORS: Dict[str, ValidatorFunc] = {
"EMAIL_RECIPIENTS": validate_email_recipients,
"EMAIL_SUBJECT_PREFIX": validate_string,
"EMAIL_FROM": validate_optional_string,
"EMAIL_FAIL_SILENTLY": validate_boolean,
"TIMING_TOO_FAST_THRESHOLD": validate_timing_fast,
"TIMING_TOO_SLOW_THRESHOLD": validate_timing_slow,
"ENABLE_CONSOLE_LOGGING": validate_boolean,
"LOG_LEVEL": validate_log_level,
"ENABLE_GET_METHOD_DETECTION": validate_boolean,
"MAX_USERNAME_LENGTH": validate_username_length,
"MAX_PASSWORD_LENGTH": validate_username_length,
"WORDPRESS_USERNAME_MAX_LENGTH": validate_username_length,
"WORDPRESS_PASSWORD_MAX_LENGTH": validate_username_length,
"DJANGO_ERROR_MESSAGE": validate_string,
"WORDPRESS_ERROR_MESSAGE": validate_string,
}
def _is_callable_not_type(value: Any) -> bool:
"""
Check if value is callable but not a type/class.
Args:
value: Value to check
Returns:
bool: True if callable and not a type
"""
return callable(value) and not isinstance(value, type)
[docs]
class Settings:
"""
Settings management for django-honeyguard.
Allows settings to be configured either through:
1. A HONEYGUARD dictionary in Django settings
2. Individual HONEYGUARD_* settings
Settings are lazily loaded and cached for performance.
"""
[docs]
def __getattr__(self, name: str) -> Any:
"""
Get a setting value by name.
Args:
name: Setting name (without HONEYGUARD_ prefix)
Returns:
Setting value from Django settings or default
Raises:
AttributeError: If setting name is not valid
"""
if name not in DEFAULTS:
raise AttributeError(
f"'{self.__class__.__name__}' object has no attribute '{name}'"
)
value = self._get_setting(name)
# Execute callables (except types)
if _is_callable_not_type(value):
value = value()
# Cache the result
setattr(self, name, value)
return value
def _get_setting(self, setting: str, validate: bool = True) -> Any:
"""
Get setting value from Django settings or defaults.
Priority order:
1. HONEYGUARD dictionary setting
2. Individual HONEYGUARD_* setting
3. Default value
Args:
setting: Setting name (without HONEYGUARD_ prefix)
validate: Whether to validate the setting value
Returns:
Setting value (validated if validate=True)
Raises:
ImproperlyConfigured: If validation fails
"""
# Check for dictionary-style settings
honeyguard_config = getattr(dj_settings, "HONEYGUARD", {})
if setting in honeyguard_config:
value = honeyguard_config[setting]
else:
# Check for individual HONEYGUARD_* settings
django_setting = f"HONEYGUARD_{setting}"
value = getattr(dj_settings, django_setting, DEFAULTS[setting])
# Validate the value if validator exists
if validate and setting in VALIDATORS:
validator = VALIDATORS[setting]
setting_full_name = (
f"HONEYGUARD['{setting}'] or HONEYGUARD_{setting}"
)
try:
validated_value, _ = validator(value, setting_full_name)
return validated_value
except ImproperlyConfigured:
# Re-raise configuration errors as-is
raise
except Exception as e:
# Wrap unexpected errors
raise ImproperlyConfigured(
f"Error validating {setting_full_name}: {e}"
) from e
return value
[docs]
def change_setting(
self, setting: str, value: Any, enter: bool, **kwargs: Any
) -> None:
"""
Handle Django setting changes via setting_changed signal.
Args:
setting: Django setting name that changed
value: New value of the setting
enter: True if setting is being added/changed, False if removed
**kwargs: Additional signal arguments
"""
# Handle HONEYGUARD dictionary setting
if setting == "HONEYGUARD":
self._handle_dict_setting_change(value, enter)
return
# Handle individual HONEYGUARD_* settings
if not setting.startswith("HONEYGUARD_"):
return
setting_name = setting[11:] # Strip 'HONEYGUARD_' prefix
# Ensure valid app setting
if setting_name not in DEFAULTS:
return
# Update or clear cached value
if enter:
setattr(self, setting_name, value)
else:
if hasattr(self, setting_name):
delattr(self, setting_name)
def _handle_dict_setting_change(self, value: Any, enter: bool) -> None:
"""
Handle changes to the HONEYGUARD dictionary setting.
Args:
value: New dictionary value
enter: True if setting is being added/changed, False if removed
"""
if enter and isinstance(value, dict):
# Update all settings from dictionary
for key, val in value.items():
if key in DEFAULTS:
setattr(self, key, val)
else:
# Clear all cached values
for key in DEFAULTS:
if hasattr(self, key):
delattr(self, key)
[docs]
def reset(self) -> None:
"""Reset all cached settings to force reload from Django settings."""
for key in DEFAULTS:
if hasattr(self, key):
delattr(self, key)
# Create global settings instance
settings = Settings()
# Connect to Django's setting_changed signal
setting_changed.connect(settings.change_setting)