Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 117 additions & 0 deletions src/ros2_medkit_gateway/config/gateway_params.secure.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
# ROS 2 Medkit Gateway - secure field profile
#
# Hardened parameter preset for on-prem / plant-network (appliance)
# deployments. It turns ON every control that the default development config
# leaves OFF: JWT auth, TLS, restricted CORS, and rate limiting. Use this file
# instead of gateway_params.yaml for any deployment reachable from an
# untrusted network.
#
# ros2 run ros2_medkit_gateway gateway_node \
# --ros-args --params-file gateway_params.secure.yaml
#
# Items marked "REQUIRED" below must be set before the gateway will start /
# accept clients. See design/hardening.rst for the full checklist and
# credential / certificate provisioning steps.

ros2_medkit_gateway:
ros__parameters:
server:
# Appliance binds all interfaces; TLS + auth below protect the surface.
# Narrow to a management interface IP when the deployment allows it.
host: "0.0.0.0"
port: 8443

# TLS/HTTPS - REQUIRED. Provision a real certificate (see
# scripts/generate_dev_certs.sh for the dev-only equivalent). The key
# file must be chmod 600 and owned by the gateway service user.
tls:
enabled: true
cert_file: "/etc/ros2_medkit/certs/cert.pem" # REQUIRED
key_file: "/etc/ros2_medkit/certs/key.pem" # REQUIRED (chmod 600)
ca_file: ""
# 1.3 preferred on a controlled fleet; drop to 1.2 only for legacy
# clients that cannot negotiate 1.3.
min_version: "1.3"

# CORS - restrict to the explicit origins that serve the operator UI.
# Never use ["*"] with credentials. Empty list disables CORS entirely
# (correct for API-only appliances with no browser UI).
cors:
allowed_origins: ["https://medkit-ui.local"] # REQUIRED if a browser UI is used; else [""]
allowed_methods: ["GET", "PUT", "POST", "DELETE", "OPTIONS"]
allowed_headers: ["Content-Type", "Accept", "Authorization"]
allow_credentials: true
max_age_seconds: 600

# Authentication (JWT + RBAC) - REQUIRED on.
auth:
enabled: true
# HS256 shared secret (>= 32 chars) or, for RS256, the private key path.
# REQUIRED - inject from a secret store / env at deploy time; do NOT
# commit a real secret to source control.
jwt_secret: "" # REQUIRED
jwt_public_key: ""
jwt_algorithm: "HS256"
token_expiry_seconds: 3600
refresh_token_expiry_seconds: 86400
# "all" forces auth on every request (reads + writes). Use "write" only
# when unauthenticated reads are explicitly acceptable on this network.
require_auth_for: "all"
issuer: "ros2_medkit_gateway"
# Provision the minimum set of role-scoped clients. Format:
# "client_id:client_secret:role" (roles: viewer/operator/configurator/admin).
# REQUIRED - replace with real, rotated credentials.
clients: [""] # REQUIRED

# Rate limiting - ON to bound abuse / runaway clients.
rate_limiting:
enabled: true
global_requests_per_minute: 600
client_requests_per_minute: 120
# Tighten mutating endpoints (operations / data writes).
endpoint_limits: ["/api/v1/*/operations/*:30"]
client_cleanup_interval_seconds: 300
client_max_idle_seconds: 600

# Diagnostic scripts - disabled by default on an appliance. Enable
# deliberately and keep uploads off (manifest-defined scripts only).
scripts:
scripts_dir: ""
allow_uploads: false
max_file_size_mb: 10
max_concurrent_executions: 5
default_timeout_sec: 300
max_execution_history: 100

# Bulk data uploads - cap the payload size; raise only if the deployment
# genuinely needs large uploads.
bulk_data:
storage_dir: "/var/lib/ros2_medkit/bulk_data"
max_upload_size: 26214400 # 25 MiB
categories: [""]

# SOVD resource locking on, so concurrent operators cannot stamp on each
# other's mutations.
locking:
enabled: true
default_max_expiration: 3600
cleanup_interval: 30
defaults:
components:
lock_required_scopes: ["operations"]
breakable: true
apps:
lock_required_scopes: ["operations"]
breakable: true

# OpenAPI /docs endpoints off on a hardened appliance (reduce surface).
docs:
enabled: false

# Peer aggregation: if used across hosts, require TLS and do NOT forward
# client tokens to mDNS-discovered peers unless every peer is trusted.
aggregation:
enabled: false
require_tls: true
forward_auth: false
peer_scheme: "https"
104 changes: 104 additions & 0 deletions src/ros2_medkit_gateway/design/hardening.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
Gateway hardening (secure field profile)
========================================

The gateway ships every transport and access control needed for a hardened
deployment - JWT authentication with RBAC, TLS/HTTPS, restricted CORS, and
token-bucket rate limiting - but they are **disabled by default** so local
development works out of the box. A gateway exposed on a plant network with the
defaults is wide open: unauthenticated reads and writes over cleartext HTTP.

