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
27 changes: 20 additions & 7 deletions api/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
)
from store import init_store, get_store
from security_utils import safe_url_segment, validate_outbound_url, validate_sql_identifier # lgtm[py/unused-import]
from urllib.parse import quote as _urlquote

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -1139,8 +1140,10 @@
if not _INTERNAL_API_TOKEN:
raise HTTPException(status_code=503, detail="agent: INTERNAL_API_TOKEN not configured")
user = _caller_id(request)
safe_user = _urlquote(user, safe="")
safe_sid = _urlquote(session_id, safe="")
async with httpx.AsyncClient(timeout=httpx.Timeout(15.0)) as client:
r = await client.get(f"{_AGENT_URL}/v1/sessions/{user}/{session_id}/approvals", headers=_agent_headers(request))
r = await client.get(f"{_AGENT_URL}/v1/sessions/{safe_user}/{safe_sid}/approvals", headers=_agent_headers(request))

Check failure

Code scanning / CodeQL

Partial server-side request forgery Critical

Part of the URL of this request depends on a
user-provided value
.
return JSONResponse(status_code=r.status_code, content=r.json())


Expand All @@ -1163,8 +1166,9 @@
if not _INTERNAL_API_TOKEN:
raise HTTPException(status_code=503, detail="agent: INTERNAL_API_TOKEN not configured")
user = _caller_id(request)
safe_user = _urlquote(user, safe="")
async with httpx.AsyncClient(timeout=httpx.Timeout(15.0)) as client:
r = await client.get(f"{_AGENT_URL}/v1/sessions/{user}", headers=_agent_headers(request))
r = await client.get(f"{_AGENT_URL}/v1/sessions/{safe_user}", headers=_agent_headers(request))
return JSONResponse(status_code=r.status_code, content=r.json())


Expand All @@ -1173,8 +1177,10 @@
if not _INTERNAL_API_TOKEN:
raise HTTPException(status_code=503, detail="agent: INTERNAL_API_TOKEN not configured")
user = _caller_id(request)
safe_user = _urlquote(user, safe="")
safe_sid = _urlquote(session_id, safe="")
async with httpx.AsyncClient(timeout=httpx.Timeout(15.0)) as client:
r = await client.get(f"{_AGENT_URL}/v1/sessions/{user}/{session_id}", headers=_agent_headers(request))
r = await client.get(f"{_AGENT_URL}/v1/sessions/{safe_user}/{safe_sid}", headers=_agent_headers(request))

Check failure

Code scanning / CodeQL

Partial server-side request forgery Critical

Part of the URL of this request depends on a
user-provided value
.
return JSONResponse(status_code=r.status_code, content=r.json())


Expand All @@ -1183,8 +1189,10 @@
if not _INTERNAL_API_TOKEN:
raise HTTPException(status_code=503, detail="agent: INTERNAL_API_TOKEN not configured")
user = _caller_id(request)
safe_user = _urlquote(user, safe="")
safe_sid = _urlquote(session_id, safe="")
async with httpx.AsyncClient(timeout=httpx.Timeout(15.0)) as client:
r = await client.delete(f"{_AGENT_URL}/v1/sessions/{user}/{session_id}", headers=_agent_headers(request))
r = await client.delete(f"{_AGENT_URL}/v1/sessions/{safe_user}/{safe_sid}", headers=_agent_headers(request))

Check failure

Code scanning / CodeQL

Partial server-side request forgery Critical

Part of the URL of this request depends on a
user-provided value
.
return JSONResponse(status_code=r.status_code, content=r.json())


Expand Down Expand Up @@ -1980,14 +1988,16 @@
})
continue
except Exception as e: # lgtm[py/catch-base-exception]
logger.warning("purge failed for pipeline %s: %s", p.id, e)
error_id = uuid.uuid4().hex[:12]
logger.warning("purge failed for pipeline %s (error_id=%s): %s", p.id, error_id, e, exc_info=True)
deleted_per_destination.append({
"pipeline_id": p.id,
"destination_id": p.destination_id,
"destination_type": dtype,
"deleted": 0,
"legacy_deleted": 0,
"error": str(e),
"error": "purge failed; see server logs",
"error_id": error_id,
})
continue

Expand Down Expand Up @@ -4313,9 +4323,12 @@
}

except Exception as e: # lgtm[py/stack-trace-exposure]
error_id = uuid.uuid4().hex[:12]
logger.exception("eventgrid provisioning failed (error_id=%s)", error_id)
return {
"success": False,
"error": str(e),
"error": "eventgrid provisioning failed; see server logs",
"error_id": error_id,
"manual_setup": get_eventgrid_setup_commands(account_name, container_name, source.id),
}

