Skip to content

Commit 5350437

Browse files
authored
fix(homecore): review findings from PR #1451 — HAP secret redaction, REST cap, event_type, migration --force (#1452)
HAP accessory signing seed no longer reachable via derived Debug. /api/history/period and /api/logbook no longer break the default (unfiltered) call shape above 32 entities. fire_event's event_type validation relaxed to match real HA's contract. homecore-migrate gained a --force flag for re-running imports. Public v2051 release notes corrected. 110 tests across the 3 touched crates, 0 failed, clippy clean.
1 parent 42d56fc commit 5350437

10 files changed

Lines changed: 328 additions & 30 deletions

File tree

v2/crates/homecore-api/src/rest.rs

Lines changed: 59 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@ async fn history_response(
149149
));
150150
}
151151

152+
let explicit_filter = query.filter_entity_id.is_some();
152153
let entity_ids = match query.filter_entity_id.as_deref() {
153154
Some(raw) => raw
154155
.split(',')
@@ -167,9 +168,15 @@ async fn history_response(
167168
.map(|snapshot| snapshot.entity_id.clone())
168169
.collect(),
169170
};
170-
if entity_ids.len() > MAX_HISTORY_ENTITIES {
171+
// Only reject an explicit, unusually-large `filter_entity_id` list. The
172+
// real HA frontend's history page calls this endpoint with NO filter by
173+
// design (meaning "all entities") — a real install routinely has 50-500+
174+
// entities, so applying this cap there rejected the single most common
175+
// call shape outright. The `MAX_API_HISTORY_ROWS` total-row budget below
176+
// already bounds the actual work regardless of entity count.
177+
if explicit_filter && entity_ids.len() > MAX_HISTORY_ENTITIES {
171178
return Err(ApiError::BadRequest(format!(
172-
"history queries are limited to {MAX_HISTORY_ENTITIES} entities"
179+
"history queries are limited to {MAX_HISTORY_ENTITIES} explicitly filtered entities"
173180
)));
174181
}
175182

@@ -277,6 +284,7 @@ async fn logbook_response(
277284
"end_time must not precede start_time".into(),
278285
));
279286
}
287+
let explicit_filter = query.entity.is_some();
280288
let entity_ids = match query.entity.as_deref() {
281289
Some(raw) => raw
282290
.split(',')
@@ -295,9 +303,13 @@ async fn logbook_response(
295303
.map(|snapshot| snapshot.entity_id.clone())
296304
.collect(),
297305
};
298-
if entity_ids.len() > MAX_HISTORY_ENTITIES {
306+
// See the matching comment in `history_response`: only reject an
307+
// explicit, unusually-large filter — the default (no filter, "all
308+
// entities") is the real HA frontend's normal call shape, and the
309+
// `MAX_API_HISTORY_ROWS` row budget below already bounds the work.
310+
if explicit_filter && entity_ids.len() > MAX_HISTORY_ENTITIES {
299311
return Err(ApiError::BadRequest(format!(
300-
"logbook queries are limited to {MAX_HISTORY_ENTITIES} entities"
312+
"logbook queries are limited to {MAX_HISTORY_ENTITIES} explicitly filtered entities"
301313
)));
302314
}
303315
let mut entries = Vec::new();
@@ -592,19 +604,31 @@ pub async fn get_events(
592604
))
593605
}
594606

