ipc: close unauthenticated event-spoofing gap in emit's no-source path
The IPC "emit" method's no-source path took a bare event+data and sent it
straight to emit_tx tagged AdapterSource::System with zero validation of
the event name. Any same-UID process on the socket could send e.g.
{"event":"bread.power.ac.connected",...} and have it delivered to every
Lua subscriber indistinguishable from a real adapter event, since System
is the same tag the daemon uses for its own trusted, Rust-originated
sends (bread.system.startup, bread.profile.activated).
Fix, scoped to the actual threat model (same-UID Unix socket trust means
there's no way to cryptographically distinguish "the real bread-cli
binary" from any other local process, so a generic connection-identity
handshake would be theater):
- New AdapterSource::Manual tag for the no-source emit path. System is
now reserved for daemon-internal, Rust-code-originated sends only and
can never again be produced from data that arrived over the wire.
- The event name is rejected if its top-level dotted segment is one of
the reserved, adapter-owned domains (RESERVED_DOMAINS in
bread-shared/src/apps.rs) -- extended with bluetooth/workspace/window/
monitor, event families the Hyprland and Bluetooth adapters already
publish under but that were missing from that list. Freely-named
custom/test event names are untouched, so `bread emit <name>` and
bread-emit's fire-and-forget single-line-write design keep working
exactly as documented.
Bumped API_VERSION to 1.5.0 and updated Documentation.md's IPC emit
section and Namespaces reserved-domains list accordingly.
Also fixed a subscribe/emit race that surfaced while adding regression
tests for this: events.subscribe's ack is written to the client before
the server task actually registers on the broadcast channel, so a test
that emits immediately after reading the ack can race the registration.
Added a settle delay plus an explicit timeout (instead of an unbounded
read loop) so a future regression fails the test instead of hanging the
whole binary.
This commit is contained in:
parent
96639516b1
commit
7bb6fbb20f
6 changed files with 281 additions and 11 deletions
|
|
@ -8,7 +8,7 @@ use std::sync::Arc;
|
|||
use std::time::Instant;
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use bread_shared::apps::{is_known_app, validate_app_namespace};
|
||||
use bread_shared::apps::{event_domain, is_known_app, is_reserved_domain, validate_app_namespace};
|
||||
use bread_shared::{now_unix_ms, AdapterSource, BreadEvent, RawEvent};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
|
|
@ -27,7 +27,7 @@ use crate::lua::RuntimeHandle;
|
|||
/// something new-but-additive (a binding, an event, an IPC param); bump the
|
||||
/// major version only for a breaking change, which should not happen inside
|
||||
/// this daemon's v1 lifetime per that section's stated policy.
|
||||
const API_VERSION: &str = "1.4.0";
|
||||
const API_VERSION: &str = "1.5.0";
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Server {
|
||||
|
|
@ -323,12 +323,35 @@ impl Server {
|
|||
}
|
||||
Ok(json!({ "emitted": true }))
|
||||
} else {
|
||||
// Unsourced emit: the manual-testing path ("bread emit
|
||||
// <event>", used to poke Lua handlers without unplugging
|
||||
// cables). Tagged `Manual`, never `System` — `System` is
|
||||
// reserved for events the daemon originates itself in
|
||||
// Rust code (e.g. `bread.system.startup` in `serve()`
|
||||
// above), not for anything that arrived over the wire.
|
||||
// A socket client can still name any custom/test event
|
||||
// it likes, but not one whose top-level segment is a
|
||||
// reserved, adapter-owned domain (`bread.power.*`,
|
||||
// `bread.hyprland.*`, ...) — otherwise this path would
|
||||
// let any same-UID process impersonate a real adapter
|
||||
// event with nothing downstream able to tell the
|
||||
// difference.
|
||||
let Some(event) = req.params.get("event").and_then(Value::as_str) else {
|
||||
return Err((id, "missing event name".to_string()));
|
||||
};
|
||||
if let Some(domain) = event_domain(event) {
|
||||
if is_reserved_domain(domain) {
|
||||
return Err((
|
||||
id,
|
||||
format!(
|
||||
"event '{event}' claims the reserved '{domain}' domain — manual emit cannot impersonate an adapter-owned event; use a custom event name, or a sourced emit if this should go through the normalizer"
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
if self
|
||||
.emit_tx
|
||||
.send(BreadEvent::new(event, AdapterSource::System, data))
|
||||
.send(BreadEvent::new(event, AdapterSource::Manual, data))
|
||||
.is_err()
|
||||
{
|
||||
return Err((id, "emit channel closed".to_string()));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue