Address review blockers and medium/low findings: - F1: HTML-escape all dynamic analytics fields (html.escape quote=True) - F2: Gate POST /api/v1/analytics/usage through console_authz record_analytics_usage (fail closed for unauthenticated / Phase 1) - F3: Enforce usage_events retention (max rows + max age) - F4: Coerce None remote/org/repo to empty strings in load_analytics Add tests for XSS escaping, unauthorized ingest deny, retention, and None scope coercion. Document authz + retention in analytics guide. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
@@ -165,7 +165,8 @@ class AnalyticsWebUITest(unittest.TestCase):
|
||||
self.assertEqual(data["total_events"], 1)
|
||||
self.assertIn("gemini-3.6-flash", data["by_model"])
|
||||
|
||||
def test_analytics_ingest_endpoint(self) -> None:
|
||||
def test_analytics_ingest_unauthorized_denied(self) -> None:
|
||||
"""F2: unauthenticated POST must not write the control-plane DB."""
|
||||
payload = {
|
||||
"remote": "dadeschools",
|
||||
"org": "Scaled-Tech-Consulting",
|
||||
@@ -180,16 +181,71 @@ class AnalyticsWebUITest(unittest.TestCase):
|
||||
"metadata": "Review note token=secret456",
|
||||
}
|
||||
response = self.client.post("/api/v1/analytics/usage", json=payload)
|
||||
self.assertEqual(response.status_code, 201)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
res_json = response.json()
|
||||
self.assertTrue(res_json["ok"])
|
||||
self.assertGreater(res_json["usage_id"], 0)
|
||||
self.assertFalse(res_json.get("ok", True))
|
||||
self.assertEqual(res_json.get("error"), "unauthorized")
|
||||
authorization = res_json.get("authorization") or {}
|
||||
self.assertFalse(authorization.get("allowed"))
|
||||
self.assertFalse(authorization.get("execution_enabled"))
|
||||
|
||||
# Check that it appears in GET /api/v1/analytics
|
||||
# No new row written
|
||||
res2 = self.client.get("/api/v1/analytics")
|
||||
self.assertEqual(res2.status_code, 200)
|
||||
data2 = res2.json()
|
||||
self.assertEqual(data2["total_events"], 2)
|
||||
self.assertEqual(res2.json()["total_events"], 1)
|
||||
|
||||
def test_html_escapes_script_bearing_model_role_stage(self) -> None:
|
||||
"""F1: stored XSS — dynamic model/role/stage must render escaped."""
|
||||
xss = '<script>alert(1)</script>'
|
||||
record_usage(
|
||||
db_path=self.db_path,
|
||||
remote="dadeschools",
|
||||
org="Scaled-Tech-Consulting",
|
||||
repo="Gitea-Tools",
|
||||
role=xss,
|
||||
model=xss,
|
||||
stage=xss,
|
||||
issue_number=999,
|
||||
status="success",
|
||||
)
|
||||
response = self.client.get("/analytics")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
# Raw tag must not appear; escaped form must.
|
||||
self.assertNotIn("<script>alert(1)</script>", response.text)
|
||||
self.assertIn("<script>alert(1)</script>", response.text)
|
||||
|
||||
def test_load_analytics_coerces_none_scope(self) -> None:
|
||||
"""F4: None remote/org/repo become empty strings, never None."""
|
||||
snapshot = load_analytics(db_path=self.db_path, remote=None, org=None, repo=None)
|
||||
self.assertIsInstance(snapshot.remote, str)
|
||||
self.assertIsInstance(snapshot.org, str)
|
||||
self.assertIsInstance(snapshot.repo, str)
|
||||
self.assertEqual(snapshot.remote, "")
|
||||
self.assertEqual(snapshot.org, "")
|
||||
self.assertEqual(snapshot.repo, "")
|
||||
|
||||
def test_usage_events_retention_max_rows(self) -> None:
|
||||
"""F3: record_usage_event enforces USAGE_EVENTS_MAX_ROWS."""
|
||||
db = control_plane_db.ControlPlaneDB(db_path=self.db_path)
|
||||
original_max = db.USAGE_EVENTS_MAX_ROWS
|
||||
try:
|
||||
db.USAGE_EVENTS_MAX_ROWS = 3
|
||||
for i in range(5):
|
||||
db.record_usage_event(
|
||||
remote="dadeschools",
|
||||
org="org",
|
||||
repo="repo",
|
||||
role="author",
|
||||
model=f"model-{i}",
|
||||
stage="test",
|
||||
)
|
||||
rows = db.query_usage_events(limit=100)
|
||||
self.assertLessEqual(len(rows), 3)
|
||||
# Newest three retained
|
||||
models = {r["model"] for r in rows}
|
||||
self.assertEqual(models, {"model-2", "model-3", "model-4"})
|
||||
finally:
|
||||
db.USAGE_EVENTS_MAX_ROWS = original_max
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user