607+
/// Whether `event_type` is acceptable to fire on the domain bus.
608+
///
609+
/// Real Home Assistant places essentially no format restriction on event
610+
/// types beyond "non-empty string" — integrations commonly fire types with
611+
/// mixed case, dots, or hyphens (e.g. `mobile_app.notification_action`,
612+
/// `ios.action_fired`). The original check here only accepted
613+
/// `[a-z0-9_]+`, silently rejecting any of those — a real behavioral gap
614+
/// versus the documented contract, not a security boundary (this endpoint is
615+
/// already bearer-authenticated). We keep only the bounds that protect the
616+
/// server itself: non-empty, a sane length cap, and no control characters
617+
/// (which could otherwise corrupt log lines or downstream storage).
618+
pub(crate) fn is_valid_event_type(event_type: &str) -> bool {
619+
!event_type.is_empty()
620+
&& event_type.len() <= 255
621+
&& event_type.chars().all(|ch| !ch.is_control())
622+
}
623+
595624
pub async fn fire_event(
596625
headers: HeaderMap,
597626
State(s): State<SharedState>,
598627
Path(event_type): Path<String>,
599628
Json(body): Json<serde_json::Value>,
600629
) -> ApiResult<Json<serde_json::Value>> {
601630
let _ = BearerAuth::from_headers(&headers, s.tokens()).await?;
602-
if event_type.is_empty()
603-
|| event_type.len() > 255
604-
|| !event_type
605-
.chars()
606-
.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_')
607-
{
631+
if !is_valid_event_type(&event_type) {
608632
return Err(ApiError::BadRequest("invalid event_type".into()));
609633
}
610634
if !body.is_object() && !body.is_null() {
@@ -705,3 +729,28 @@ pub async fn compatibility(
705729
}
706730
})))
707731
}
732+
733+
#[cfg(test)]
734+
mod tests {
735+
use super::is_valid_event_type;
736+
737+
/// Real HA integrations commonly fire event types with mixed case, dots,
738+
/// or hyphens (e.g. `mobile_app.notification_action`). The original
739+
/// `[a-z0-9_]+`-only check rejected all of these; only non-empty,
740+
/// length, and control-character bounds should remain.
741+
#[test]
742+
fn realistic_ha_event_types_are_accepted() {
743+
assert!(is_valid_event_type("mobile_app.notification_action"));
744+
assert!(is_valid_event_type("ios.action_fired"));
745+
assert!(is_valid_event_type("Custom-Event.2"));
746+
assert!(is_valid_event_type("state_changed"));
747+
}
748+
749+
#[test]
750+
fn empty_oversized_or_control_char_event_types_are_rejected() {
751+
assert!(!is_valid_event_type(""));
752+
assert!(!is_valid_event_type(&"a".repeat(256)));
753+
assert!(!is_valid_event_type("bad\nevent"));
754+
assert!(!is_valid_event_type("bad\tevent"));
755+
}
756+
}

