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
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue