Skip to content

Commit bd919de

Browse files
author
selfpatch dev
committed
fix(opcua,gateway,docs): address review and CI findings
Fail loud on unrecognized OPC-UA security_policy/security_mode/user_auth instead of silently defaulting to an insecure profile. Keep read-replay observed-but-unmapped conditions in the seen set so reconcile does not clear them, and reject a non-sequence nodes: section in the node map. Drop unused locals in EXPECT_THROW, fix the hardening table syntax and bulk_data.max_upload_size name, and add hardening to the design toctree. Refs #477 #478 #479 #480
1 parent 13dc3a0 commit bd919de

6 files changed

Lines changed: 75 additions & 22 deletions

File tree

src/ros2_medkit_gateway/design/hardening.rst

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -21,19 +21,19 @@ field profile preset ``config/gateway_params.secure.yaml`` instead of
2121
What the secure profile turns on
2222
--------------------------------
2323

24-
============================ ============== ===========================================
25-
Control Default Secure profile
26-
============================ ============== ===========================================
27-
``auth.enabled`` false true
28-
``auth.require_auth_for`` write all (auth on reads + writes)
29-
``server.tls.enabled`` false true (HTTPS, min TLS 1.3)
30-
``cors.allowed_origins`` ``[""]`` explicit origin list (no wildcard)
31-
``rate_limiting.enabled`` false true (global + per-client + per-endpoint)
32-
``scripts.allow_uploads`` true false (manifest-defined scripts only)
33-
``docs.enabled`` true false (reduced surface)
34-
``bulk_data.max_upload`` 100 MiB 25 MiB
35-
``locking`` on operations none lock required before mutation
36-
============================ ============== ===========================================
24+
================================ ============== ===========================================
25+
Control Default Secure profile
26+
================================ ============== ===========================================
27+
``auth.enabled`` false true
28+
``auth.require_auth_for`` write all (auth on reads + writes)
29+
``server.tls.enabled`` false true (HTTPS, min TLS 1.3)
30+
``cors.allowed_origins`` ``[""]`` explicit origin list (no wildcard)
31+
``rate_limiting.enabled`` false true (global + per-client + per-endpoint)
32+
``scripts.allow_uploads`` true false (manifest-defined scripts only)
33+
``docs.enabled`` true false (reduced surface)
34+
``bulk_data.max_upload_size`` 100 MiB 25 MiB
35+
``locking`` on operations none lock required before mutation
36+
================================ ============== ===========================================
3737

3838
Credential and certificate provisioning
3939
----------------------------------------

src/ros2_medkit_gateway/design/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -674,6 +674,7 @@ Additional Design Documents
674674
aggregation
675675
dto_contract
676676
entity_cache_architecture
677+
hardening
677678
lifecycle
678679
plugin_entity_notifications
679680
ros2_subscription_architecture

src/ros2_medkit_gateway/test/test_gateway_node.cpp

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -929,7 +929,7 @@ TEST_F(TestGatewayNode, auth_enabled_with_empty_secret_fails_closed) {
929929
rclcpp::Parameter("auth.jwt_secret", std::string("")),
930930
rclcpp::Parameter("auth.jwt_algorithm", std::string("HS256")),
931931
});
932-
EXPECT_THROW({ auto n = std::make_shared<ros2_medkit_gateway::GatewayNode>(options); }, std::exception);
932+
EXPECT_THROW(std::make_shared<ros2_medkit_gateway::GatewayNode>(options), std::exception);
933933
}
934934

935935
TEST_F(TestGatewayNode, tls_enabled_with_missing_cert_fails_closed) {
@@ -946,7 +946,7 @@ TEST_F(TestGatewayNode, tls_enabled_with_missing_cert_fails_closed) {
946946
rclcpp::Parameter("server.tls.cert_file", std::string("")),
947947
rclcpp::Parameter("server.tls.key_file", std::string("")),
948948
});
949-
EXPECT_THROW({ auto n = std::make_shared<ros2_medkit_gateway::GatewayNode>(options); }, std::exception);
949+
EXPECT_THROW(std::make_shared<ros2_medkit_gateway::GatewayNode>(options), std::exception);
950950
}
951951

