@@ -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+
595624pub 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\n event" ) ) ;
754+ assert ! ( !is_valid_event_type( "bad\t event" ) ) ;
755+ }
756+ }
0 commit comments