@cryptotaxi247 / CoPilot / commits / 899070a8

fix(security): require auth on six unauthenticated API routes (GHSA-xh98-w6qh-cr44) (#886)

Six /api/* routes shipped without any authentication dependency, allowing unauthenticated cross-tenant disclosure and pre-auth state changes: GET /api/agents/dashboard/agents (cross-tenant agent inventory) GET /api/incidents/.../alerts/not-created (live alert backlog) POST /api/incidents/.../create/auto (alert creation) POST /api/carbonblack/provision (EDR provisioning) GET /api/carbonblack/test (EDR collection trigger) GET /api/wazuh-indexer/resize_wazuh_index_fields (destructive index change) The scheduler invokes these handlers in-process (not over HTTP), and the scheduler_login() HTTP-token helper is unused, so adding HTTP auth does not affect scheduled jobs. - 5 routes now require an admin/analyst JWT via dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))]. - /agents/dashboard/agents is genuinely called by external Grafana, so it gets a verify_grafana_header shared-secret dependency (advisory Option 2). Unlike the legacy verify_graylog_header/verify_velociraptor_header, it FAILS CLOSED: it does not fall back to a hardcoded default secret (a published default would reintroduce the bypass, cf. GHSA-4gxj-hw3c-3x2x). GRAFANA_API_HEADER_VALUE must be set or the route is denied; documented in .env.example. No docker-compose change needed: copilot-backend uses env_file: .env, so the new variable is passed through automatically. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

taylor_socfortress committed May 27, 2026 at 14:03 UTC 899070a8dbb8396a0fad3a0c8ac296534ee53b84
5 files changed +33
.env.example
+6
@@ -20,6 +20,12 @@ SSO_STATE_SECRET=REPLACE_ME
20 TOTP_ENCRYPTION_KEY=REPLACE_ME
21 GRAYLOG_API_HEADER_VALUE=ab73de7a-6f61-4dde-87cd-3af5175a7281
22 VELOCIRAPTOR_API_HEADER_VALUE=ab73de7a-6f61-4dde-87cd-3af5175a7281
23 +# Shared secret required on the Grafana-invoked /api/agents/dashboard/agents route
24 +# (GHSA-xh98-w6qh-cr44). Unlike the two values above, this one FAILS CLOSED: if it is
25 +# left blank the route is denied for everyone. Generate a unique random value per
26 +# deployment (e.g. `openssl rand -hex 32`) and set the same value as a request header
27 +# named `grafana` in your Grafana dashboard's datasource. Do NOT reuse the default above.
28 +GRAFANA_API_HEADER_VALUE=
29
30 MYSQL_URL=copilot-mysql
31 # ! Avoid using special characters in the password ! #
backend/app/agents/routes/agents.py
+20
@@ -1,6 +1,7 @@
1 import asyncio
2 import csv
3 import io
4 +import os
5 from datetime import datetime
6 from datetime import timedelta
7 from typing import List
@@ -261,10 +262,29 @@ async def get_agents(current_user: User = Depends(AuthHandler().get_current_user
262 raise HTTPException(status_code=500, detail=f"Failed to fetch agents: {e}")
263
264
265 +# Function to validate the Grafana shared-secret header.
266 +# Modeled on verify_graylog_header (app/active_response/routes/graylog.py), but
267 +# fails closed: it does NOT fall back to a hardcoded default secret. A default
268 +# baked into the open-source repo would let anyone reproduce it and bypass the
269 +# check (the same class of bug as the JWT_SECRET default in GHSA-4gxj-hw3c-3x2x).
270 +# GRAFANA_API_HEADER_VALUE must be set or the route is denied. See GHSA-xh98-w6qh-cr44.
271 +async def verify_grafana_header(grafana: Optional[str] = Header(None)):
272 + """Verify that a Grafana-invoked request carries the correct shared-secret header."""
273 + expected_header = os.getenv("GRAFANA_API_HEADER_VALUE")
274 + if not expected_header:
275 + logger.error("GRAFANA_API_HEADER_VALUE is not configured; denying Grafana dashboard request")
276 + raise HTTPException(status_code=403, detail="Grafana header authentication is not configured")
277 + if grafana != expected_header:
278 + logger.error("Invalid or missing Grafana header")
279 + raise HTTPException(status_code=403, detail="Invalid or missing Grafana header")
280 + return grafana
281 +
282 +
283 @agents_router.get(
284 "/dashboard/agents",
285 response_model=AgentsResponse,
286 description="Get all Wazuh agents for a specific customer (Grafana dashboard use)",
287 + dependencies=[Depends(verify_grafana_header)],
288 )
289 async def get_customer_agents_for_dashboard(
290 customer_code: Optional[str] = Header(None, description="Customer code to filter agents by"),
backend/app/connectors/wazuh_indexer/routes/monitoring.py
+1
@@ -184,6 +184,7 @@ async def get_output_shard_number_to_be_set_based_on_nodes_route() -> int:
184 @wazuh_indexer_router.get(
185 "/resize_wazuh_index_fields",
186 description="Resize Wazuh Index fields",
187 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
188 )
189 async def resize_wazuh_index_fields_route():
190 """
backend/app/incidents/routes/incident_alert.py
+2
@@ -79,6 +79,7 @@ async def get_index_names_route() -> IndexNamesResponse:
79 @incidents_alerts_router.get(
80 "/alerts/not-created",
81 description="Get alerts not created in CoPilot",
82 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
83 )
84 async def get_alerts_not_created_route() -> AlertsPayload:
85 """
@@ -217,6 +218,7 @@ async def create_alert_manual_route(
218 "/create/auto",
219 response_model=AutoCreateAlertResponse,
220 description="Is invoked by the scheduler to create an incident alert in CoPilot",
221 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
222 )
223 async def create_alert_auto_route(
224 session: AsyncSession = Depends(get_db),
backend/app/integrations/carbonblack/routes/provision.py
+4
@@ -1,7 +1,9 @@
1 from fastapi import APIRouter
2 from fastapi import Depends
3 +from fastapi import Security
4 from sqlalchemy.ext.asyncio import AsyncSession
5
6 +from app.auth.utils import AuthHandler
7 from app.db.db_session import get_db
8 from app.integrations.carbonblack.schema.provision import ProvisionCarbonBlackRequest
9 from app.integrations.carbonblack.schema.provision import ProvisionCarbonBlackResponse
@@ -20,6 +22,7 @@ integration_carbonblack_provision_scheduler_router = APIRouter()
22 "/provision",
23 response_model=ProvisionCarbonBlackResponse,
24 description="Provision a CarbonBlack integration.",
25 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
26 )
27 async def provision_carbonblack_route(
28 provision_carbonblack_request: ProvisionCarbonBlackRequest,
@@ -58,6 +61,7 @@ async def provision_carbonblack_route(
61 "/test",
62 response_model=ProvisionCarbonBlackResponse,
63 description="Invoke a CarbonBlack integration for testing",
64 + dependencies=[Security(AuthHandler().require_any_scope("admin", "analyst"))],
65 )
66 async def test() -> ProvisionCarbonBlackResponse:
67 """