Skip to content

Commit 12b0764

Browse files
committed
fix(opcua,#386): input validation + cross-pipeline alarm collision check (bburda review)
Two security/correctness gaps from bburda's review on PR #387: (1) **fault_code / comment unbounded input.** ``execute_operation`` for ``acknowledge_fault`` / ``confirm_fault`` accepted operator input without length cap or charset filter: - ``fault_code`` was concatenated into log lines and HTTP error bodies via ``"...fault_code=\" + fault_code + \"...\"`` - newlines, control chars, or multi-MB blobs would land verbatim in gateway logs. - ``comment`` was wrapped as ``LocalizedText`` and forwarded verbatim to the PLC's condition log - an authenticated operator role could spam the PLC with multi-MB blobs on every ack. Both now validated at the operation entry: ``fault_code`` must match ``is_valid_path_segment`` (alphanumeric + ``_`` + ``-``, 1-256 chars, matching the existing entity-id bound) and ``comment`` is capped at 256 chars. Failures return HTTP 400 with the actual length so the operator sees the bound, not a silent truncation. (2) **Cross-pipeline alarm collision uncaught.** The previous mutual-exclusion check in ``NodeMap::load`` (which I added in response to Copilot review) caught ``alarm_source`` misplaced under ``nodes:``, but missed the genuine collision worth checking: the same ``(entity_id, fault_code)`` declared by both threshold polling (``nodes[*].alarm``) and event subscription (``event_alarms``). fault_manager would receive both pipelines' calls and the resulting status flapping is impossible to debug at runtime; the two paths have different semantics (debounced vs state-machine) so a merge is not even well-defined. ``NodeMap::load`` now builds a set of ``(entity_id, fault_code)`` keys from ``event_alarms`` and rejects the whole file if any ``nodes[*].alarm`` matches a key already claimed by an event alarm. Two new gtest cases lock the contract: - ``RejectsThresholdEventAlarmCollision`` - same ``(tank_process, PLC_OVERPRESSURE)`` declared by both -> load returns false. - ``AcceptsDifferentFaultCodesAcrossPipelines`` - same entity but distinct fault_codes per pipeline -> load passes (no false-positive). Local verify: 34/34 test_node_map + 13/13 test_opcua_plugin.
1 parent dac0b72 commit 12b0764

3 files changed

Lines changed: 107 additions & 0 deletions

File tree

src/ros2_medkit_plugins/ros2_medkit_opcua/src/node_map.cpp

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,11 @@
2121
#include <fstream>
2222
#include <iostream>
2323
#include <optional>
24+
#include <set>
2425
#include <sstream>
2526
#include <stdexcept>
2627
#include <string>
28+
#include <utility>
2729

2830
#include <nlohmann/json.hpp>
2931
#include <rclcpp/rclcpp.hpp>
@@ -351,6 +353,30 @@ bool NodeMap::load(const std::string & yaml_path) {
351353
}
352354
}
353355

356+
// Cross-section collision check (bburda review on PR #387). The two
357+
// alarm pipelines - threshold polling under ``nodes[*].alarm`` and
358+
// native event subscription under ``event_alarms`` - must not address
359+
// the same ``(entity_id, fault_code)``: fault_manager would receive
360+
// both ``report_fault`` calls and the resulting status flapping is
361+
// impossible to debug at runtime. The two paths produce different
362+
// semantics (debounced vs state-machine driven) so the merge is not
363+
// even well-defined.
364+
{
365+
std::set<std::pair<std::string, std::string>> event_keys;
366+
for (const auto & cfg : event_alarms_) {
367+
event_keys.emplace(cfg.entity_id, cfg.fault_code);
368+
}
369+
for (const auto & entry : entries_) {
370+
if (entry.alarm.has_value() && event_keys.count({entry.entity_id, entry.alarm->fault_code}) > 0) {
371+
RCLCPP_ERROR(rclcpp::get_logger("opcua.node_map"),
372+
"Duplicate (entity_id=%s, fault_code=%s) declared by both nodes[*].alarm "
373+
"(threshold mode) and event_alarms[*] (subscription mode) - mutually exclusive",
374+
entry.entity_id.c_str(), entry.alarm->fault_code.c_str());
375+
return false;
376+
}
377+
}
378+
}
379+
354380
build_entity_defs();
355381
return true;
356382

