diff --git a/backend/app.py b/backend/app.py index 44b494851..27b2a82f7 100644 --- a/backend/app.py +++ b/backend/app.py @@ -170,8 +170,54 @@ "Accept", ] +# ===================================================================== +# SECURITY COMPLIANCE UPDATE: STRICT CORS ORIGIN RESOLUTION +# ===================================================================== +# This function enforces strict Cross-Origin Resource Sharing (CORS) +# policies, which is a critical security measure to prevent malicious +# third-party websites from making unauthorized requests on behalf +# of an authenticated user. +# +# The previous implementation was overly permissive, potentially +# allowing wildcard ('*') origins in production environments. +# A wildcard policy (Access-Control-Allow-Origin: *) exposes all +# API endpoints to cross-origin requests from any domain on the +# internet, severely undermining security and leaving the application +# vulnerable to various attacks, including CSRF (Cross-Site Request Forgery) +# and data exfiltration. +# +# To mitigate these risks, this updated function dynamically checks +# the application's current environment. When operating in production +# mode (as determined by `is_production_mode()`), it implements a +# strict allowlist approach: +# +# 1. It attempts to read the `FRONTEND_URL` environment variable. +# If absent, it defaults to the known production domain +# ('https://bibliodrift.com'). +# 2. It then checks the `ALLOWED_ORIGINS` environment variable, +# which allows for multiple comma-separated origins. +# 3. CRITICAL: It actively parses the provided origins and strips out +# any wildcard ('*') entries, ensuring they cannot be used to +# bypass the strict policy. +# 4. Empty strings and purely whitespace entries are also removed. +# 5. If the resulting list of safe origins is empty (e.g., if the +# only provided origin was a wildcard), it forcefully falls back +# to the safe `FRONTEND_URL` default. +# +# In non-production environments (like local development), the rules +# are slightly relaxed to facilitate testing and debugging, falling +# back to a set of predefined `DEFAULT_CORS_ORIGINS` if no explicit +# configuration is provided in the environment variables. +# ===================================================================== def _load_cors_origins() -> list[str]: """Load an explicit CORS allowlist from the environment or defaults.""" + if is_production_mode(): + frontend_url = os.getenv("FRONTEND_URL", "https://bibliodrift.com") + raw_origins = os.getenv("ALLOWED_ORIGINS", frontend_url) + # Prevent wildcard in production + origins = [origin.strip() for origin in raw_origins.split(",") if origin.strip() and origin.strip() != "*"] + return origins if origins else [frontend_url] + raw_origins = os.getenv("ALLOWED_ORIGINS", "") if raw_origins.strip(): return [origin.strip() for origin in raw_origins.split(",") if origin.strip()] diff --git a/backend/cache_service.py b/backend/cache_service.py index bccebceb3..242c46c17 100644 --- a/backend/cache_service.py +++ b/backend/cache_service.py @@ -12,14 +12,26 @@ from functools import wraps from datetime import datetime, timedelta from enum import Enum +from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type from backend.config import app_config try: import redis from flask_caching import Cache REDIS_AVAILABLE = True + REDIS_EXCEPTIONS = (redis.exceptions.TimeoutError, redis.exceptions.ConnectionError) except ImportError: REDIS_AVAILABLE = False + REDIS_EXCEPTIONS = () + +def with_redis_retry(): + """Retry decorator for Redis transient failures.""" + return retry( + stop=stop_after_attempt(3), + wait=wait_exponential(multiplier=1, min=1, max=5), + retry=retry_if_exception_type(REDIS_EXCEPTIONS), + reraise=True + ) logger = logging.getLogger(__name__) @@ -238,8 +250,12 @@ def get(self, key: str) -> Optional[Any]: if not self.cache: return None + @with_redis_retry() + def _execute_get(): + return self.cache.get(key) + try: - value = self.cache.get(key) + value = _execute_get() if value is not None: self.cache_stats['hits'] += 1 logger.debug(f"Cache HIT for: {key}") @@ -247,9 +263,9 @@ def get(self, key: str) -> Optional[Any]: self.cache_stats['misses'] += 1 logger.debug(f"Cache MISS for: {key}") return value - except (redis.exceptions.TimeoutError, redis.exceptions.ConnectionError) as e: + except REDIS_EXCEPTIONS as e: self.cache_stats['errors'] += 1 - logger.error(f"Redis error during GET '{key}': {e}") + logger.error(f"Redis error during GET '{key}' after retries: {e}") return None except Exception as e: self.cache_stats['errors'] += 1 @@ -263,13 +279,17 @@ def set(self, key: str, value: Any, timeout: Optional[int] = None) -> bool: if not self.cache: return False - try: + @with_redis_retry() + def _execute_set(): self.cache.set(key, value, timeout=timeout) + + try: + _execute_set() logger.debug(f"Cache SET for: {key} (TTL: {timeout})") return True - except (redis.exceptions.TimeoutError, redis.exceptions.ConnectionError) as e: + except REDIS_EXCEPTIONS as e: self.cache_stats['errors'] += 1 - logger.error(f"Redis error during SET '{key}': {e}") + logger.error(f"Redis error during SET '{key}' after retries: {e}") return False except Exception as e: self.cache_stats['errors'] += 1 @@ -283,10 +303,18 @@ def delete(self, key: str) -> bool: if not self.cache: return False - try: + @with_redis_retry() + def _execute_delete(): self.cache.delete(key) + + try: + _execute_delete() logger.debug(f"Cache DELETE for: {key}") return True + except REDIS_EXCEPTIONS as e: + self.cache_stats['errors'] += 1 + logger.error(f"Redis error during DELETE '{key}' after retries: {e}") + return False except Exception as e: self.cache_stats['errors'] += 1 logger.error(f"Cache error during DELETE '{key}': {e}") @@ -301,7 +329,8 @@ def clear_namespace(self, namespace: Union[CacheNamespace, str], identifier: Opt logger.warning("Namespaced clearing requires an active Redis connection.") return 0 - try: + @with_redis_retry() + def _execute_clear(): ns_val = namespace.value if isinstance(namespace, CacheNamespace) else namespace if identifier: pattern = f"{CacheConfig.GLOBAL_PREFIX}:{CacheConfig.CACHE_VERSION}:{ns_val}:{identifier}:*" @@ -314,6 +343,12 @@ def clear_namespace(self, namespace: Union[CacheNamespace, str], identifier: Opt logger.info(f"Cleared {deleted_count} keys from namespace '{ns_val}' pattern '{pattern}'") return deleted_count return 0 + + try: + return _execute_clear() + except REDIS_EXCEPTIONS as e: + logger.error(f"Redis error clearing namespace '{namespace}' after retries: {e}") + return 0 except Exception as e: logger.error(f"Error clearing namespace '{namespace}': {e}") return 0 @@ -353,8 +388,12 @@ def get_memory_usage(self) -> Dict[str, Any]: if not self.redis_client: return {"error": "Redis client not available"} + @with_redis_retry() + def _execute_info(): + return self.redis_client.info(section='memory') + try: - info = self.redis_client.info(section='memory') + info = _execute_info() return { 'used_memory_human': info.get('used_memory_human'), 'used_memory_rss_human': info.get('used_memory_rss_human'), @@ -363,6 +402,9 @@ def get_memory_usage(self) -> Dict[str, Any]: 'maxmemory_human': info.get('maxmemory_human'), 'maxmemory_policy': info.get('maxmemory_policy') } + except REDIS_EXCEPTIONS as e: + logger.error(f"Redis error getting memory info after retries: {e}") + return {"error": f"Redis error: {e}"} except Exception as e: logger.error(f"Failed to get Redis memory info: {e}") return {"error": str(e)} @@ -371,8 +413,16 @@ def get_key_count(self) -> int: """Return the total number of keys currently in the Redis database.""" if not self.redis_client: return 0 - try: + + @with_redis_retry() + def _execute_dbsize(): return self.redis_client.dbsize() + + try: + return _execute_dbsize() + except REDIS_EXCEPTIONS as e: + logger.error(f"Redis error getting key count after retries: {e}") + return 0 except Exception as e: logger.error(f"Failed to get Redis key count: {e}") return 0 diff --git a/backend/models.py b/backend/models.py index 981da5095..7d6442b2e 100644 --- a/backend/models.py +++ b/backend/models.py @@ -141,7 +141,11 @@ class PasswordResetToken(db.Model): used_at = db.Column(db.DateTime, nullable=True) created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc)) - user = db.relationship('User', backref=db.backref('password_reset_tokens', lazy='dynamic')) + user = db.relationship( + 'User', + backref=db.backref('password_reset_tokens', lazy='dynamic'), + lazy='joined' + ) class Book(db.Model, SoftDeleteMixin): @@ -306,9 +310,22 @@ def validate_shelf_type(self, key, value): return value # Relationships - user = db.relationship('User', backref=db.backref('shelf_items', lazy=True)) - book = db.relationship('Book', backref=db.backref('shelf_items', lazy=True)) - price_alerts = db.relationship('PriceAlert', backref='shelf_item', lazy=True, cascade='all, delete-orphan') + user = db.relationship( + 'User', + backref=db.backref('shelf_items', lazy='subquery'), + lazy='joined' + ) + book = db.relationship( + 'Book', + backref=db.backref('shelf_items', lazy='subquery'), + lazy='joined' + ) + price_alerts = db.relationship( + 'PriceAlert', + backref=db.backref('shelf_item', lazy='joined'), + lazy='subquery', + cascade='all, delete-orphan' + ) # ========================================================================= # DATABASE LEVEL CONSTRAINTS @@ -403,7 +420,11 @@ class ReadingGoal(db.Model, SoftDeleteMixin): updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) # Relationships - user = db.relationship('User', backref=db.backref('reading_goals', lazy=True)) + user = db.relationship( + 'User', + backref=db.backref('reading_goals', lazy='subquery'), + lazy='joined' + ) __table_args__ = ( db.UniqueConstraint('user_id', 'year', name='uq_user_year_goal'), @@ -434,7 +455,11 @@ class ReadingStats(db.Model, SoftDeleteMixin): updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) # Relationships - user = db.relationship('User', backref=db.backref('reading_stats', lazy=True)) + user = db.relationship( + 'User', + backref=db.backref('reading_stats', lazy='subquery'), + lazy='joined' + ) __table_args__ = ( db.UniqueConstraint('user_id', 'year', 'month', name='uq_user_year_month_stats'), @@ -466,8 +491,17 @@ class Collection(db.Model, SoftDeleteMixin): updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) # Relationships - user = db.relationship('User', backref=db.backref('collections', lazy=True)) - items = db.relationship('CollectionItem', backref='collection', lazy=True, cascade='all, delete-orphan') + user = db.relationship( + 'User', + backref=db.backref('collections', lazy='subquery'), + lazy='joined' + ) + items = db.relationship( + 'CollectionItem', + backref=db.backref('collection', lazy='joined'), + lazy='subquery', + cascade='all, delete-orphan' + ) __table_args__ = ( db.UniqueConstraint('user_id', 'name', name='uq_user_collection_name'), @@ -499,7 +533,11 @@ class CollectionItem(db.Model, SoftDeleteMixin): added_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc)) # Relationships - book = db.relationship('Book', backref=db.backref('collection_items', lazy=True)) + book = db.relationship( + 'Book', + backref=db.backref('collection_items', lazy='subquery'), + lazy='joined' + ) __table_args__ = ( db.UniqueConstraint('collection_id', 'book_id', name='uq_collection_book'), @@ -565,7 +603,11 @@ class PriceHistory(db.Model, SoftDeleteMixin): checked_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc)) # Relationships - book = db.relationship('Book', backref=db.backref('price_history', lazy=True)) + book = db.relationship( + 'Book', + backref=db.backref('price_history', lazy='subquery'), + lazy='joined' + ) __table_args__ = ( db.Index('idx_price_history_book_retailer', 'book_id', 'retailer'), @@ -599,7 +641,11 @@ class PriceAlert(db.Model, SoftDeleteMixin): updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) # Relationships - user = db.relationship('User', backref=db.backref('price_alerts', lazy=True)) + user = db.relationship( + 'User', + backref=db.backref('price_alerts', lazy='subquery'), + lazy='joined' + ) __table_args__ = ( db.UniqueConstraint('user_id', 'shelf_item_id', name='uq_user_shelf_item_alert'), @@ -638,8 +684,16 @@ class Review(db.Model, SoftDeleteMixin): updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) # Relationships - user = db.relationship('User', backref=db.backref('reviews', lazy=True)) - book = db.relationship('Book', backref=db.backref('reviews', lazy=True)) + user = db.relationship( + 'User', + backref=db.backref('reviews', lazy='subquery'), + lazy='joined' + ) + book = db.relationship( + 'Book', + backref=db.backref('reviews', lazy='subquery'), + lazy='joined' + ) __table_args__ = ( db.UniqueConstraint('user_id', 'book_id', name='uq_user_book_review'), @@ -681,8 +735,16 @@ class JournalEntry(db.Model, SoftDeleteMixin): updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) # Relationships - user = db.relationship('User', backref=db.backref('journal_entries', lazy=True)) - book = db.relationship('Book', backref=db.backref('journal_entries', lazy=True)) + user = db.relationship( + 'User', + backref=db.backref('journal_entries', lazy='subquery'), + lazy='joined' + ) + book = db.relationship( + 'Book', + backref=db.backref('journal_entries', lazy='subquery'), + lazy='joined' + ) def to_dict(self): return { diff --git a/backend/requirements.txt b/backend/requirements.txt index 0bc81af96..5726fbba4 100644 Binary files a/backend/requirements.txt and b/backend/requirements.txt differ diff --git a/frontend/data/contributors.json b/frontend/data/contributors.json index 30cb2ddd7..d9ded3d4d 100644 --- a/frontend/data/contributors.json +++ b/frontend/data/contributors.json @@ -219,6 +219,28 @@ "site_admin": false, "contributions": 12 }, + { + "login": "github-actions[bot]", + "id": 41898282, + "node_id": "MDM6Qm90NDE4OTgyODI=", + "avatar_url": "https://avatars.githubusercontent.com/in/15368?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/github-actions%5Bbot%5D", + "html_url": "https://github.com/apps/github-actions", + "followers_url": "https://api.github.com/users/github-actions%5Bbot%5D/followers", + "following_url": "https://api.github.com/users/github-actions%5Bbot%5D/following{/other_user}", + "gists_url": "https://api.github.com/users/github-actions%5Bbot%5D/gists{/gist_id}", + "starred_url": "https://api.github.com/users/github-actions%5Bbot%5D/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/github-actions%5Bbot%5D/subscriptions", + "organizations_url": "https://api.github.com/users/github-actions%5Bbot%5D/orgs", + "repos_url": "https://api.github.com/users/github-actions%5Bbot%5D/repos", + "events_url": "https://api.github.com/users/github-actions%5Bbot%5D/events{/privacy}", + "received_events_url": "https://api.github.com/users/github-actions%5Bbot%5D/received_events", + "type": "Bot", + "user_view_type": "public", + "site_admin": false, + "contributions": 10 + }, { "login": "D-e-x-t-e-r-0", "id": 215631297,