v2/crates/homecore-api/src/ws.rs

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -328,12 +328,7 @@ impl Connection {
328328
self.err(tx, cmd.id, "invalid_format", "event_type is required");
329329
return;
330330
};
331-
if event_type.is_empty()
332-
|| event_type.len() > 255
333-
|| !event_type
334-
.chars()
335-
.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_')
336-
{
331+
if !crate::rest::is_valid_event_type(&event_type) {
337332
self.err(tx, cmd.id, "invalid_format", "invalid event_type");
338333
return;
339334
}

v2/crates/homecore-api/tests/compatibility_surface.rs

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,3 +102,92 @@ async fn history_reads_real_recorder_rows() {
102102
assert_eq!(body[0][0]["state"], "on");
103103
assert_eq!(body[0][0]["attributes"]["brightness"], 123);
104104
}
105+
106+
/// The real HA frontend's history/logbook pages call these endpoints with NO
107+
/// entity filter by design (meaning "all entities") — a real install
108+
/// routinely has 50-500+ entities. The 32-entity cap must only reject an
109+
/// explicit, unusually-large `filter_entity_id`/`entity` list, never the
110+
/// default unfiltered "all entities" shape.
111+
#[tokio::test]
112+
async fn history_and_logbook_unfiltered_are_not_capped_by_entity_count() {
113+
use homecore::{Context, EntityId};
114+
use homecore_recorder::Recorder;
115+
116+
let homecore = HomeCore::new();
117+
let recorder = Recorder::open("sqlite::memory:").await.unwrap();
118+
for i in 0..40 {
119+
homecore.states().set(
120+
EntityId::parse(&format!("sensor.probe_{i}")).unwrap(),
121+
"on",
122+
serde_json::json!({}),
123+
Context::new(),
124+
);
125+
}
126+
assert_eq!(homecore.states().all().len(), 40, "sanity: more than MAX_HISTORY_ENTITIES");
127+
128+
let tokens = LongLivedTokenStore::empty();
129+
tokens.register("test-token").await;
130+
let state =
131+
SharedState::with_tokens(homecore, "Test", "test", tokens).with_recorder(Some(recorder));
132+
let app = router(state);
133+
134+
let history_response = app
135+
.clone()
136+
.oneshot(
137+
Request::builder()
138+
.uri("/api/history/period")
139+
.header("authorization", "Bearer test-token")
140+
.body(Body::empty())
141+
.unwrap(),
142+
)
143+
.await
144+
.unwrap();
145+
assert_eq!(
146+
history_response.status(),
147+
StatusCode::OK,
148+
"unfiltered history with >32 known entities must not be rejected"
149+
);
150+
151+
let logbook_response = app
152+
.oneshot(
153+
Request::builder()
154+
.uri("/api/logbook")
155+
.header("authorization", "Bearer test-token")
156+
.body(Body::empty())
157+
.unwrap(),
158+
)
159+
.await
160+
.unwrap();
161+
assert_eq!(
162+
logbook_response.status(),
163+
StatusCode::OK,
164+
"unfiltered logbook with >32 known entities must not be rejected"
165+
);
166+
}
167+
168+
/// An explicit, unusually-large `filter_entity_id` list is still rejected —
169+
/// only the default "no filter" shape is exempt from the cap.
170+
#[tokio::test]
171+
async fn history_explicit_oversized_filter_is_still_rejected() {
172+
use homecore_recorder::Recorder;
173+
174+
let homecore = HomeCore::new();
175+
let recorder = Recorder::open("sqlite::memory:").await.unwrap();
176+
let tokens = LongLivedTokenStore::empty();
177+
tokens.register("test-token").await;
178+
let state =
179+
SharedState::with_tokens(homecore, "Test", "test", tokens).with_recorder(Some(recorder));
180+
181+
let filter: String = (0..40).map(|i| format!("sensor.probe_{i}")).collect::<Vec<_>>().join(",");
182+
let response = router(state)
183+
.oneshot(
184+
Request::builder()
185+
.uri(format!("/api/history/period?filter_entity_id={filter}"))
186+
.header("authorization", "Bearer test-token")
187+
.body(Body::empty())
188+
.unwrap(),
189+
)
190+
.await
191+
.unwrap();
192+
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
193+
}

v2/crates/homecore-hap/src/pairing.rs

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -144,20 +144,44 @@ pub struct PairingStoreProvisioning {
144144
pub setup_code: Option<SetupCode>,
145145
}
146146

147-
#[derive(Debug, Clone, Serialize, Deserialize)]
147+
#[derive(Clone, Serialize, Deserialize)]
148148
#[serde(deny_unknown_fields)]
149149
struct StoredAccessory {
150150
device_id: String,
151151
signing_seed: [u8; 32],
152152
}
153153

154-
#[derive(Debug, Clone, Serialize, Deserialize)]
154+
// Manual, redacted impl: `signing_seed` is the accessory's permanent Ed25519
155+
// identity key, used to sign every Pair-Setup/Pair-Verify transcript for the
156+
// device's whole lifetime with no rotation mechanism. A derived `Debug` would
157+
// print it in plaintext the first time anything formats this struct (a log
158+
// line, a panic message) — same rationale as `SetupCode`'s manual impl below.
159+
impl std::fmt::Debug for StoredAccessory {
160+
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
161+
formatter
162+
.debug_struct("StoredAccessory")
163+
.field("device_id", &self.device_id)
164+
.field("signing_seed", &"[REDACTED]")
165+
.finish()
166+
}
167+
}
168+
169+
#[derive(Clone, Serialize, Deserialize)]
155170
#[serde(deny_unknown_fields)]
156171
struct StoredSetup {
157172
salt: [u8; 16],
158173
verifier: Vec<u8>,
159174
}
160175