src/ros2_medkit_plugins/ros2_medkit_opcua/src/opcua_plugin.cpp

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -909,6 +909,27 @@ OpcuaPlugin::execute_operation(const std::string & entity_id, const std::string
909909
std::string fault_code = parameters["fault_code"].get<std::string>();
910910
std::string comment = parameters.value("comment", std::string{});
911911

912+
// Input validation (bburda review on PR #387). ``fault_code`` lands in
913+
// log lines and HTTP error bodies via string concatenation; without
914+
// length / charset bounds an authenticated operator can inject
915+
// newlines, control chars, or multi-MB blobs into gateway logs.
916+
// ``comment`` is forwarded verbatim to the PLC as ``LocalizedText`` -
917+
// unbounded length lets the same role spam the PLC's condition log.
918+
// 256 chars matches the existing entity-id bound (``is_valid_path_segment``)
919+
// and is generous enough for typical ``PLC_OVERPRESSURE_SENSOR_3`` style
920+
// identifiers + a one-sentence operator comment.
921+
if (!is_valid_path_segment(fault_code)) {
922+
return tl::make_unexpected(OperationProviderErrorInfo{OperationProviderError::InvalidParameters,
923+
"fault_code must be alphanumeric+_- and 1-256 chars (got " +
924+
std::to_string(fault_code.size()) + " chars)",
925+
400});
926+
}
927+
if (comment.size() > 256) {
928+
return tl::make_unexpected(
929+
OperationProviderErrorInfo{OperationProviderError::InvalidParameters,
930+
"comment exceeds 256 chars (got " + std::to_string(comment.size()) + ")", 400});
931+
}
932+
912933
auto runtime = poller_->lookup_condition(entity_id, fault_code);
913934
if (!runtime) {
914935
return tl::make_unexpected(OperationProviderErrorInfo{

src/ros2_medkit_plugins/ros2_medkit_opcua/test/test_node_map.cpp

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -521,6 +521,66 @@ component_id: test
521521
EXPECT_TRUE(map.entries()[0].alarm->above_threshold);
522522
}
523523

524+
TEST_F(NodeMapTest, RejectsThresholdEventAlarmCollision) {
525+
// bburda review on PR #387: the genuine schema collision worth checking
526+
// is not "alarm_source under nodes" (covered separately) but the same
527+
// ``(entity_id, fault_code)`` declared by both a threshold alarm under
528+
// ``nodes[*].alarm`` and a subscription entry under ``event_alarms``.
529+
// fault_manager would receive both pipelines' calls and the resulting
530+
// status flapping is impossible to debug at runtime; the two paths have
531+
// different semantics (debounced vs state-machine) so a merge is not
532+
// even well-defined. Loader rejects the whole file.
533+
std::string path = "/tmp/test_node_map_alarm_collision.yaml";
534+
std::ofstream f(path);
535+
f << R"(
536+
area_id: test
537+
component_id: test
538+
nodes:
539+
- node_id: "ns=1;i=1"
540+
entity_id: tank_process
541+
data_name: pressure
542+
alarm:
543+
fault_code: PLC_OVERPRESSURE
544+
threshold: 90.0
545+
event_alarms:
546+
- alarm_source: "ns=2;s=Alarms.Overpressure"
547+
entity_id: tank_process
548+
fault_code: PLC_OVERPRESSURE
549+
)";
550+
f.close();
551+
552+
NodeMap map;
553+
EXPECT_FALSE(map.load(path));
554+
}
555+
556+
TEST_F(NodeMapTest, AcceptsDifferentFaultCodesAcrossPipelines) {
557+
// Same entity, *different* fault_codes across pipelines is fine - each
558+
// fault_manager entry is keyed on fault_code, so no collision. Locks
559+
// the contract that the rejection above is precise (won't false-positive
560+
// when only entity overlaps).
561+
std::string path = "/tmp/test_node_map_alarm_no_collision.yaml";
562+
std::ofstream f(path);
563+
f << R"(
564+
area_id: test
565+
component_id: test
566+
nodes:
567+
- node_id: "ns=1;i=1"
568+
entity_id: tank_process
569+
data_name: pressure
570+
alarm:
571+
fault_code: PLC_PRESSURE_HIGH
572+
threshold: 90.0
573+
event_alarms:
574+
- alarm_source: "ns=2;s=Alarms.Overheat"
575+
entity_id: tank_process
576+
fault_code: PLC_OVERHEAT
577+
)";
578+
f.close();
579+
580+
NodeMap map;
581+
EXPECT_TRUE(map.load(path));
582+
}
583+
524584
TEST_F(NodeMapTest, RejectsAlarmSourceUnderNodes) {
525585
// Schema validation: ``alarm_source`` is only valid in the top-level
526586
// ``event_alarms:`` section. Used to be silently ignored when not paired

0 commit comments

Comments
 (0)