952952
// =============================================================================

src/ros2_medkit_plugins/ros2_medkit_opcua/src/node_map.cpp

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,15 @@ bool NodeMap::load(const std::string & yaml_path) {
184184
node_id_index_.clear();
185185

186186
auto nodes = root["nodes"];
187+
// A ``nodes:`` key that is present but neither null nor a sequence (e.g. a
188+
// scalar or a map) is a config error, not an absent section. Fail loudly
189+
// instead of silently dropping every node mapping. An explicitly empty
190+
// ``nodes:`` (null) stays valid and is handled by the emptiness check below.
191+
if (nodes && !nodes.IsNull() && !nodes.IsSequence()) {
192+
RCLCPP_ERROR(rclcpp::get_logger("opcua.node_map"),
193+
"'nodes:' must be a sequence of node entries - refusing to load");
194+
return false;
195+
}
187196
const bool has_nodes = nodes && nodes.IsSequence();
188197

189198
if (has_nodes && nodes.size() > 10000) {

src/ros2_medkit_plugins/ros2_medkit_opcua/src/opcua_plugin.cpp

Lines changed: 45 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
#include <cstdio>
3030
#include <cstdlib>
3131
#include <sstream>
32+
#include <stdexcept>
3233

3334
namespace ros2_medkit_gateway {
3435

@@ -48,6 +49,44 @@ inline bool plugin_debug_enabled() {
4849
return rcutils_logging_logger_is_enabled_for("opcua.plugin", RCUTILS_LOG_SEVERITY_DEBUG);
4950
}
5051

52+
// Security-config parse wrappers. An unrecognized value must NOT silently fall
53+
// back to the insecure default (SecurityPolicy::None / SecurityMode::None /
54+
// anonymous auth): a typo would quietly downgrade a hardened link. Fail loud and
55+
// refuse - the thrown exception disables the plugin in PluginManager::configure_plugins.
56+
SecurityPolicy require_security_policy(const std::string & value) {
57+
bool ok = true;
58+
const auto parsed = OpcuaClient::parse_security_policy(value, &ok);
59+
if (!ok) {
60+
RCLCPP_ERROR(opcua_plugin_logger(),
61+
"Unrecognized OPC-UA security_policy '%s'; refusing to start with an insecure fallback",
62+
value.c_str());
63+
throw std::invalid_argument("opcua: unrecognized security_policy '" + value + "'");
64+
}
65+
return parsed;
66+
}
67+
68+
SecurityMode require_security_mode(const std::string & value) {
69+
bool ok = true;
70+
const auto parsed = OpcuaClient::parse_security_mode(value, &ok);
71+
if (!ok) {
72+
RCLCPP_ERROR(opcua_plugin_logger(),
73+
"Unrecognized OPC-UA security_mode '%s'; refusing to start with an insecure fallback", value.c_str());
74+
throw std::invalid_argument("opcua: unrecognized security_mode '" + value + "'");
75+
}
76+
return parsed;
77+
}
78+
79+
UserAuthMode require_user_auth_mode(const std::string & value) {
80+
bool ok = true;
81+
const auto parsed = OpcuaClient::parse_user_auth_mode(value, &ok);
82+
if (!ok) {
83+
RCLCPP_ERROR(opcua_plugin_logger(),
84+
"Unrecognized OPC-UA user_auth_mode '%s'; refusing to start with an insecure fallback", value.c_str());
85+
throw std::invalid_argument("opcua: unrecognized user_auth_mode '" + value + "'");
86+
}
87+
return parsed;
88+
}
89+
5190
/// Parse a JSON "value" field, coerce to the node's declared data_type, and
5291
/// validate against the optional min/max range. Shared by handle_plc_operations,
5392
/// DataProvider::write_data, and OperationProvider::execute_operation to keep
@@ -119,12 +158,12 @@ void OpcuaPlugin::configure(const nlohmann::json & config) {
119158
// OPC-UA SecureChannel security + user identity. All opt-in; defaults keep
120159
// the legacy anonymous + SecurityPolicy=None behaviour.
121160
if (config.contains("security_policy")) {
122-
client_config_.security_policy = OpcuaClient::parse_security_policy(config["security_policy"].get<std::string>());
161+
client_config_.security_policy = require_security_policy(config["security_policy"].get<std::string>());
123162
}
124163
if (config.contains("security_mode") || config.contains("message_security_mode")) {
125164
const std::string mode_str = config.contains("security_mode") ? config["security_mode"].get<std::string>()
126165
: config["message_security_mode"].get<std::string>();
127-
client_config_.security_mode = OpcuaClient::parse_security_mode(mode_str);
166+
client_config_.security_mode = require_security_mode(mode_str);
128167
}
129168
if (config.contains("client_cert_path")) {
130169
client_config_.client_cert_path = config["client_cert_path"].get<std::string>();
@@ -147,7 +186,7 @@ void OpcuaPlugin::configure(const nlohmann::json & config) {
147186
client_config_.reject_untrusted = config["reject_untrusted"].get<bool>();
148187
}
149188
if (config.contains("user_auth_mode")) {
150-
client_config_.user_auth_mode = OpcuaClient::parse_user_auth_mode(config["user_auth_mode"].get<std::string>());
189+
client_config_.user_auth_mode = require_user_auth_mode(config["user_auth_mode"].get<std::string>());
151190
}
152191
if (config.contains("username")) {
153192
client_config_.username = config["username"].get<std::string>();
@@ -190,10 +229,10 @@ void OpcuaPlugin::configure(const nlohmann::json & config) {
190229
}
191230
// Security env overrides (for Docker / appliance deployments).
192231
if (auto * env = std::getenv("OPCUA_SECURITY_POLICY")) {
193-
client_config_.security_policy = OpcuaClient::parse_security_policy(env);
232+
client_config_.security_policy = require_security_policy(env);
194233
}
195234
if (auto * env = std::getenv("OPCUA_SECURITY_MODE")) {
196-
client_config_.security_mode = OpcuaClient::parse_security_mode(env);
235+
client_config_.security_mode = require_security_mode(env);
197236
}
198237
if (auto * env = std::getenv("OPCUA_CLIENT_CERT")) {
199238
client_config_.client_cert_path = env;
@@ -229,7 +268,7 @@ void OpcuaPlugin::configure(const nlohmann::json & config) {
229268
client_config_.reject_untrusted = !(v == "0" || v == "false" || v == "no");
230269
}
231270
if (auto * env = std::getenv("OPCUA_USER_AUTH")) {
232-
client_config_.user_auth_mode = OpcuaClient::parse_user_auth_mode(env);
271+
client_config_.user_auth_mode = require_user_auth_mode(env);
233272
}
234273
if (auto * env = std::getenv("OPCUA_USERNAME")) {
235274
client_config_.username = env;

src/ros2_medkit_plugins/ros2_medkit_opcua/src/opcua_poller.cpp

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -434,6 +434,11 @@ void OpcuaPoller::read_fallback_replay() {
434434
seen.insert(snap.condition_id.toString());
435435
continue;
436436
}
437+
// Mark every successfully observed condition as seen, even if it matches
438+
// no mapping below. Otherwise reconcile_after_read would treat a live (but
439+
// unmapped) condition as "cleared while offline" and wrongly clear it.
440+
seen.insert(snap.condition_id.toString());
441+
437442
AlarmEventInput input;
438443
input.enabled_state = snap.enabled_state;
439444
input.active_state = snap.active_state;
@@ -458,7 +463,6 @@ void OpcuaPoller::read_fallback_replay() {
458463
eff.severity_override = resolved.severity_override;
459464
eff.message_override = resolved.message_override;
460465

461-
seen.insert(snap.condition_id.toString());
462466
apply_condition_state(eff, snap.condition_id, input, snap.severity, snap.message, /*event_id=*/nullptr);
463467
}
464468
}

0 commit comments

Comments
 (0)