mirror of
https://github.com/ruvnet/RuView
synced 2026-08-06 19:51:43 +00:00
fix(bfld security): close HIGH privacy-bypass in process_to_frame (identity surface leaked despite restrictive class) + JSON-injection (#1075)
* fix(bfld): route process_to_frame payload through PrivacyGate (ADR-141 privacy bypass) BfldPipeline::process_to_frame stamped the frame header with the active privacy class but serialized the caller-supplied BfldPayload UNCHANGED via BfldFrame::from_payload. This let a frame labeled Anonymous(2) or Restricted(3) carry the full identity-leaky compressed_angle_matrix (+ amplitude/phase proxies, csi_delta) that PrivacyGate::demote is documented and tested (privacy_gate_demote.rs) to strip at exactly those classes. A NetworkSink accepts class >= Derived(1), so such a frame would publish the beamforming angle matrix — the identity surface — across the node boundary despite its restrictive class byte. The class byte lied about payload content. Fix: after building the frame at the active class, apply PrivacyGate::demote to the same class. demote() strips sections by target-class threshold (independent of any class transition), so a same-class demote performs no class change but brings the payload into policy compliance. Research classes (Raw/Derived) keep the full payload — demote is a no-op there. Pinned by three fails-on-old tests in pipeline_to_frame.rs: - process_to_frame_at_anonymous_strips_identity_leaky_sections (FAILED pre-fix) - process_to_frame_in_privacy_mode_strips_amplitude_and_phase (FAILED pre-fix) - process_to_frame_at_derived_preserves_full_payload (guards against over-strip) The pre-existing round-trip test is updated to assert the gated payload. Co-Authored-By: claude-flow <ruv@ruv.net> * fix(bfld): JSON-escape zone_id in MQTT state-topic payload render_events emitted the zone_activity payload as format!("\"{zone}\"") with no escaping, while ha_discovery.rs already escapes operator-controlled strings via push_str_field. A zone name containing a double-quote or backslash therefore produced malformed / injectable JSON on the state topic that Home Assistant parses (e.g. zone `a"b` -> payload `"a"b"`). Fix: add json_string_literal() mirroring ha_discovery's escaping (", \, \n, \r, \t, control chars) and use it for the zone payload. Value-identical for normal zone names (living_room etc.). Pinned by zone_payload_escapes_json_metacharacters (FAILED pre-fix); the existing zone_payload_is_json_string_with_quotes still passes unchanged. Co-Authored-By: claude-flow <ruv@ruv.net> * docs(adr-141): record bfld privacy+security review findings + CHANGELOG Document the two fixed bugs (process_to_frame privacy-bypass; zone_id JSON injection) and the dimensions confirmed clean (event-field gating, witness/hash framing, fail-closed) in ADR-141, plus CHANGELOG [Unreleased] Security/Fixed entries. Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
@@ -135,10 +135,13 @@ pub fn render_events(event: &BfldEvent) -> Vec<TopicMessage> {
|
||||
|
||||
if let Some(zone) = &event.zone_id {
|
||||
// Emit a JSON string so consumers can distinguish "no zone" (omitted)
|
||||
// from "single-zone deployment" (always the same zone string).
|
||||
// from "single-zone deployment" (always the same zone string). The zone
|
||||
// name is operator-controlled; escape JSON metacharacters so a name
|
||||
// containing a quote or backslash cannot produce malformed/injected
|
||||
// JSON. Mirrors ha_discovery.rs::push_str_field's escaping.
|
||||
out.push(TopicMessage {
|
||||
topic: TopicMessage::ruview_topic(node, "zone_activity"),
|
||||
payload: format!("\"{zone}\""),
|
||||
payload: json_string_literal(zone),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -155,3 +158,26 @@ pub fn render_events(event: &BfldEvent) -> Vec<TopicMessage> {
|
||||
|
||||
out
|
||||
}
|
||||
|
||||
/// Wrap `value` in JSON double-quote delimiters, escaping the metacharacters
|
||||
/// that would otherwise break out of the string literal (`"`, `\`, control
|
||||
/// chars, and the bare `\n`/`\r`/`\t` whitespace). Kept in lockstep with
|
||||
/// `ha_discovery::push_str_field` so state-topic and discovery payloads escape
|
||||
/// identically.
|
||||
fn json_string_literal(value: &str) -> String {
|
||||
let mut out = String::with_capacity(value.len() + 2);
|
||||
out.push('"');
|
||||
for ch in value.chars() {
|
||||
match ch {
|
||||
'"' => out.push_str("\\\""),
|
||||
'\\' => out.push_str("\\\\"),
|
||||
'\n' => out.push_str("\\n"),
|
||||
'\r' => out.push_str("\\r"),
|
||||
'\t' => out.push_str("\\t"),
|
||||
c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
|
||||
c => out.push(c),
|
||||
}
|
||||
}
|
||||
out.push('"');
|
||||
out
|
||||
}
|
||||
|
||||
@@ -141,6 +141,15 @@ impl BfldPipeline {
|
||||
/// builds the frame via [`BfldFrame::from_payload`] so the CRC covers the
|
||||
/// section-prefixed bytes.
|
||||
///
|
||||
/// The emitted frame's payload is forced into compliance with the active
|
||||
/// privacy class via [`crate::PrivacyGate::demote`]: at `Anonymous` the
|
||||
/// identity-leaky `compressed_angle_matrix` and `csi_delta` sections are
|
||||
/// stripped, and at `Restricted` the amplitude/phase proxies are stripped
|
||||
/// too. This closes the gap (ADR-141) where a frame stamped with a
|
||||
/// restrictive class byte could otherwise carry the full high-information
|
||||
/// BFI payload across a [`crate::NetworkSink`]. Research classes (`Raw`,
|
||||
/// `Derived`) keep the full payload — `demote` is a no-op there.
|
||||
///
|
||||
/// Returns `None` whenever the gate drops the underlying event (Reject or
|
||||
/// Recalibrate), so `process_to_frame` is a strict subset of `process`.
|
||||
pub fn process_to_frame(
|
||||
@@ -151,11 +160,21 @@ impl BfldPipeline {
|
||||
embedding: Option<IdentityEmbedding>,
|
||||
) -> Option<BfldFrame> {
|
||||
let timestamp_ns = inputs.timestamp_ns;
|
||||
let active_class = self.current_privacy_class();
|
||||
let _gate_signal = self.process(inputs, embedding)?;
|
||||
let mut header = header_template;
|
||||
header.timestamp_ns = timestamp_ns;
|
||||
header.privacy_class = self.current_privacy_class().as_u8();
|
||||
Some(BfldFrame::from_payload(header, &payload))
|
||||
header.privacy_class = active_class.as_u8();
|
||||
let frame = BfldFrame::from_payload(header, &payload);
|
||||
// Enforce the payload-content policy for the stamped class. The frame
|
||||
// is already at `active_class`, so this is a same-class demotion: it
|
||||
// performs no class change but strips the sections that class forbids.
|
||||
// demote() only fails on InvalidDemote (target < source), which cannot
|
||||
// happen here because source == target, so the expect is unreachable.
|
||||
Some(
|
||||
crate::PrivacyGate::demote(frame, active_class)
|
||||
.expect("same-class demote is always valid"),
|
||||
)
|
||||
}
|
||||
|
||||
/// `true` if `enable_privacy_mode()` has been called more recently than
|
||||
|
||||
@@ -127,6 +127,38 @@ fn zone_payload_is_json_string_with_quotes() {
|
||||
assert_eq!(zone.payload, "\"living_room\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zone_payload_escapes_json_metacharacters() {
|
||||
// A zone name containing a double-quote or backslash must not break out of
|
||||
// the JSON string literal it is emitted into. ha_discovery.rs already
|
||||
// escapes operator-controlled strings via push_str_field; render_events
|
||||
// must do the same for parity so the state-topic payload is always valid
|
||||
// JSON that Home Assistant can parse.
|
||||
let ev = BfldEvent::with_privacy_gating(
|
||||
"seed-01".into(),
|
||||
0,
|
||||
true,
|
||||
0.1,
|
||||
1,
|
||||
0.9,
|
||||
Some(r#"living"room\back"#.into()),
|
||||
PrivacyClass::Anonymous,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let msgs = render_events(&ev);
|
||||
let zone = msgs
|
||||
.iter()
|
||||
.find(|m| m.topic.contains("zone_activity"))
|
||||
.expect("zone_activity topic");
|
||||
// Expected: the inner quote and backslash are backslash-escaped, wrapped in
|
||||
// one pair of unescaped delimiter quotes -> a single valid JSON string.
|
||||
assert_eq!(zone.payload, r#""living\"room\\back""#);
|
||||
// And it must parse as JSON back to the original zone string.
|
||||
let parsed: String = serde_json::from_str(&zone.payload).expect("valid JSON string");
|
||||
assert_eq!(parsed, r#"living"room\back"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_risk_payload_is_fixed_precision_decimal() {
|
||||
let msgs = render_events(&sample_event(PrivacyClass::Anonymous, false));
|
||||
|
||||
@@ -88,6 +88,11 @@ fn process_to_frame_returns_none_under_sustained_high_risk() {
|
||||
|
||||
#[test]
|
||||
fn process_to_frame_round_trips_through_bytes() {
|
||||
// Default pipeline class is Anonymous(2). The frame must round-trip through
|
||||
// wire bytes with no CRC error; the payload it carries is the privacy-gated
|
||||
// (angle-matrix-stripped) form, not the raw input — see
|
||||
// process_to_frame_at_anonymous_strips_identity_leaky_sections for the
|
||||
// content assertion. This test pins byte/CRC consistency only.
|
||||
let mut p = BfldPipeline::new(BfldConfig::new("seed-01"));
|
||||
let frame = p
|
||||
.process_to_frame(
|
||||
@@ -100,7 +105,10 @@ fn process_to_frame_round_trips_through_bytes() {
|
||||
let bytes = frame.to_bytes();
|
||||
let parsed = BfldFrame::from_bytes(&bytes).expect("frame must round-trip");
|
||||
let parsed_payload = parsed.parse_payload().expect("payload must round-trip");
|
||||
assert_eq!(parsed_payload, typed_payload());
|
||||
// Round-trip preserves whatever the privacy gate left in place.
|
||||
assert_eq!(parsed_payload, frame.parse_payload().unwrap());
|
||||
// And the identity surface is gone at Anonymous.
|
||||
assert!(parsed_payload.compressed_angle_matrix.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -141,6 +149,94 @@ fn process_to_frame_preserves_header_template_identity_fields() {
|
||||
assert_eq!({ frame.header.channel }, 36);
|
||||
}
|
||||
|
||||
// --- ADR-141 privacy-gate-correctness regression -------------------------
|
||||
//
|
||||
// `process_to_frame` stamps the frame with the pipeline's privacy_class but
|
||||
// (pre-fix) serialized the caller-supplied payload UNCHANGED. That let a frame
|
||||
// labeled Anonymous(2) / Restricted(3) carry the full identity-leaky
|
||||
// `compressed_angle_matrix` (+ amplitude/phase/csi_delta) that
|
||||
// `PrivacyGate::demote` is documented (privacy_gate_demote.rs) to strip at
|
||||
// exactly those classes. A NetworkSink accepts class >= Derived, so such a
|
||||
// frame would publish the beamforming angle matrix (identity surface) to the
|
||||
// network despite its restrictive class byte. These tests pin that the payload
|
||||
// content matches what the stamped class permits.
|
||||
|
||||
#[test]
|
||||
fn process_to_frame_at_anonymous_strips_identity_leaky_sections() {
|
||||
// Default pipeline class is Anonymous(2): the angle matrix and csi_delta
|
||||
// MUST NOT survive into the emitted frame, matching PrivacyGate::demote.
|
||||
let mut p = BfldPipeline::new(BfldConfig::new("seed-01"));
|
||||
let mut leaky = typed_payload();
|
||||
leaky.csi_delta = Some(vec![0x55; 24]);
|
||||
let frame = p
|
||||
.process_to_frame(
|
||||
inputs(1_700_000_000_000_000_000, [0.1, 0.1, 0.1, 0.1]),
|
||||
header_template(),
|
||||
leaky,
|
||||
Some(embedding()),
|
||||
)
|
||||
.expect("low-risk frame must be emitted");
|
||||
assert_eq!({ frame.header.privacy_class }, PrivacyClass::Anonymous.as_u8());
|
||||
let payload = frame.parse_payload().expect("payload parses");
|
||||
assert!(
|
||||
payload.compressed_angle_matrix.is_empty(),
|
||||
"Anonymous frame must NOT carry the compressed_angle_matrix (identity surface)",
|
||||
);
|
||||
assert!(
|
||||
payload.csi_delta.is_none(),
|
||||
"Anonymous frame must NOT carry csi_delta",
|
||||
);
|
||||
// Aggregate sensing sections survive.
|
||||
assert_eq!(payload.snr_vector.len(), 8);
|
||||
assert_eq!(payload.amplitude_proxy.len(), 16);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_to_frame_in_privacy_mode_strips_amplitude_and_phase() {
|
||||
// privacy_mode -> Restricted(3): amplitude + phase proxies must ALSO drop.
|
||||
let mut p = BfldPipeline::new(
|
||||
BfldConfig::new("seed-01").with_privacy_class(PrivacyClass::Anonymous),
|
||||
);
|
||||
p.enable_privacy_mode();
|
||||
let frame = p
|
||||
.process_to_frame(
|
||||
inputs(0, [0.1, 0.1, 0.1, 0.1]),
|
||||
header_template(),
|
||||
typed_payload(),
|
||||
Some(embedding()),
|
||||
)
|
||||
.expect("frame emitted");
|
||||
assert_eq!({ frame.header.privacy_class }, PrivacyClass::Restricted.as_u8());
|
||||
let payload = frame.parse_payload().expect("payload parses");
|
||||
assert!(payload.compressed_angle_matrix.is_empty(), "angle matrix stripped at Restricted");
|
||||
assert!(payload.amplitude_proxy.is_empty(), "amplitude stripped at Restricted");
|
||||
assert!(payload.phase_proxy.is_empty(), "phase stripped at Restricted");
|
||||
assert_eq!(payload.snr_vector.len(), 8, "snr_vector survives");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_to_frame_at_derived_preserves_full_payload() {
|
||||
// Derived(1) is a research mode that legitimately keeps the angle matrix.
|
||||
// The strip must NOT over-fire at classes below Anonymous.
|
||||
let mut p = BfldPipeline::new(
|
||||
BfldConfig::new("seed-01").with_privacy_class(PrivacyClass::Derived),
|
||||
);
|
||||
let frame = p
|
||||
.process_to_frame(
|
||||
inputs(0, [0.1, 0.1, 0.1, 0.1]),
|
||||
header_template(),
|
||||
typed_payload(),
|
||||
Some(embedding()),
|
||||
)
|
||||
.expect("frame emitted");
|
||||
assert_eq!({ frame.header.privacy_class }, PrivacyClass::Derived.as_u8());
|
||||
let payload = frame.parse_payload().expect("payload parses");
|
||||
assert_eq!(
|
||||
payload, typed_payload(),
|
||||
"Derived research frame keeps the full payload unchanged",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_to_frame_uses_input_timestamp_not_template_timestamp() {
|
||||
let mut p = BfldPipeline::new(BfldConfig::new("seed-01"));
|
||||
|
||||
Reference in New Issue
Block a user