For any deployment reachable from an untrusted network, start from the secure
field profile preset ``config/gateway_params.secure.yaml`` instead of
``config/gateway_params.yaml``:

.. code-block:: bash

ros2 run ros2_medkit_gateway gateway_node \
--ros-args --params-file gateway_params.secure.yaml \
-p auth.jwt_secret:="$MEDKIT_JWT_SECRET" \
-p 'auth.clients:=["operator:'"$OP_SECRET"':operator"]'

What the secure profile turns on
--------------------------------

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

Credential and certificate provisioning
----------------------------------------

1. **TLS certificate.** Provision a real server certificate + private key and
point ``server.tls.cert_file`` / ``server.tls.key_file`` at them. The key
file must be ``chmod 600`` and owned by the gateway service user. For a
dev/test box only, ``scripts/generate_dev_certs.sh`` emits a self-signed
``cert.pem`` / ``key.pem`` / ``ca.pem`` (never use these in production).

2. **JWT secret.** Generate a high-entropy secret of at least 32 characters
(HS256) or provision an RS256 key pair. Inject it at deploy time from a
secret store or environment variable - do not commit it to source control.

3. **Role-scoped clients.** Create the minimum set of clients in
``auth.clients`` (``client_id:client_secret:role``). Roles, least to most
privileged: ``viewer`` (read), ``operator`` (+ trigger ops / ack faults /
publish), ``configurator`` (+ modify configs), ``admin`` (+ auth
management). Rotate secrets periodically.

4. **Obtain a token** and call the API over HTTPS:

.. code-block:: bash

curl -sk -X POST https://gateway:8443/api/v1/auth/authorize \
-H 'Content-Type: application/json' \
-d '{"client_id":"operator","client_secret":"...","grant_type":"client_credentials"}'
# use the returned access_token:
curl -sk https://gateway:8443/api/v1/faults -H "Authorization: Bearer $TOKEN"

Hardening checklist
-------------------

Before exposing a gateway on a shared / plant network, confirm:

- [ ] ``auth.enabled: true`` and ``auth.require_auth_for`` is ``all`` (or
``write`` only if unauthenticated reads are explicitly acceptable).
- [ ] ``auth.jwt_secret`` is set to a >= 32-char secret injected from a secret
store (not the placeholder, not in version control).
- [ ] ``auth.clients`` lists only the role-scoped clients you need; default /
example credentials removed; secrets rotated.
- [ ] ``server.tls.enabled: true`` with a real certificate; private key is
``chmod 600``; ``min_version`` is ``1.3`` (or ``1.2`` only for legacy
clients).
- [ ] ``cors.allowed_origins`` is an explicit list (no ``*``); ``*`` is never
combined with ``allow_credentials: true``.
- [ ] ``rate_limiting.enabled: true`` with per-client and mutating-endpoint
limits tuned to the deployment.
- [ ] ``scripts.allow_uploads: false`` unless remote script upload is a
required, reviewed capability.
- [ ] ``bulk_data.max_upload_size`` bounded to what the deployment needs.
- [ ] If peer aggregation is used: ``aggregation.require_tls: true`` and
``forward_auth`` only enabled when every peer is trusted.
- [ ] Bind ``server.host`` to a management interface where the network layout
allows, and place the gateway behind the plant firewall / segmentation.
- [ ] Back the gateway with persistent storage on a volume with restricted
permissions (faults DB, triggers DB, rosbag snapshots).

OPC-UA plugin (southbound) hardening
------------------------------------

