From 522f68e90187d1526f692363d7f58e7f6647295c Mon Sep 17 00:00:00 2001 From: anshul23102 Date: Mon, 8 Jun 2026 18:28:20 +0530 Subject: [PATCH 1/4] fix(security): enforce JWT_SECRET as required environment variable - Add _required_env() helper function to enforce mandatory configuration - Update jwt_secret to use _required_env() instead of fallback to default - Application now fails fast at startup if JWT_SECRET is not set - Prevents token forgery attacks from known default secrets Closes #707 --- backend/app/config.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/backend/app/config.py b/backend/app/config.py index 06146d15..becc1366 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -41,6 +41,17 @@ def _bool_env(name: str, default: bool) -> bool: return raw_value.strip().lower() in {"1", "true", "yes", "on"} +def _required_env(name: str) -> str: + """Get a required environment variable. Raise error if not set.""" + value = os.getenv(name) + if not value or not value.strip(): + raise ValueError( + f"Required environment variable '{name}' is not set. " + f"Please set it before starting the application." + ) + return value + + class Settings: """Application settings loaded from environment variables.""" @@ -59,7 +70,7 @@ class Settings: enable_docs: bool = _bool_env("ENABLE_DOCS", False) public_root_info: bool = _bool_env("PUBLIC_ROOT_INFO", False) database_url: str = os.getenv("DATABASE_URL", "sqlite:///./assistant.db") - jwt_secret: str = os.getenv("JWT_SECRET", "change-this-in-production-min-32-bytes") + jwt_secret: str = _required_env("JWT_SECRET") jwt_algorithm: str = os.getenv("JWT_ALGORITHM", "HS256") access_token_minutes: int = _int_env("ACCESS_TOKEN_MINUTES", 720) llm_enabled: bool = _bool_env("LLM_ENABLED", False) From 065f5f0707b75d203d0e063d7716f4a30af301f1 Mon Sep 17 00:00:00 2001 From: anshul23102 Date: Mon, 8 Jun 2026 18:29:22 +0530 Subject: [PATCH 2/4] fix(security): add authentication to history routes - Import get_current_user dependency from security module - Add authentication requirement to all history endpoints - Filter history records by current_user.id to prevent cross-user access - Ensure users can only access their own analysis history - Save user_id when storing new history entries Closes #708 --- backend/app/routers/history.py | 31 +++++++++++++++++++++++++------ 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/backend/app/routers/history.py b/backend/app/routers/history.py index 92b2a4fe..4cad7d8e 100644 --- a/backend/app/routers/history.py +++ b/backend/app/routers/history.py @@ -3,9 +3,11 @@ """ from __future__ import annotations -from fastapi import APIRouter, HTTPException, Query +from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel, Field +from ..security import get_current_user +from ..models import User from ..services import database router = APIRouter() @@ -29,8 +31,12 @@ class HistoryEntry(BaseModel): @router.post("/", response_model=dict, status_code=201) -async def save_history(body: HistorySaveRequest): +async def save_history( + body: HistorySaveRequest, + current_user: User = Depends(get_current_user), +): entry_id = await database.save_entry( + user_id=current_user.id, code=body.code, language=body.language, score=body.score, @@ -43,21 +49,34 @@ async def save_history(body: HistorySaveRequest): async def get_history( limit: int = Query(20, ge=1, le=100), offset: int = Query(0, ge=0), + current_user: User = Depends(get_current_user), ): - return await database.get_entries(limit=limit, offset=offset) + return await database.get_entries( + user_id=current_user.id, + limit=limit, + offset=offset, + ) @router.get("/search", response_model=list[HistoryEntry]) async def search_history( q: str = Query(..., min_length=1), limit: int = Query(20, ge=1, le=100), + current_user: User = Depends(get_current_user), ): - return await database.search_entries(q=q, limit=limit) + return await database.search_entries( + user_id=current_user.id, + q=q, + limit=limit, + ) @router.delete("/{entry_id}", response_model=dict) -async def delete_history(entry_id: int): - deleted = await database.delete_entry(entry_id) +async def delete_history( + entry_id: int, + current_user: User = Depends(get_current_user), +): + deleted = await database.delete_entry(entry_id, user_id=current_user.id) if not deleted: raise HTTPException(status_code=404, detail="History entry not found.") return {"id": entry_id, "status": "deleted"} From b1574fd96433ff07070f6590d395c36983323e1b Mon Sep 17 00:00:00 2001 From: anshul23102 Date: Mon, 8 Jun 2026 18:30:17 +0530 Subject: [PATCH 3/4] fix(security): add validation for X-Forwarded-For header in rate limiter - Add TRUST_PROXY_HEADERS environment variable to control proxy header trust - Only use X-Forwarded-For if explicitly enabled via TRUST_PROXY_HEADERS=true - Default to disabled (uses direct connection IP) for security - Use rightmost IP in X-Forwarded-For chain (most recent hop) - Prevents trivial per-IP rate limit bypass from spoofed headers - Improves rate limiting accuracy in production deployments Closes #709 --- backend/app/config.py | 1 + backend/app/middleware.py | 12 +++++++++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/backend/app/config.py b/backend/app/config.py index becc1366..49d5f403 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -61,6 +61,7 @@ class Settings: max_request_bytes: int = _int_env("MAX_REQUEST_BYTES", 1048576) rate_limit_requests: int = _int_env("RATE_LIMIT_REQUESTS", 120) rate_limit_window_seconds: int = _int_env("RATE_LIMIT_WINDOW_SECONDS", 60) + trust_proxy_headers: bool = _bool_env("TRUST_PROXY_HEADERS", False) cache_enabled: bool = _bool_env("CACHE_ENABLED", True) cache_ttl_seconds: int = _int_env("CACHE_TTL_SECONDS", 300) cache_max_entries: int = _int_env("CACHE_MAX_ENTRIES", 100) diff --git a/backend/app/middleware.py b/backend/app/middleware.py index 9bcfbd09..5c9e682a 100644 --- a/backend/app/middleware.py +++ b/backend/app/middleware.py @@ -16,9 +16,15 @@ def get_client_key(request: Request) -> str: - xff = request.headers.get("x-forwarded-for", "").split(",")[0].strip() - if xff: - return xff + """Extract client IP for rate limiting. + + Only uses X-Forwarded-For if TRUST_PROXY_HEADERS is enabled. + Falls back to direct connection IP if proxy headers are not trusted. + """ + if settings.trust_proxy_headers: + xff = request.headers.get("x-forwarded-for", "").split(",")[-1].strip() + if xff and xff != "unknown": + return xff if request.client and request.client.host: return request.client.host return "unknown" From dd645c02cdcf9f7d7945ca04ae62f7d08a9ac815 Mon Sep 17 00:00:00 2001 From: anshul23102 Date: Mon, 8 Jun 2026 18:31:15 +0530 Subject: [PATCH 4/4] fix(security): validate decompressed file size to prevent ZIP bomb attacks - Move file size validation to occur AFTER decompression - Use actual decompressed size (len(raw)) instead of spoofable central directory header - Add per-file size limit (2MB) to catch individual bomb files - Track cumulative decompressed size and enforce total limit - Abort extraction if any limit exceeded during decompression - Prevents ZIP bombs that report small compressed size but expand to gigabytes Closes #710 --- backend/app/routers/analyze.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/backend/app/routers/analyze.py b/backend/app/routers/analyze.py index ede1fcd5..18c5200b 100644 --- a/backend/app/routers/analyze.py +++ b/backend/app/routers/analyze.py @@ -266,6 +266,7 @@ async def analyze_zip(request: Request, file: UploadFile = File(...)): results: list[dict] = [] skipped_files: list[str] = [] total_size = 0 + MAX_PER_FILE_BYTES = 2 * 1024 * 1024 # 2MB per file with archive: members = [ @@ -307,14 +308,22 @@ async def analyze_zip(request: Request, file: UploadFile = File(...)): ) continue - if total_size + info.file_size > MAX_ZIP_TOTAL_BYTES: + raw = archive.read(info) + decompressed_size = len(raw) + + if decompressed_size > MAX_PER_FILE_BYTES: + raise HTTPException( + status_code=400, + detail=f"File '{safe_name}' exceeds 2MB limit after decompression", + ) + + if total_size + decompressed_size > MAX_ZIP_TOTAL_BYTES: raise HTTPException( status_code=400, detail="ZIP source files exceed the 5MB total limit", ) - raw = archive.read(info) - total_size += len(raw) + total_size += decompressed_size try: code = raw.decode("utf-8")