176+
// Manual, redacted impl: `salt`/`verifier` are the SRP-6a material derived
177+
// from the setup code. Printing them would hand an attacker exactly what an
178+
// offline dictionary attack against the (8-digit) setup code needs.
179+
impl std::fmt::Debug for StoredSetup {
180+
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
181+
formatter.write_str("StoredSetup([REDACTED])")
182+
}
183+
}
184+
161185
impl Drop for StoredAccessory {
162186
fn drop(&mut self) {
163187
self.signing_seed.zeroize();
@@ -679,6 +703,40 @@ mod tests {
679703
assert_eq!(format!("{code:?}"), "SetupCode([REDACTED])");
680704
}
681705

706+
/// A stray `format!("{store:?}")` (a future debug log line, a panic
707+
/// message) must never print the accessory's permanent Ed25519 signing
708+
/// seed or the SRP salt/verifier — both are compromise-forever secrets
709+
/// with no rotation mechanism.
710+
#[test]
711+
fn stored_accessory_and_setup_debug_never_print_secret_material() {
712+
let accessory = StoredAccessory {
713+
device_id: "AA:BB:CC:DD:EE:FF".into(),
714+
signing_seed: [0x42; 32],
715+
};
716+
let rendered = format!("{accessory:?}");
717+
assert!(rendered.contains("device_id"));
718+
assert!(rendered.contains("AA:BB:CC:DD:EE:FF"));
719+
assert!(!rendered.contains("66"), "hex of 0x42 must not leak: {rendered}");
720+
assert_eq!(
721+
rendered,
722+
"StoredAccessory { device_id: \"AA:BB:CC:DD:EE:FF\", signing_seed: \"[REDACTED]\" }"
723+
);
724+
725+
let setup = StoredSetup { salt: [0x7a; 16], verifier: vec![0x13; 8] };
726+
assert_eq!(format!("{setup:?}"), "StoredSetup([REDACTED])");
727+
728+
// The redaction must propagate through every derived-Debug container
729+
// that embeds these structs, with no further code changes needed.
730+
let state = StoreState {
731+
accessory,
732+
setup,
733+
controllers: std::collections::BTreeMap::new(),
734+
};
735+
let rendered_state = format!("{state:?}");
736+
assert!(!rendered_state.contains("0x42"));
737+
assert!(rendered_state.contains("[REDACTED]"));
738+
}
739+
682740
#[test]
683741
fn removing_last_admin_clears_all_pairings() {
684742
let directory = tempfile::tempdir().unwrap();

v2/crates/homecore-migrate/src/cli.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,11 @@ pub struct ImportEntitiesArgs {
4848
/// Path to the HOMECORE storage directory (destination).
4949
#[arg(long)]
5050
pub to: PathBuf,
51+
/// Overwrite an existing destination file instead of refusing. Use this
52+
/// to re-run an import after fixing a bad source row, or to re-import
53+
/// after further changes on the HA side.
54+
#[arg(long)]
55+
pub force: bool,
5156
}
5257

5358
#[derive(Debug, clap::Args)]
@@ -58,6 +63,11 @@ pub struct ImportDevicesArgs {
5863
/// Path to the HOMECORE storage directory (destination).
5964
#[arg(long)]
6065
pub to: PathBuf,
66+
/// Overwrite an existing destination file instead of refusing. Use this
67+
/// to re-run an import after fixing a bad source row, or to re-import
68+
/// after further changes on the HA side.
69+
#[arg(long)]
70+
pub force: bool,
6171
}
6272

6373
#[derive(Debug, clap::Args)]
@@ -68,6 +78,11 @@ pub struct ImportConfigEntriesArgs {
6878
/// Path to the HOMECORE storage directory (destination).
6979
#[arg(long)]
7080
pub to: PathBuf,
81+
/// Overwrite an existing destination file instead of refusing. Use this
82+
/// to re-run an import after fixing a bad source row, or to re-import
83+
/// after further changes on the HA side.
84+
#[arg(long)]
85+
pub force: bool,
7186
}
7287

7388
#[derive(Debug, clap::Args)]

v2/crates/homecore-migrate/src/config_entries.rs

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ use std::path::{Path, PathBuf};
1111
use serde::{Deserialize, Serialize};
1212

1313
use crate::{
14-
storage::{read_envelope, write_json_atomic_noclobber, HaStorageEnvelope},
14+
storage::{read_envelope, write_json_atomic, HaStorageEnvelope},
1515
MigrateError,
1616
};
1717

@@ -183,9 +183,20 @@ pub fn convert_config_entries(path: &Path) -> Result<HomeCoreConfigEnvelope, Mig
183183
pub fn write_config_entries(
184184
storage_dir: &Path,
185185
envelope: &HomeCoreConfigEnvelope,
186+
) -> Result<PathBuf, MigrateError> {
187+
write_config_entries_with(storage_dir, envelope, false)
188+
}
189+
190+
/// As [`write_config_entries`], but `force = true` atomically replaces an
191+
/// existing destination instead of refusing — the escape hatch for
192+
/// re-running an import after fixing a bad source row.
193+
pub fn write_config_entries_with(
194+
storage_dir: &Path,
195+
envelope: &HomeCoreConfigEnvelope,
196+
force: bool,
186197
) -> Result<PathBuf, MigrateError> {
187198
let target = storage_dir.join(DESTINATION_KEY);
188-
write_json_atomic_noclobber(&target, envelope)
199+
write_json_atomic(&target, envelope, force)
189200
}
190201

191202
pub fn read_homecore_config_entries(path: &Path) -> Result<HomeCoreConfigEnvelope, MigrateError> {

0 commit comments

Comments
 (0)