Skip to content

Commit b03caf8

Browse files
authored
[codex] Harden dashboard and Discord security boundaries
Squash merge PR #324.
1 parent 64541c3 commit b03caf8

8 files changed

Lines changed: 420 additions & 145 deletions

File tree

.env.example

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,12 @@ WEB_HOST_BIND=127.0.0.1
6666
# `WEB_PORT` with a deterministic per-worktree port automatically.
6767
# Deprecated fallback names still work: WEBHOOK_INGEST_HOST, WEBHOOK_INGEST_PORT,
6868
# WEBHOOK_INGEST_HOST_BIND, WEBHOOK_INGEST_HOST_PORT.
69-
# Required: ingest requests are rejected when unset
69+
# Required for protected non-dashboard API routes. Webhook routes use this only
70+
# when WEBHOOK_SHARED_SECRET is unset.
7071
API_SHARED_SECRET=
72+
# Optional: separate secret for external /webhooks/* callers. When unset or
73+
# blank, webhook routes fall back to API_SHARED_SECRET for compatibility.
74+
WEBHOOK_SHARED_SECRET=
7175
# Authentik admin API (required for /create-sso-user and /create-user-accounts)
7276
# Base URL can omit /api/v3; the client adds it automatically.
7377
AUTHENTIK_API_BASE_URL=

apps/api/src/five08/backend/api.py

Lines changed: 43 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -410,19 +410,51 @@ def _get_agent_orchestrator() -> AgentOrchestrator:
410410
return _AGENT_ORCHESTRATOR
411411

412412

413-
def _is_authorized(request: Request) -> bool:
414-
"""Validate shared API secret."""
415-
if not settings.api_shared_secret:
416-
logger.error("Rejecting request: API_SHARED_SECRET is not configured")
413+
def _is_authorized_with_secret(
414+
request: Request,
415+
*,
416+
configured_secret: str | None,
417+
setting_name: str,
418+
) -> bool:
419+
"""Validate an X-API-Secret header against one configured secret."""
420+
secret = (configured_secret or "").strip()
421+
if not secret:
422+
logger.error("Rejecting request: %s is not configured", setting_name)
417423
return False
418424

419425
provided_secret = request.headers.get("X-API-Secret", "")
420-
if secrets.compare_digest(provided_secret, settings.api_shared_secret):
426+
if secrets.compare_digest(provided_secret, secret):
421427
return True
422-
logger.warning("Rejecting request: invalid X-API-Secret")
428+
logger.warning("Rejecting request: invalid X-API-Secret for %s", setting_name)
423429
return False
424430

425431

432+
def _is_authorized(request: Request) -> bool:
433+
"""Validate the internal shared API secret."""
434+
return _is_authorized_with_secret(
435+
request,
436+
configured_secret=settings.api_shared_secret,
437+
setting_name="API_SHARED_SECRET",
438+
)
439+
440+
441+
def _is_webhook_authorized(request: Request) -> bool:
442+
"""Validate the external webhook secret, with legacy API secret fallback."""
443+
webhook_secret = (settings.webhook_shared_secret or "").strip()
444+
if webhook_secret:
445+
return _is_authorized_with_secret(
446+
request,
447+
configured_secret=webhook_secret,
448+
setting_name="WEBHOOK_SHARED_SECRET",
449+
)
450+
451+
return _is_authorized_with_secret(
452+
request,
453+
configured_secret=settings.api_shared_secret,
454+
setting_name="API_SHARED_SECRET",
455+
)
456+
457+
426458
def _agent_request_rate_limited(discord_user_id: str) -> bool:
427459
now = time.monotonic()
428460
window_start = now - _AGENT_REQUEST_RATE_LIMIT_WINDOW_SECONDS
@@ -2411,7 +2443,7 @@ async def health_handler(request: Request) -> JSONResponse:
24112443

24122444
async def ingest_handler(request: Request, source: str) -> JSONResponse:
24132445
"""Validate and enqueue incoming webhook payloads."""
2414-
if not _is_authorized(request):
2446+
if not _is_webhook_authorized(request):
24152447
return JSONResponse({"error": "unauthorized"}, status_code=401)
24162448

24172449
try:
@@ -2446,7 +2478,7 @@ async def ingest_handler(request: Request, source: str) -> JSONResponse:
24462478

24472479
async def espocrm_webhook_handler(request: Request) -> JSONResponse:
24482480
"""Validate EspoCRM webhook payload and enqueue per-contact jobs."""
2449-
if not _is_authorized(request):
2481+
if not _is_webhook_authorized(request):
24502482
return JSONResponse({"error": "unauthorized"}, status_code=401)
24512483

24522484
try:
@@ -5856,7 +5888,7 @@ async def dashboard_sync_people_handler(request: Request) -> JSONResponse:
58565888

58575889
async def espocrm_people_sync_webhook_handler(request: Request) -> JSONResponse:
58585890
"""Queue per-contact people cache sync jobs from CRM webhook events."""
5859-
if not _is_authorized(request):
5891+
if not _is_webhook_authorized(request):
58605892
return JSONResponse({"error": "unauthorized"}, status_code=401)
58615893

58625894
try:
@@ -5910,7 +5942,7 @@ async def docuseal_webhook_handler(request: Request) -> JSONResponse:
59105942
Job payload contract for the queue is:
59115943
completed_at = "YYYY-MM-DD HH:mm:ss" in UTC.
59125944
"""
5913-
if not _is_authorized(request):
5945+
if not _is_webhook_authorized(request):
59145946
return JSONResponse({"error": "unauthorized"}, status_code=401)
59155947

59165948
try:
@@ -6027,7 +6059,7 @@ async def docuseal_webhook_handler(request: Request) -> JSONResponse:
60276059

60286060
async def google_forms_intake_webhook_handler(request: Request) -> JSONResponse:
60296061
"""Validate a Google Forms intake submission and enqueue a processing job."""
6030-
if not _is_authorized(request):
6062+
if not _is_webhook_authorized(request):
60316063
return JSONResponse({"error": "unauthorized"}, status_code=401)
60326064

60336065
try:

0 commit comments

Comments
 (0)