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
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
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
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
14 changes: 10 additions & 4 deletions frontend/src/SearchInspector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,19 +89,25 @@ export default function SearchInspector() {
{p.symbol && <span className="badge badge-accent">{p.symbol}</span>}
<span className="text-muted" style={{ fontSize: '0.8rem' }}>(Lines {p.start_line}-{p.end_line})</span>
{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';
}
Expand Down
13 changes: 13 additions & 0 deletions tests/test_litellm_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Loading