From 6498096822c5aa0626cbe1ec4a2bae4fa212a2a7 Mon Sep 17 00:00:00 2001 From: Antigravity Agent Date: Sun, 27 Sep 2026 05:43:42 -0500 Subject: [PATCH] fix(security): resolve CodeQL SSRF, ReDoS, sensitive logging, and weak hashing alerts - py/full-ssrf (#42): validate URL scheme, block restricted metadata hosts, and reconstruct safe endpoints with urlunsplit in litellm_service and settings router - py/polynomial-redos (#43, #44): optimize template literal regex and switch Next.js route path extraction to deterministic component splitting in api_route_extractor - js/incomplete-url-substring-sanitization (#95): parse hostname via new URL and verify domain boundaries in SearchInspector.tsx - py/clear-text-logging-sensitive-data (#1, #2, #3): sanitize logging in key_service by eliminating key prefix strings from log messages - py/weak-sensitive-data-hashing (#41): use PBKDF2-HMAC-SHA256 with salt for API key hashing in key_service - sync test counts in REQUIREMENTS.md --- REQUIREMENTS.md | 5 ++- app/api/routers/settings.py | 16 ++++++- app/services/auth/key_service.py | 13 +++--- app/services/chunking/api_route_extractor.py | 12 +++-- app/services/litellm_service.py | 46 +++++++++++++++++--- frontend/src/SearchInspector.tsx | 14 ++++-- tests/test_litellm_service.py | 13 ++++++ 7 files changed, 96 insertions(+), 23 deletions(-) diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index d480db4..100dcf1 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -2,7 +2,7 @@ > **Note:** This document is automatically generated and verified against the live test suite by `scripts/generate_requirements.py` and `tests/backend/test_requirements_sync.py`. -**Test Verification Baseline:** **968 Automated Tests** (651 Pytest Backend + 271 Vitest Frontend + 46 Playwright E2E). +**Test Verification Baseline:** **969 Automated Tests** (652 Pytest Backend + 271 Vitest Frontend + 46 Playwright E2E). --- @@ -980,7 +980,7 @@ persisting all records and vector points correctly across multiple flushes._ - `test_incremental_pipeline_clone_error_resilience` - _Verifies that a failure during shallow clone records an error in git_repositories and leaves the prior indexed state intact without data loss._ -#### `tests/test_litellm_service.py` (7 tests) +#### `tests/test_litellm_service.py` (8 tests) - `test_discover_models_success` - `test_discover_models_timeout` - `test_discover_models_connect_error` @@ -988,6 +988,7 @@ and leaves the prior indexed state intact without data loss._ - `test_discover_models_http_500_error` - `test_discover_models_url_normalization` - `test_discover_models_default_resolution` +- `test_discover_models_ssrf_rejection` #### `tests/test_local_storage_indexing.py` (4 tests) - `test_incremental_indexing_on_save` diff --git a/app/api/routers/settings.py b/app/api/routers/settings.py index cffa42e..6266be9 100644 --- a/app/api/routers/settings.py +++ b/app/api/routers/settings.py @@ -4,6 +4,7 @@ import sqlite3 import logging from typing import Optional +from urllib.parse import urlsplit from fastapi import APIRouter, Request from fastapi.responses import JSONResponse @@ -326,11 +327,24 @@ def _reindex(): @router.get("/admin/api/models/discover") async def api_discover_models(url: Optional[str] = None, api_key: Optional[str] = None): try: + if url: + parsed = urlsplit(url.strip()) + if parsed.scheme not in ("http", "https"): + return JSONResponse( + status_code=400, + content={"status": "error", "error": "Invalid URL scheme: only http and https are allowed."} + ) + host = (parsed.hostname or "").lower() + if not host or host in ("169.254.169.254", "metadata.google.internal") or host.startswith("169.254."): + return JSONResponse( + status_code=400, + content={"status": "error", "error": "Invalid or restricted target host."} + ) res = await litellm_service.discover_models(url=url, api_key=api_key) return res except Exception as e: logger.error(f"Error discovering models: {e}") - return JSONResponse(status_code=500, content={"status": "error", "error": str(e), "message": str(e)}) + return JSONResponse(status_code=500, content={"status": "error", "error": "Failed to discover models."}) @router.get("/admin/api/settings/embedding") async def api_get_embedding_settings(): diff --git a/app/services/auth/key_service.py b/app/services/auth/key_service.py index 32f1083..3f96181 100644 --- a/app/services/auth/key_service.py +++ b/app/services/auth/key_service.py @@ -41,8 +41,9 @@ def _get_engine(self, engine: Optional[Engine] = None) -> Engine: @staticmethod def hash_key(raw_key: str) -> str: - """Computes deterministic SHA-256 hash of secret key string.""" - return hashlib.sha256(raw_key.encode("utf-8")).hexdigest() + """Computes deterministic PBKDF2-HMAC-SHA256 hash of key string.""" + salt = b"contextcortex_api_key_salt_v1" + return hashlib.pbkdf2_hmac("sha256", raw_key.encode("utf-8"), salt, 50_000).hex() def issue_api_key( self, @@ -94,7 +95,7 @@ def issue_api_key( ).first() inserted_id = row[0] if row else 0 - logger.info(f"Issued new API key '{name}' (id={inserted_id}, prefix={key_prefix}, role={assigned_role.value})") + logger.info(f"Issued new API key '{name}' (id={inserted_id}, role={assigned_role.value})") return ApiKeyOut( id=inserted_id, @@ -305,7 +306,7 @@ def bootstrap_admin_key( group_name="admin", engine=eng, ) - logger.info(f"Auto-bootstrapped initial admin API key (prefix: {key.key_prefix})") + logger.info("Auto-bootstrapped initial admin API key.") return key # Custom explicit secret key specified @@ -319,7 +320,7 @@ def bootstrap_admin_key( ).mappings().fetchone() if row: - logger.info(f"Bootstrap admin key already registered (id={row['id']}, prefix={key_prefix})") + logger.info(f"Bootstrap admin key already registered (id={row['id']})") return ApiKeyOut( id=row["id"], name=row["name"], @@ -355,7 +356,7 @@ def bootstrap_admin_key( ).first() inserted_id = r[0] if r else 0 - logger.info(f"Bootstrapped configured initial admin key (id={inserted_id}, prefix={key_prefix})") + logger.info(f"Bootstrapped configured initial admin key (id={inserted_id})") return ApiKeyOut( id=inserted_id, name=name.strip(), diff --git a/app/services/chunking/api_route_extractor.py b/app/services/chunking/api_route_extractor.py index 74afa3b..6f4ad1c 100644 --- a/app/services/chunking/api_route_extractor.py +++ b/app/services/chunking/api_route_extractor.py @@ -17,7 +17,7 @@ def normalize_path_pattern(path: str) -> str: # Express style :param path = re.sub(r':([a-zA-Z_][a-zA-Z0-9_]*)', r'{\1}', path) # Template literal ${param} - path = re.sub(r'\$\{([^}]+)\}', r'{\1}', path) + path = re.sub(r'\$\{([^{}]+)\}', r'{\1}', path) # Next.js [id] path = re.sub(r'\[([a-zA-Z_][a-zA-Z0-9_]*)\]', r'{\1}', path) @@ -167,8 +167,14 @@ def extract_api_routes_and_calls( if re.search(r'route\.(?:ts|js|tsx|jsx)$', norm_fp): # Infer route path from folder structure # e.g. app/api/users/[id]/route.ts -> /api/users/{id} - app_match = re.search(r'(?:app|pages)(/.*?)/route\.(?:ts|js|tsx|jsx)$', norm_fp) - route_path = normalize_path_pattern(app_match.group(1)) if app_match else "/" + route_base = re.sub(r'/route\.(?:ts|js|tsx|jsx)$', '', norm_fp) + parts = route_base.split('/') + raw_route_path = "/" + for idx, part in enumerate(parts): + if part in ("app", "pages") and idx + 1 < len(parts): + raw_route_path = "/" + "/".join(parts[idx + 1:]) + break + route_path = normalize_path_pattern(raw_route_path) for i, line in enumerate(lines, start=1): m_next = re.search(r'export\s+(?:async\s+)?function\s+(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\b', line) if m_next: diff --git a/app/services/litellm_service.py b/app/services/litellm_service.py index 692056e..502d4a8 100644 --- a/app/services/litellm_service.py +++ b/app/services/litellm_service.py @@ -1,6 +1,7 @@ import os import logging from typing import Optional, Dict, Any, List +from urllib.parse import urlsplit, urlunsplit import httpx from app.services.database import get_embedding_db_config @@ -38,14 +39,45 @@ async def discover_models( or "dummy" ) - # Normalize URL to target /models endpoint - clean_url = raw_url.strip().rstrip("/") - if clean_url.endswith("/models"): - endpoint = clean_url + # Validate URL against SSRF + parsed = urlsplit(raw_url.strip()) + if parsed.scheme not in ("http", "https"): + error_msg = f"Invalid URL scheme '{parsed.scheme}': only http and https are permitted." + logger.warning(f"LiteLLM model discovery rejected: {error_msg}") + return { + "status": "error", + "message": error_msg, + "total_models": 0, + "models": [], + "embedding_models": [], + "vision_models": [], + "chat_models": [], + } + + host = (parsed.hostname or "").lower() + if not host or host in ("169.254.169.254", "metadata.google.internal") or host.startswith("169.254."): + error_msg = f"Invalid or restricted host '{host}'." + logger.warning(f"LiteLLM model discovery rejected: {error_msg}") + return { + "status": "error", + "message": error_msg, + "total_models": 0, + "models": [], + "embedding_models": [], + "vision_models": [], + "chat_models": [], + } + + # Normalize URL to target /models endpoint safely + clean_path = parsed.path.rstrip("/") + if clean_path.endswith("/models"): + endpoint_path = clean_path else: - if not clean_url.endswith("/v1"): - clean_url = f"{clean_url}/v1" - endpoint = f"{clean_url}/models" + if not clean_path.endswith("/v1"): + clean_path = f"{clean_path}/v1" + endpoint_path = f"{clean_path}/models" + + endpoint = urlunsplit((parsed.scheme, parsed.netloc, endpoint_path, "", "")) headers = {"Authorization": f"Bearer {resolved_api_key}"} diff --git a/frontend/src/SearchInspector.tsx b/frontend/src/SearchInspector.tsx index 05e6ce3..fd8fcb8 100644 --- a/frontend/src/SearchInspector.tsx +++ b/frontend/src/SearchInspector.tsx @@ -89,19 +89,25 @@ export default function SearchInspector() { {p.symbol && {p.symbol}} (Lines {p.start_line}-{p.end_line}) {p.github_url && (() => { + let hostname = ''; + try { + hostname = new URL(p.github_url).hostname.toLowerCase(); + } catch { + // Ignore invalid URL + } const u = p.github_url.toLowerCase(); let label = 'View Source'; let icon = 'fa-solid fa-code-branch'; - if (u.includes('gitlab') || u.includes('/-/blob/')) { + if (hostname === 'gitlab.com' || hostname.endsWith('.gitlab.com') || u.includes('/-/blob/')) { label = 'View on GitLab'; icon = 'fa-brands fa-gitlab'; - } else if (u.includes('gitea') || u.includes('forgejo')) { + } else if (hostname.includes('gitea') || hostname.includes('forgejo')) { label = 'View on Gitea'; icon = 'fa-solid fa-mug-hot'; - } else if (u.includes('bitbucket')) { + } else if (hostname === 'bitbucket.org' || hostname.endsWith('.bitbucket.org')) { label = 'View on Bitbucket'; icon = 'fa-brands fa-bitbucket'; - } else if (u.includes('github.com')) { + } else if (hostname === 'github.com' || hostname.endsWith('.github.com')) { label = 'View on GitHub'; icon = 'fa-brands fa-github'; } diff --git a/tests/test_litellm_service.py b/tests/test_litellm_service.py index 5d510b3..80a3b63 100644 --- a/tests/test_litellm_service.py +++ b/tests/test_litellm_service.py @@ -183,3 +183,16 @@ async def test_discover_models_default_resolution(monkeypatch): args, kwargs = mock_get.call_args assert args[0] == "http://custom-db-litellm:4000/v1/models" assert kwargs["headers"]["Authorization"] == "Bearer db-secret-key" + + +@pytest.mark.asyncio +async def test_discover_models_ssrf_rejection(): + # Test invalid scheme + res_scheme = await discover_models(url="file:///etc/passwd") + assert res_scheme["status"] == "error" + assert "Invalid URL scheme" in res_scheme["message"] + + # Test cloud metadata host + res_metadata = await discover_models(url="http://169.254.169.254/latest/meta-data") + assert res_metadata["status"] == "error" + assert "restricted host" in res_metadata["message"]