The gateway controls the northbound REST surface; the OPC-UA plugin controls
the southbound connection to the PLC. Harden both. The plugin supports
SecurityPolicy (Basic256Sha256 / Aes128 / Aes256), MessageSecurityMode
(Sign / SignAndEncrypt), a client application-instance certificate, a server
trust store with reject-untrusted, and user identity (anonymous /
username-password / X.509). See ``ros2_medkit_opcua`` README, section
"OPC-UA client security".
1 change: 1 addition & 0 deletions src/ros2_medkit_gateway/design/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -674,6 +674,7 @@ Additional Design Documents
aggregation
dto_contract
entity_cache_architecture
hardening
lifecycle
plugin_entity_notifications
ros2_subscription_architecture
22 changes: 18 additions & 4 deletions src/ros2_medkit_gateway/src/gateway_node.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -496,8 +496,15 @@ GatewayNode::GatewayNode(const rclcpp::NodeOptions & options) : Node("ros2_medki
.build();
// Note: HttpServerManager will log TLS configuration details
} catch (const std::exception & e) {
RCLCPP_ERROR(get_logger(), "Invalid TLS configuration: %s. TLS disabled.", e.what());
tls_config_ = TlsConfig{}; // Disabled
// Fail closed: TLS was explicitly requested but could not be built (e.g.
// missing/invalid cert or key). Refuse to start rather than silently
// serving plaintext HTTP. Disabling here would expose the API in the
// clear under a configuration that asked for encryption.
RCLCPP_FATAL(get_logger(),
"TLS is enabled (server.tls.enabled=true) but the TLS configuration is invalid: %s. "
"Refusing to start in plaintext - fix the certificate/key configuration or disable TLS.",
e.what());
throw std::runtime_error(std::string("Invalid TLS configuration: ") + e.what());
}
} else {
RCLCPP_INFO(get_logger(), "TLS/HTTPS: disabled");
Expand Down Expand Up @@ -551,8 +558,15 @@ GatewayNode::GatewayNode(const rclcpp::NodeOptions & options) : Node("ros2_medki
algorithm_to_string(auth_config_.jwt_algorithm).c_str(),
get_parameter("auth.require_auth_for").as_string().c_str());
} catch (const std::exception & e) {
RCLCPP_ERROR(get_logger(), "Invalid authentication configuration: %s. Authentication disabled.", e.what());
auth_config_ = AuthConfig{}; // Disabled
// Fail closed: authentication was explicitly requested but could not be
// built (e.g. empty jwt_secret). Refuse to start rather than silently
// serving an unauthenticated API under a configuration that asked for
// auth.
RCLCPP_FATAL(get_logger(),
"Authentication is enabled (auth.enabled=true) but the auth configuration is invalid: %s. "
"Refusing to start unauthenticated - fix the auth configuration or disable auth.",
e.what());
throw std::runtime_error(std::string("Invalid authentication configuration: ") + e.what());
}
} else {
RCLCPP_INFO(get_logger(), "Authentication: disabled");
Expand Down
41 changes: 41 additions & 0 deletions src/ros2_medkit_gateway/test/test_gateway_node.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -908,6 +908,47 @@ TEST_F(TestGatewayNode, test_invalid_function_id_bad_request) {
EXPECT_EQ(res->status, 400);
}

// =============================================================================
// Secure-profile fail-closed tests (issue #477): when auth/TLS is explicitly
// enabled but its configuration is invalid, the node must refuse to start
// rather than silently fall back to an unauthenticated / plaintext server.
// =============================================================================

TEST_F(TestGatewayNode, auth_enabled_with_empty_secret_fails_closed) {
// auth.enabled=true with an empty HS256 secret: the AuthConfigBuilder rejects
// it, and the node must propagate that as a fatal (throw) instead of disabling
// auth and serving unauthenticated.
node_.reset();
int free_port = reserve_free_port();
ASSERT_NE(free_port, 0);

rclcpp::NodeOptions options;
options.parameter_overrides({
rclcpp::Parameter("server.port", free_port),
rclcpp::Parameter("auth.enabled", true),
rclcpp::Parameter("auth.jwt_secret", std::string("")),
rclcpp::Parameter("auth.jwt_algorithm", std::string("HS256")),
});
EXPECT_THROW(std::make_shared<ros2_medkit_gateway::GatewayNode>(options), std::exception);
}

TEST_F(TestGatewayNode, tls_enabled_with_missing_cert_fails_closed) {
// server.tls.enabled=true with no cert/key: the TlsConfigBuilder rejects it,
// and the node must refuse to start in plaintext.
node_.reset();
int free_port = reserve_free_port();
ASSERT_NE(free_port, 0);

rclcpp::NodeOptions options;
options.parameter_overrides({
rclcpp::Parameter("server.port", free_port),
rclcpp::Parameter("server.tls.enabled", true),
rclcpp::Parameter("server.tls.cert_file", std::string("")),
rclcpp::Parameter("server.tls.key_file", std::string("")),
});
EXPECT_THROW(std::make_shared<ros2_medkit_gateway::GatewayNode>(options), std::exception);
}

// =============================================================================
// Entity type path extraction tests
// =============================================================================
Expand Down
6 changes: 6 additions & 0 deletions src/ros2_medkit_plugins/ros2_medkit_opcua/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,12 @@ fetchcontent_declare(
set(UAPP_INTERNAL_OPEN62541 ON CACHE BOOL "" FORCE)
set(CMAKE_POSITION_INDEPENDENT_CODE ON CACHE BOOL "" FORCE)

# Enable the OpenSSL encryption backend so the client can negotiate signed /
# encrypted SecureChannels (SecurityPolicy Basic256Sha256 etc.) and present a
# client application-instance certificate. Without this open62541 is built
# SecurityPolicy=None only and the encrypted ClientConfig overloads compile out.
set(UA_ENABLE_ENCRYPTION "OPENSSL" CACHE STRING "" FORCE)

# open62541 + open62541pp are third-party code pulled via FetchContent.
# ROS2MedkitWarnings promotes -Wswitch-enum, -Wnull-dereference, and other
# warnings to errors project-wide, and those fire on upstream C sources that
Expand Down
Loading
Loading