Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions backend/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()]
Expand Down
70 changes: 60 additions & 10 deletions backend/cache_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -238,18 +250,22 @@ 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}")
else:
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
Expand All @@ -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
Expand All @@ -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}")
Expand All @@ -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}:*"
Expand All @@ -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
Expand Down Expand Up @@ -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'),
Expand All @@ -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)}
Expand All @@ -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
Expand Down
92 changes: 77 additions & 15 deletions backend/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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'),
Expand Down Expand Up @@ -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'),
Expand Down Expand Up @@ -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'),
Expand Down Expand Up @@ -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'),
Expand Down Expand Up @@ -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'),
Expand Down Expand Up @@ -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'),
Expand Down Expand Up @@ -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'),
Expand Down Expand Up @@ -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 {
Expand Down
Binary file modified backend/requirements.txt
Binary file not shown.
Loading
Loading