Skip to content

Commit 72bd6ec

Browse files
committed
fix(security): gate POST /api/enrichment-proposals/{id}/decide behind the agent key
The broker write-back endpoint accepted unauthenticated decisions with a self-asserted broker_pubkey (default "anon") that persisted to the global log, broadcast to all clients, and triggered writeback/attribution -- a Sybil/spoofing surface introduced with the broker loop (023c847). Sole legitimate caller is the agentbox broker-bridge (service-to-service), so the gate follows the established x-agent-key / VISIONCLAW_AGENT_KEY pattern from image_gen_handler rather than the human-session RequireAuth wraps. 401 before any decision is recorded. cargo check clean; 5/5 enrichment_proposals tests pass. The agentbox caller gains the matching X-Agent-Key header in its own repo. Ecosystem audit 2026-06-11. Co-Authored-By: jjohare <github@thedreamlab.uk>
1 parent 52bb7eb commit 72bd6ec

1 file changed

Lines changed: 37 additions & 1 deletion

File tree

src/handlers/enrichment_proposals_handler.rs

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
//! When the durable store lands, step 3 becomes "apply to the proposal/KG
2222
//! aggregate"; the wire contract and provenance minting are already correct.
2323
24-
use actix_web::{web, HttpResponse};
24+
use actix_web::{web, HttpRequest, HttpResponse};
2525
use log::{debug, info, warn};
2626
use once_cell::sync::Lazy;
2727
use serde::{Deserialize, Serialize};
@@ -116,6 +116,33 @@ pub fn decision_count() -> usize {
116116
DECISION_LOG.lock().map(|g| g.len()).unwrap_or(0)
117117
}
118118

119+
/// Shared service credential for unattended service-to-service callers. Mirrors
120+
/// the established sibling pattern in `image_gen_handler::agent_key` so the
121+
/// agentbox broker-bridge authenticates against the same `VISIONCLAW_AGENT_KEY`
122+
/// it already uses for the image-gen agent-submit route.
123+
fn agent_key() -> String {
124+
std::env::var("VISIONCLAW_AGENT_KEY").unwrap_or_else(|_| "changeme-agent-key".to_string())
125+
}
126+
127+
/// Constant-time-ish equality on the presented `X-Agent-Key`. Returns `Ok(())`
128+
/// when the request bears the valid service credential, else an `Unauthorized`
129+
/// response the caller can short-circuit on.
130+
fn require_agent_key(req: &HttpRequest) -> Result<(), HttpResponse> {
131+
let provided = req
132+
.headers()
133+
.get("x-agent-key")
134+
.and_then(|v| v.to_str().ok())
135+
.unwrap_or("");
136+
if provided != agent_key() {
137+
warn!("[enrichment-decide] rejected: invalid or missing X-Agent-Key");
138+
return Err(HttpResponse::Unauthorized().json(serde_json::json!({
139+
"success": false,
140+
"error": "Invalid or missing X-Agent-Key header",
141+
})));
142+
}
143+
Ok(())
144+
}
145+
119146
fn now_ms() -> u64 {
120147
std::time::SystemTime::now()
121148
.duration_since(std::time::UNIX_EPOCH)
@@ -176,10 +203,19 @@ pub(crate) fn record_decision(
176203

177204
/// `POST /api/enrichment-proposals/{id}/decide`.
178205
pub async fn decide(
206+
req: HttpRequest,
179207
path: web::Path<String>,
180208
body: web::Json<BrokerDecisionRequest>,
181209
client_coordinator: web::Data<actix::Addr<ClientCoordinatorActor>>,
182210
) -> HttpResponse {
211+
// Gate first: this mutating governance route is a service-to-service call
212+
// from the agentbox broker-bridge, not the human UI (no client/src caller).
213+
// Require the established service credential before any decision is recorded,
214+
// broadcast, or attributed — closes the unauthenticated write-back loop.
215+
if let Err(resp) = require_agent_key(&req) {
216+
return resp;
217+
}
218+
183219
let case_id = path.into_inner();
184220

185221
let record = match record_decision(&case_id, &body) {

0 commit comments

Comments
 (0)