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
5 changes: 3 additions & 2 deletions REQUIREMENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

---

Expand Down Expand Up @@ -980,14 +980,15 @@ 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`
- `test_discover_models_http_401_unauthorized`
- `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`
Expand Down
5 changes: 4 additions & 1 deletion app/api/routers/files.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,12 @@ async def api_read_file(
@router.post("/admin/api/files/summarize")
async def api_summarize_file(payload: FileSummarizePayload):
try:
raw_path = payload.path.strip() if payload.path else ""
if not raw_path or "\x00" in raw_path or any(part == ".." for part in raw_path.replace("\\", "/").split("/")):
return JSONResponse(status_code=400, content={"error": "Path traversal or invalid path detected."})
summarizer = get_summarizer_service()
summary_text = summarizer.get_or_create_summary(
filepath=payload.path,
filepath=raw_path,
repo=payload.repo,
force_refresh=payload.force_refresh
)
Expand Down
10 changes: 8 additions & 2 deletions app/api/routers/repositories.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,10 @@ async def api_get_paths():
@router.post("/admin/api/paths")
async def api_add_path(config: LocalPathConfig):
try:
resolved = os.path.abspath(config.path)
raw_path = config.path.strip() if config.path else ""
if not raw_path or "\x00" in raw_path or any(part == ".." for part in raw_path.replace("\\", "/").split("/")):
return JSONResponse(status_code=400, content={"error": "Path traversal or invalid path detected."})
resolved = os.path.normpath(os.path.abspath(raw_path))
if not os.path.exists(resolved):
return JSONResponse(status_code=400, content={"error": f"Path '{resolved}' does not exist on disk."})

Expand Down Expand Up @@ -279,7 +282,10 @@ async def api_test_search(payload: SearchRequest):

@router.get("/admin/api/browse")
async def api_browse_dir(path: str = "/"):
resolved = os.path.abspath(path)
cleaned = path.strip() if path else "/"
if "\x00" in cleaned or any(part in ("..", ".") for part in cleaned.replace("\\", "/").split("/") if part):
cleaned = "/"
resolved = os.path.normpath(os.path.abspath(cleaned))
if not os.path.exists(resolved):
resolved = "/"
try:
Expand Down
16 changes: 15 additions & 1 deletion app/api/routers/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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():
Expand Down
5 changes: 3 additions & 2 deletions app/services/adr.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,9 +155,10 @@ def sync_adr_file(filepath: str, repo: str, content: Optional[str] = None) -> Op
"""Reads or receives ADR markdown file content and syncs it to SQLite database."""
try:
if content is None:
if not os.path.exists(filepath):
norm_fp = os.path.normpath(os.path.abspath(filepath))
if not os.path.exists(norm_fp):
return None
with open(filepath, "r", encoding="utf-8", errors="ignore") as f:
with open(norm_fp, "r", encoding="utf-8", errors="ignore") as f:
content = f.read()

parsed = parse_adr_markdown(content, filepath, repo)
Expand Down
13 changes: 7 additions & 6 deletions app/services/auth/key_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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"],
Expand Down Expand Up @@ -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(),
Expand Down
12 changes: 9 additions & 3 deletions app/services/chunking/api_route_extractor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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:
Expand Down
8 changes: 8 additions & 0 deletions app/services/file_reader.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ def _is_within_root(self, target: str, root: str) -> bool:
and os.path.realpath(target_abs) == os.path.realpath(root_abs)
)

root_prefix = root_abs if root_abs.endswith(os.sep) else root_abs + os.sep
if not target_abs.startswith(root_prefix) and target_abs != root_abs:
return False

try:
if os.path.commonpath([target_abs, root_abs]) != root_abs:
return False
Expand All @@ -46,6 +50,10 @@ def _is_within_root(self, target: str, root: str) -> bool:
# Verify symlink containment to prevent symlink breakouts
target_real = os.path.realpath(target_abs)
root_real = os.path.realpath(root_abs)
root_real_prefix = root_real if root_real.endswith(os.sep) else root_real + os.sep
if not target_real.startswith(root_real_prefix) and target_real != root_real:
return False

try:
if os.path.commonpath([target_real, root_real]) != root_real:
return False
Expand Down
3 changes: 2 additions & 1 deletion app/services/indexing/processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -380,7 +380,8 @@ def process_file_content(
metadata={"kind": chunk.get("kind", "code")}
))

mtime = os.path.getmtime(filepath) if os.path.exists(filepath) else 0.0
filepath_norm = os.path.normpath(os.path.abspath(filepath))
mtime = os.path.getmtime(filepath_norm) if os.path.exists(filepath_norm) else 0.0
summary_tuple = (
filepath,
repo,
Expand Down
46 changes: 39 additions & 7 deletions app/services/litellm_service.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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}"}

Expand Down
24 changes: 21 additions & 3 deletions app/services/local_storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,19 @@ def resolve_safe_path(self, rel_path: str) -> str:
if any(part == ".." for part in parts):
raise ValueError("Path traversal or invalid path detected")

target = os.path.abspath(os.path.join(self.storage_root, cleaned))
norm_root = os.path.normpath(os.path.abspath(self.storage_root))
target = os.path.normpath(os.path.abspath(os.path.join(norm_root, cleaned)))

prefix = norm_root if norm_root.endswith(os.sep) else norm_root + os.sep
if not target.startswith(prefix) and target != norm_root:
raise ValueError("Path traversal or invalid path detected")

try:
common = os.path.commonpath([target, self.storage_root])
common = os.path.commonpath([target, norm_root])
except ValueError:
raise ValueError("Path traversal or invalid path detected")

if common != self.storage_root:
if common != norm_root:
raise ValueError("Path traversal or invalid path detected")

return target
Expand All @@ -61,6 +67,8 @@ def save_file_content(
category: Optional[str] = None
) -> Dict[str, Any]:
target_path = self.resolve_safe_path(rel_path)
if not target_path.startswith(self.storage_root):
raise ValueError("Path traversal or invalid path detected")
os.makedirs(os.path.dirname(target_path), exist_ok=True)

if isinstance(content, bytes):
Expand Down Expand Up @@ -97,6 +105,8 @@ def index_file(
category: Optional[str] = None
) -> Dict[str, Any]:
abs_path = self.resolve_safe_path(rel_path)
if not abs_path.startswith(self.storage_root):
raise ValueError("Path traversal or invalid path detected")
if not os.path.exists(abs_path) or not os.path.isfile(abs_path):
raise FileNotFoundError(f"File '{rel_path}' not found on disk for indexing.")

Expand Down Expand Up @@ -206,6 +216,8 @@ def save_file(

def delete_file(self, rel_path: str, repo: str = "local_storage") -> Dict[str, Any]:
abs_path = self.resolve_safe_path(rel_path)
if not abs_path.startswith(self.storage_root):
raise ValueError("Path traversal or invalid path detected")
self.delete_file_disk(rel_path)

try:
Expand All @@ -232,6 +244,8 @@ def delete_file(self, rel_path: str, repo: str = "local_storage") -> Dict[str, A

def read_file_content(self, rel_path: str) -> Dict[str, Any]:
target_path = self.resolve_safe_path(rel_path)
if not target_path.startswith(self.storage_root):
raise ValueError("Path traversal or invalid path detected")
if not os.path.exists(target_path) or not os.path.isfile(target_path):
raise FileNotFoundError(f"File '{rel_path}' does not exist in local storage.")

Expand Down Expand Up @@ -269,6 +283,8 @@ def read_file_content(self, rel_path: str) -> Dict[str, Any]:

def delete_file_disk(self, rel_path: str) -> bool:
target_path = self.resolve_safe_path(rel_path)
if not target_path.startswith(self.storage_root):
raise ValueError("Path traversal or invalid path detected")
if not os.path.exists(target_path):
return False

Expand All @@ -280,6 +296,8 @@ def delete_file_disk(self, rel_path: str) -> bool:

def get_file_tree(self, subfolder: Optional[str] = None) -> Dict[str, Any]:
scan_root = self.resolve_safe_path(subfolder) if subfolder else self.storage_root
if not scan_root.startswith(self.storage_root):
raise ValueError("Path traversal or invalid path detected")
if not os.path.exists(scan_root):
return {"root": self.storage_root, "current_folder": subfolder or "", "directories": [], "files": []}

Expand Down
7 changes: 4 additions & 3 deletions app/services/pdf_extractor.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,11 +85,12 @@ def extract_pdf_pages(
raise ValueError(f"PDF exceeds size limit of {MAX_PDF_SIZE_BYTES // (1024*1024)}MB")
doc = pymupdf.open(stream=source, filetype="pdf")
else:
if not os.path.exists(source):
norm_source = os.path.normpath(os.path.abspath(source))
if not os.path.exists(norm_source):
raise FileNotFoundError(f"PDF file not found: {source}")
if os.path.getsize(source) > MAX_PDF_SIZE_BYTES:
if os.path.getsize(norm_source) > MAX_PDF_SIZE_BYTES:
raise ValueError(f"PDF exceeds size limit of {MAX_PDF_SIZE_BYTES // (1024*1024)}MB")
doc = pymupdf.open(source)
doc = pymupdf.open(norm_source)
except Exception as e:
if isinstance(e, (ValueError, FileNotFoundError)):
raise
Expand Down
14 changes: 9 additions & 5 deletions app/services/summarizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -243,17 +243,21 @@
content = None

if content is None:
if os.path.exists(filepath):
norm_fp = os.path.normpath(os.path.abspath(filepath))
if os.path.exists(norm_fp) and os.path.isfile(norm_fp):
try:
with open(filepath, "r", encoding="utf-8", errors="replace") as f:
with open(norm_fp, "r", encoding="utf-8", errors="replace") as f:
content = f.read()
except Exception as e:
logger.warning(f"Failed to read file from disk '{filepath}': {e}")
logger.warning(f"Failed to read file from disk '{norm_fp}': {e}")
else:
try:
from app.services.local_storage import get_default_storage_path
storage_cand = os.path.join(get_default_storage_path(), filepath)
if os.path.exists(storage_cand):
storage_root = os.path.normpath(os.path.abspath(get_default_storage_path()))
storage_prefix = storage_root if storage_root.endswith(os.sep) else storage_root + os.sep
cleaned_fp = filepath.strip().replace("\\", "/").lstrip("/")
storage_cand = os.path.normpath(os.path.abspath(os.path.join(storage_root, cleaned_fp)))
if storage_cand.startswith(storage_prefix) and os.path.exists(storage_cand) and os.path.isfile(storage_cand):
with open(storage_cand, "r", encoding="utf-8", errors="replace") as f:
content = f.read()
except Exception:
Expand Down
Loading
Loading