mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
Merge pull request #4 from hobostay/fix/ws-event-ownership
fix(daemon): reject cross-browser session events from non-owners
This commit is contained in:
@@ -416,10 +416,10 @@ async fn handle_inbound_text(state: &Arc<DaemonState>, client: &Arc<BrowserClien
|
|||||||
}
|
}
|
||||||
Frame::Event(ev) => match ev.event {
|
Frame::Event(ev) => match ev.event {
|
||||||
bsk_protocol::EventKind::SessionWindowClosed => {
|
bsk_protocol::EventKind::SessionWindowClosed => {
|
||||||
handle_session_window_closed(state, &ev.payload);
|
handle_session_window_closed(state, &client.id, &ev.payload);
|
||||||
}
|
}
|
||||||
bsk_protocol::EventKind::SessionUserInterrupt => {
|
bsk_protocol::EventKind::SessionUserInterrupt => {
|
||||||
handle_session_user_interrupt(state, &ev.payload);
|
handle_session_user_interrupt(state, &client.id, &ev.payload);
|
||||||
}
|
}
|
||||||
other => {
|
other => {
|
||||||
debug!(event = ?other, "event received (no handler yet)");
|
debug!(event = ?other, "event received (no handler yet)");
|
||||||
@@ -432,7 +432,11 @@ async fn handle_inbound_text(state: &Arc<DaemonState>, client: &Arc<BrowserClien
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_session_window_closed(state: &Arc<DaemonState>, payload: &serde_json::Value) {
|
fn handle_session_window_closed(
|
||||||
|
state: &Arc<DaemonState>,
|
||||||
|
sender: &BrowserId,
|
||||||
|
payload: &serde_json::Value,
|
||||||
|
) {
|
||||||
let Some(session_id) = payload.get("session_id").and_then(|v| v.as_str()) else {
|
let Some(session_id) = payload.get("session_id").and_then(|v| v.as_str()) else {
|
||||||
warn!("session.window_closed event missing session_id");
|
warn!("session.window_closed event missing session_id");
|
||||||
return;
|
return;
|
||||||
@@ -448,6 +452,24 @@ fn handle_session_window_closed(state: &Arc<DaemonState>, payload: &serde_json::
|
|||||||
})
|
})
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let session_id = super::sessions::SessionId(session_id.to_string());
|
let session_id = super::sessions::SessionId(session_id.to_string());
|
||||||
|
// Reject events that target a session owned by a *different* browser.
|
||||||
|
// Without this, any connected extension (or any same-machine process
|
||||||
|
// that completed the WS handshake) could tear down another browser's
|
||||||
|
// session by emitting window_closed with its session_id. A session
|
||||||
|
// that is already gone cannot be verified but is harmless to "forget"
|
||||||
|
// (forget_session is a no-op), so we only reject when a live owner
|
||||||
|
// disagrees.
|
||||||
|
if let Some(session) = state.sessions.get(&session_id)
|
||||||
|
&& session.browser_id != *sender
|
||||||
|
{
|
||||||
|
warn!(
|
||||||
|
session = %session_id,
|
||||||
|
sender = %sender,
|
||||||
|
owner = %session.browser_id,
|
||||||
|
"ignoring session.window_closed from a browser that does not own this session"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if super::sessions::forget_session(
|
if super::sessions::forget_session(
|
||||||
&state.sessions,
|
&state.sessions,
|
||||||
&state.tool_queues,
|
&state.tool_queues,
|
||||||
@@ -485,11 +507,29 @@ fn extract_session_id(payload: &serde_json::Value) -> Option<super::sessions::Se
|
|||||||
/// forwarded over WS we additionally push a per-RPC cancel frame so
|
/// forwarded over WS we additionally push a per-RPC cancel frame so
|
||||||
/// the extension's dispatcher can abort its `AbortController` —
|
/// the extension's dispatcher can abort its `AbortController` —
|
||||||
/// this is the same shape `handle_cancel` uses, just iterated.
|
/// this is the same shape `handle_cancel` uses, just iterated.
|
||||||
fn handle_session_user_interrupt(state: &Arc<DaemonState>, payload: &serde_json::Value) {
|
fn handle_session_user_interrupt(
|
||||||
|
state: &Arc<DaemonState>,
|
||||||
|
sender: &BrowserId,
|
||||||
|
payload: &serde_json::Value,
|
||||||
|
) {
|
||||||
let Some(sid) = extract_session_id(payload) else {
|
let Some(sid) = extract_session_id(payload) else {
|
||||||
warn!("session.user_interrupt event missing session_id");
|
warn!("session.user_interrupt event missing session_id");
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
// Same ownership guard as window_closed: a browser must not be able
|
||||||
|
// to cancel another browser's inflight tools or trip its interrupt
|
||||||
|
// marker by sending user_interrupt with a foreign session_id.
|
||||||
|
if let Some(session) = state.sessions.get(&sid)
|
||||||
|
&& session.browser_id != *sender
|
||||||
|
{
|
||||||
|
warn!(
|
||||||
|
session = %sid,
|
||||||
|
sender = %sender,
|
||||||
|
owner = %session.browser_id,
|
||||||
|
"ignoring session.user_interrupt from a browser that does not own this session"
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
let snapshots = state.tool_inflight.cancel_session(&sid);
|
let snapshots = state.tool_inflight.cancel_session(&sid);
|
||||||
info!(session = %sid, count = snapshots.len(), "user-interrupt: cancelled inflight tools");
|
info!(session = %sid, count = snapshots.len(), "user-interrupt: cancelled inflight tools");
|
||||||
|
|
||||||
@@ -547,6 +587,7 @@ mod tests {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod session_user_interrupt_tests {
|
mod session_user_interrupt_tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::daemon::browsers::BrowserId;
|
||||||
use crate::daemon::inflight::CancelReason;
|
use crate::daemon::inflight::CancelReason;
|
||||||
use crate::daemon::sessions::SessionId;
|
use crate::daemon::sessions::SessionId;
|
||||||
|
|
||||||
@@ -587,7 +628,13 @@ mod session_user_interrupt_tests {
|
|||||||
.register("b".into(), sid_b.clone())
|
.register("b".into(), sid_b.clone())
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
handle_session_user_interrupt(&state, &serde_json::json!({"session_id": "A"}));
|
// No live session is registered, so the ownership guard treats the
|
||||||
|
// session as unknown and lets the interrupt through (legacy path).
|
||||||
|
handle_session_user_interrupt(
|
||||||
|
&state,
|
||||||
|
&BrowserId("sender".into()),
|
||||||
|
&serde_json::json!({"session_id": "A"}),
|
||||||
|
);
|
||||||
|
|
||||||
assert_eq!(g_a.entry().cancel_reason(), Some(CancelReason::UserAborted));
|
assert_eq!(g_a.entry().cancel_reason(), Some(CancelReason::UserAborted));
|
||||||
assert!(g_b.entry().cancel_reason().is_none());
|
assert!(g_b.entry().cancel_reason().is_none());
|
||||||
@@ -599,7 +646,11 @@ mod session_user_interrupt_tests {
|
|||||||
let sid_a = SessionId("A".into());
|
let sid_a = SessionId("A".into());
|
||||||
let sid_b = SessionId("B".into());
|
let sid_b = SessionId("B".into());
|
||||||
|
|
||||||
handle_session_user_interrupt(&state, &serde_json::json!({"session_id": "A"}));
|
handle_session_user_interrupt(
|
||||||
|
&state,
|
||||||
|
&BrowserId("sender".into()),
|
||||||
|
&serde_json::json!({"session_id": "A"}),
|
||||||
|
);
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
state.session_interrupts.try_consume(&sid_a),
|
state.session_interrupts.try_consume(&sid_a),
|
||||||
@@ -611,6 +662,89 @@ mod session_user_interrupt_tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn handle_session_user_interrupt_honoured_from_owning_browser() {
|
||||||
|
let state = test_only_daemon_state();
|
||||||
|
let owner = BrowserId("owner-browser".into());
|
||||||
|
let sid = state
|
||||||
|
.sessions
|
||||||
|
.reserve_id(owner.clone(), 8, || 0)
|
||||||
|
.expect("reserved session id");
|
||||||
|
let guard = state
|
||||||
|
.tool_inflight
|
||||||
|
.register("rpc-1".into(), sid.clone())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
handle_session_user_interrupt(&state, &owner, &serde_json::json!({"session_id": sid.0}));
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
guard.entry().cancel_reason(),
|
||||||
|
Some(CancelReason::UserAborted),
|
||||||
|
"owning browser may interrupt its own session"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn handle_session_user_interrupt_ignored_from_non_owning_browser() {
|
||||||
|
let state = test_only_daemon_state();
|
||||||
|
let owner = BrowserId("owner-browser".into());
|
||||||
|
let attacker = BrowserId("attacker-browser".into());
|
||||||
|
let sid = state
|
||||||
|
.sessions
|
||||||
|
.reserve_id(owner.clone(), 8, || 0)
|
||||||
|
.expect("reserved session id");
|
||||||
|
let guard = state
|
||||||
|
.tool_inflight
|
||||||
|
.register("rpc-1".into(), sid.clone())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
handle_session_user_interrupt(&state, &attacker, &serde_json::json!({"session_id": sid.0}));
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
guard.entry().cancel_reason().is_none(),
|
||||||
|
"non-owning browser must not cancel another session's tools"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!state.session_interrupts.try_consume(&sid),
|
||||||
|
"interrupt marker must not be set by a non-owning browser"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn handle_session_window_closed_honoured_from_owning_browser() {
|
||||||
|
let state = test_only_daemon_state();
|
||||||
|
let owner = BrowserId("owner-browser".into());
|
||||||
|
let sid = state
|
||||||
|
.sessions
|
||||||
|
.reserve_id(owner.clone(), 8, || 0)
|
||||||
|
.expect("reserved session id");
|
||||||
|
|
||||||
|
handle_session_window_closed(&state, &owner, &serde_json::json!({"session_id": sid.0}));
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
state.sessions.get(&sid).is_none(),
|
||||||
|
"owning browser may close its own session"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn handle_session_window_closed_ignored_from_non_owning_browser() {
|
||||||
|
let state = test_only_daemon_state();
|
||||||
|
let owner = BrowserId("owner-browser".into());
|
||||||
|
let attacker = BrowserId("attacker-browser".into());
|
||||||
|
let sid = state
|
||||||
|
.sessions
|
||||||
|
.reserve_id(owner.clone(), 8, || 0)
|
||||||
|
.expect("reserved session id");
|
||||||
|
|
||||||
|
handle_session_window_closed(&state, &attacker, &serde_json::json!({"session_id": sid.0}));
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
state.sessions.get(&sid).is_some(),
|
||||||
|
"non-owning browser must not be able to tear down another session"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
fn test_only_daemon_state() -> std::sync::Arc<DaemonState> {
|
fn test_only_daemon_state() -> std::sync::Arc<DaemonState> {
|
||||||
use crate::daemon::start::DaemonConfig;
|
use crate::daemon::start::DaemonConfig;
|
||||||
std::sync::Arc::new(DaemonState::new(DaemonConfig::new(0)))
|
std::sync::Arc::new(DaemonState::new(DaemonConfig::new(0)))
|
||||||
|
|||||||
Reference in New Issue
Block a user