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:
Breadway 2026-08-04 17:41:18 +08:00
parent 96639516b1
commit 7bb6fbb20f
6 changed files with 281 additions and 11 deletions

View file

@ -18,6 +18,18 @@ pub const KNOWN_APPS: &[&str] = &[
/// app id, even if a future `bread*` app would otherwise want that name —
/// these are the top-level segments the normalizer and built-in event
/// families already use.
///
/// This is also the single source of truth the IPC boundary checks before
/// allowing a manual (no-`source`) `emit` request to use an event name — a
/// socket client may freely emit a custom/test event, but not one whose
/// top-level segment is one of these, since that would let it impersonate
/// a real adapter (or another daemon-internal event family) rather than
/// producing an obviously-manual one. See [`is_reserved_domain`] and
/// `breadd/src/ipc/mod.rs`'s `emit` handler. *Since: v1.5 — `bluetooth`,
/// `workspace`, `window`, and `monitor` added (event families the Hyprland
/// and Bluetooth adapters already published under, but that were missing
/// from this list) when this became a spoofing-prevention boundary and not
/// just an app-id-conflict one.*
const RESERVED_DOMAINS: &[&str] = &[
"terminal",
"git",
@ -34,6 +46,10 @@ const RESERVED_DOMAINS: &[&str] = &[
"notify",
"command",
"workflow",
"bluetooth",
"workspace",
"window",
"monitor",
];
/// Whether `id` is a registered sibling-app id.
@ -55,6 +71,15 @@ pub fn validate_app_namespace(app: &str, event: &str) -> bool {
event.starts_with(&format!("bread.{app}."))
}
/// The top-level dotted segment after `bread.` in an event name — e.g.
/// `Some("power")` for `"bread.power.ac.connected"`. Returns `None` for
/// event names that don't start with `bread.` at all, which are always
/// outside any reserved namespace (freely-named custom/test events, the
/// `bread emit <name>` debug use case, never take this prefix).
pub fn event_domain(event: &str) -> Option<&str> {
event.strip_prefix("bread.")?.split('.').next()
}
#[cfg(test)]
mod tests {
use super::*;
@ -88,6 +113,35 @@ mod tests {
assert!(!is_reserved_domain("clip"));
}
#[test]
fn reserved_domains_cover_every_adapter_owned_event_family() {
// Every top-level segment a real adapter (via the normalizer) or the
// daemon itself publishes under must be reserved, or a manual/no-source
// `emit` over the IPC socket could impersonate it undetected.
for domain in [
"power", "network", "device", "bluetooth", "hyprland", "workspace", "monitor",
"window", "system",
] {
assert!(
is_reserved_domain(domain),
"'{domain}' is an adapter-owned event family and must be reserved"
);
}
}
#[test]
fn event_domain_extracts_top_level_segment() {
assert_eq!(event_domain("bread.power.ac.connected"), Some("power"));
assert_eq!(event_domain("bread.custom.event"), Some("custom"));
assert_eq!(event_domain("bread.test"), Some("test"));
}
#[test]
fn event_domain_is_none_without_bread_prefix() {
assert_eq!(event_domain("power.ac.connected"), None);
assert_eq!(event_domain(""), None);
}
#[test]
fn validate_app_namespace_accepts_own_namespace() {
assert!(validate_app_namespace("clip", "bread.clip.copied"));

View file

@ -32,9 +32,25 @@ pub enum AdapterSource {
Power,
/// Network state (rtnetlink / NetworkManager).
Network,
/// Internal events synthesized by the daemon itself
/// (e.g. `bread.profile.activated`, `bread.state.changed.*`).
/// Internal events synthesized by the daemon itself, i.e. trusted,
/// Rust-code-originated sends via `emit_tx` (e.g. `bread.system.startup`,
/// `bread.profile.activated`, `bread.state.changed.*`, and Lua's
/// `bread.emit()` binding). Never assignable from data that arrived
/// over the IPC socket — see [`Manual`](AdapterSource::Manual) for that
/// case. *Since: v1.5 — this constraint is now enforced; previously the
/// IPC `emit` method's no-`source` path could also tag events `System`.*
System,
/// A manual `emit` IPC request with no `source` param — a human or
/// script poked the daemon's Unix socket directly (e.g. `bread emit
/// <event>` for testing Lua handlers without unplugging cables).
/// Distinct from [`System`](AdapterSource::System) so downstream Lua
/// modules and tooling can tell "someone manually injected this event"
/// apart from "a real adapter observed this" or "the daemon itself
/// produced this." The IPC boundary restricts which event names may be
/// tagged this way — it may not claim an adapter-owned namespace (see
/// `apps::is_reserved_domain`), but is otherwise free for custom/test
/// event names. *Since: v1.5*
Manual,
/// BlueZ Bluetooth stack via D-Bus.
Bluetooth,
/// Shell precmd/preexec hooks (terminal command lifecycle, cwd changes).
@ -222,6 +238,10 @@ mod tests {
serde_json::to_string(&AdapterSource::System).unwrap(),
"\"system\""
);
assert_eq!(
serde_json::to_string(&AdapterSource::Manual).unwrap(),
"\"manual\""
);
assert_eq!(
serde_json::to_string(&AdapterSource::Bluetooth).unwrap(),
"\"bluetooth\""
@ -268,6 +288,7 @@ mod tests {
AdapterSource::Power,
AdapterSource::Network,
AdapterSource::System,
AdapterSource::Manual,
AdapterSource::Bluetooth,
AdapterSource::Terminal,
AdapterSource::Git,