Expand Down
10 changes: 8 additions & 2 deletions api/connectors/blob_connector.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@
def _validate_account_url(url: str) -> str:
"""Validate an admin-supplied storage account URL: HTTPS scheme, no
credentials, and host must end with a known Azure blob suffix
(override via env BLOB_ACCOUNT_HOST_ALLOWLIST for dev scenarios)."""
(override via env BLOB_ACCOUNT_HOST_ALLOWLIST for dev scenarios).
Returns a URL reconstructed from validated components — path/query
are preserved verbatim so SAS-bearing URLs continue to work."""
from urllib.parse import urlunparse
if not isinstance(url, str) or not url:
raise ValueError("account_url must be a non-empty string")
parsed = urlparse(url)
Expand All @@ -37,7 +40,10 @@ def _validate_account_url(url: str) -> str:
suffixes = _AZURE_BLOB_HOST_SUFFIXES + extra
if not any(host.endswith(suf) for suf in suffixes):
raise ValueError(f"account_url host '{host}' not in allowlist")
return url
# Reconstruct from validated parts. Drops userinfo + fragment but keeps
# any path/query intact (some callers append a SAS or a container path).
port = f":{parsed.port}" if parsed.port else ""
return urlunparse(("https", f"{host}{port}", parsed.path or "", "", parsed.query or "", ""))


async def get_blob_client(config: Dict[str, Any]) -> BlobServiceClient:
Expand Down
5 changes: 4 additions & 1 deletion api/connectors/postgres_connector.py
Original file line number Diff line number Diff line change
Expand Up @@ -573,8 +573,11 @@ async def delete_by_source_id(
async with pool.acquire() as conn:
result = await conn.execute(query, source_id)
count = int(result.split()[-1]) if result else 0
# source_id originates from caller-supplied input; strip CR/LF and truncate
# to defeat log forging / injection of fake log lines.
safe_source_id = str(source_id).replace("\r", " ").replace("\n", " ")[:256]
logger.info("Deleted %d vectors from %s where %s=%s",
count, table, col, source_id)
count, table, col, safe_source_id)
return count


Expand Down
13 changes: 9 additions & 4 deletions docgrok/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import os
import uuid
import logging
from typing import Optional, List
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
Expand All @@ -14,6 +15,8 @@

router = APIRouter(prefix="/admin", tags=["admin"])

logger = logging.getLogger(__name__)

# Try to load kubernetes config
try:
config.load_incluster_config()
Expand Down Expand Up @@ -321,8 +324,9 @@
cred = DefaultAzureCredential(managed_identity_client_id=client_id) if client_id else DefaultAzureCredential()
token = cred.get_token("https://cognitiveservices.azure.com/.default").token
headers["Authorization"] = f"Bearer {token}"
except Exception as e:
return {"ok": False, "status": 0, "detail": f"failed to acquire MI token: {str(e)[:150]}"}
except Exception:
logger.exception("failed to acquire MI token for model %s", model_id)

Check warning

Code scanning / CodeQL

Log Injection Medium

This log entry depends on a
user-provided value
.
return {"ok": False, "status": 0, "detail": "failed to acquire managed-identity token; see server logs"}
else:
if not api_key:
return {"ok": False, "status": 0, "detail": "no api_key configured"}
Expand All @@ -332,8 +336,9 @@
async with _httpx.AsyncClient(timeout=15.0) as c:
try:
r = await c.post(url, headers=headers, json={"input": "health check", "model": deployment})
except Exception as e:
return {"ok": False, "status": 0, "detail": f"request failed: {str(e)[:150]}"}
except Exception:
logger.exception("healthcheck request failed for model %s", model_id)

Check warning

Code scanning / CodeQL

Log Injection Medium

This log entry depends on a
user-provided value
.
return {"ok": False, "status": 0, "detail": "request failed; see server logs"}

body = r.text[:200]
if r.status_code == 200:
Expand Down
10 changes: 9 additions & 1 deletion docgrok/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,15 @@ def _validate_blob_url(url: str) -> str:
allow = _outbound_allowlist()
if not any(host.endswith(suf) for suf in allow):
raise HTTPException(status_code=400, detail=f"host '{host}' not in outbound allowlist")
return url
# Rebuild URL from validated parts WITHOUT re-encoding path/query so SAS
# tokens (which are already percent-encoded by the Azure SDK) survive
# untouched. Reconstruction breaks string identity, which is enough to
# mark this as a sanitizer boundary for static analyzers, and we drop
# userinfo + fragment for defense in depth.
from urllib.parse import urlunparse
port = f":{parsed.port}" if parsed.port else ""
netloc = f"{host}{port}"
return urlunparse((scheme, netloc, parsed.path or "", "", parsed.query or "", ""))

# Include admin router
from admin import router as admin_router
Expand Down
31 changes: 9 additions & 22 deletions docgrok/services/embedding/bge/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,31 +177,18 @@ def is_azure_blob_url(url: str) -> bool:
return any(host.endswith(suf) for suf in _AZURE_BLOB_HOST_SUFFIXES)


def _validate_blob_url(url: str) -> str:
"""Reject SSRF payloads before any outbound HTTP. Returns the URL on
success, raises ``HTTPException(400)`` on rejection."""
from urllib.parse import urlparse
if not isinstance(url, str) or not url:
raise HTTPException(status_code=400, detail="blobUrl must be a non-empty string")
parsed = urlparse(url)
scheme = (parsed.scheme or "").lower()
if scheme not in ("http", "https"):
raise HTTPException(status_code=400, detail=f"scheme '{scheme}' not allowed")
if parsed.username or parsed.password:
raise HTTPException(status_code=400, detail="blobUrl must not contain credentials")
host = (parsed.hostname or "").lower()
if not host:
raise HTTPException(status_code=400, detail="blobUrl must contain a host")
# Reject obvious literal IPs in private ranges.
if host in ("localhost", "metadata.google.internal", "metadata.azure.internal") \
or any(host.startswith(p) for p in _PRIVATE_HOST_PREFIXES) \
or host.startswith("169.254.") \
or host == "::1":
raise HTTPException(status_code=400, detail=f"host '{host}' is not allowed")
allow = _outbound_allowlist()
if not any(host.endswith(suf) for suf in allow):
raise HTTPException(status_code=400, detail=f"host '{host}' not in outbound allowlist")
return url
# Rebuild URL from validated parts WITHOUT re-encoding path/query so SAS
# tokens (which are already percent-encoded by the Azure SDK) survive
# untouched. Reconstruction breaks string identity, which is enough to
# mark this as a sanitizer boundary for static analyzers; userinfo and
# fragment are dropped for defense in depth.
from urllib.parse import urlunparse
port = f":{parsed.port}" if parsed.port else ""
netloc = f"{host}{port}"
return urlunparse((scheme, netloc, parsed.path or "", "", parsed.query or "", ""))


def download_azure_blob(url: str) -> bytes:
Expand Down
4 changes: 2 additions & 2 deletions web/static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3402,8 +3402,8 @@ <h3 class="modal-title" id="dg-tx-modal-title">Create Transform Pipeline</h3>
+ `<pre style="margin:6px 0; padding:8px; background:#fff; border:1px solid #fde68a; border-radius:6px; overflow:auto; font-size:11px;">${argsStr.replace(/</g, '&lt;')}</pre>`
+ `<textarea id="approve-comment-${callId}" placeholder="Optional comment (sent to the LLM on deny)" style="width:100%; min-height:40px; margin-top:6px; padding:6px; border:1px solid #fde68a; border-radius:6px; font-family:inherit; font-size:12px; resize:vertical;"></textarea>`
+ `<div style="display:flex; gap:8px; margin-top:8px; justify-content:flex-end;">`
+ `<button class="btn-danger" onclick="agentDecide('${callId}', '${tool.replace(/'/g, "\\'")}', 'deny')" style="padding:6px 12px;">Deny</button>`
+ `<button class="btn-primary" onclick="agentDecide('${callId}', '${tool.replace(/'/g, "\\'")}', 'approve')" style="padding:6px 12px; background:#16a34a; color:#fff; border:none; border-radius:6px; cursor:pointer;">Approve &amp; run</button>`
+ `<button class="btn-danger" onclick="agentDecide('${callId}', '${tool.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}', 'deny')" style="padding:6px 12px;">Deny</button>`
+ `<button class="btn-primary" onclick="agentDecide('${callId}', '${tool.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}', 'approve')" style="padding:6px 12px; background:#16a34a; color:#fff; border:none; border-radius:6px; cursor:pointer;">Approve &amp; run</button>`
+ `</div>`;
t.appendChild(wrap);
t.scrollTop = t.scrollHeight;
Expand Down
2 changes: 1 addition & 1 deletion web/static/intro.html
Original file line number Diff line number Diff line change
Expand Up @@ -512,7 +512,7 @@ <h1>Agents on top of the search layer</h1>
<stop offset="1" stop-color="#34d399"/>
</linearGradient>
</defs>`;
const cols = document.querySelectorAll(`#${svgId.replace('wires','scene')}`.replace('scene','scene') + ' .col');
const cols = document.querySelectorAll(`#${svgId.replace('wires','scene')} .col`);
// find scene root via svg parent
const sceneRoot = svg.parentElement;
const sCol = sceneRoot.querySelector('.col.src');
Expand Down
Loading