Skip to content

Commit 52b9af4

Browse files
mfaferek93Michał Fąferek
andcommitted
[#34] feat: add input validation for component_id and area_id parameters (#39)
* [#34] feat: add input validation for component_id and area_id parameters Add validation to REST API endpoints enforcing ROS 2 naming conventions (alphanumeric, underscore, forward slash). Rejects invalid input with 400 Bad Request and descriptive error messages. Improves security by blocking special characters and preventing injection attempts. * [#34] fix: use C++23 std::expected and httplib status enums - Upgrade to C++23 for std::expected support - Refactor validate_entity_id to return std::expected<void, std::string> - Replace magic numbers with httplib::StatusCode enums --------- Co-authored-by: Michał Fąferek <michal.faferek@42dot.ai>
1 parent 5def9b5 commit 52b9af4

File tree

4 files changed

+232
-20
lines changed

4 files changed

+232
-20
lines changed

src/ros2_medkit_gateway/CMakeLists.txt

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
11
cmake_minimum_required(VERSION 3.8)
22
project(ros2_medkit_gateway)
33

4-
# Compiler settings
5-
if(NOT CMAKE_CXX_STANDARD)
6-
set(CMAKE_CXX_STANDARD 17)
7-
endif()
4+
set(CMAKE_CXX_STANDARD 23)
5+
set(CMAKE_CXX_STANDARD_REQUIRED ON)
86

97
if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang")
108
add_compile_options(-Wall -Wextra -Wpedantic)

src/ros2_medkit_gateway/include/ros2_medkit_gateway/rest_server.hpp

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
#include <httplib.h>
1818

19+
#include <expected>
1920
#include <memory>
2021
#include <string>
2122

@@ -44,6 +45,9 @@ class RESTServer {
4445
void handle_area_components(const httplib::Request& req, httplib::Response& res);
4546
void handle_component_data(const httplib::Request& req, httplib::Response& res);
4647

48+
// Helper methods
49+
std::expected<void, std::string> validate_entity_id(const std::string& entity_id) const;
50+
4751
GatewayNode* node_;
4852
std::string host_;
4953
int port_;

src/ros2_medkit_gateway/src/rest_server.cpp

Lines changed: 90 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,13 @@
1313
// limitations under the License.
1414

1515
#include "ros2_medkit_gateway/rest_server.hpp"
16-
#include "ros2_medkit_gateway/gateway_node.hpp"
16+
#include <iomanip>
17+
#include <sstream>
1718
#include <rclcpp/rclcpp.hpp>
19+
#include "ros2_medkit_gateway/gateway_node.hpp"
1820

1921
using json = nlohmann::json;
22+
using httplib::StatusCode;
2023

2124
namespace ros2_medkit_gateway {
2225

@@ -81,6 +84,50 @@ void RESTServer::stop() {
8184
}
8285
}
8386

87+
std::expected<void, std::string> RESTServer::validate_entity_id(
88+
const std::string& entity_id
89+
) const {
90+
// Check for empty string
91+
if (entity_id.empty()) {
92+
return std::unexpected("Entity ID cannot be empty");
93+
}
94+
95+
// Check length (reasonable limit to prevent abuse)
96+
if (entity_id.length() > 256) {
97+
return std::unexpected("Entity ID too long (max 256 characters)");
98+
}
99+
100+
// Validate characters according to ROS 2 naming conventions
101+
// Allow: alphanumeric (a-z, A-Z, 0-9), underscore (_)
102+
// Reject: hyphen (not allowed in ROS 2 names), forward slash (conflicts with URL routing),
103+
// special characters, escape sequences
104+
for (char c : entity_id) {
105+
bool is_alphanumeric = (c >= 'a' && c <= 'z') ||
106+
(c >= 'A' && c <= 'Z') ||
107+
(c >= '0' && c <= '9');
108+
bool is_allowed_special = (c == '_');
109+
110+
if (!is_alphanumeric && !is_allowed_special) {
111+
// For non-printable characters, show the character code
112+
std::string char_repr;
113+
if (c < 32 || c > 126) {
114+
std::ostringstream oss;
115+
oss << "0x" << std::hex << std::setfill('0') << std::setw(2)
116+
<< static_cast<unsigned int>(static_cast<unsigned char>(c));
117+
char_repr = oss.str();
118+
} else {
119+
char_repr = std::string(1, c);
120+
}
121+
return std::unexpected(
122+
"Entity ID contains invalid character: '" + char_repr +
123+
"'. Only alphanumeric and underscore are allowed"
124+
);
125+
}
126+
}
127+
128+
return {};
129+
}
130+
84131
void RESTServer::handle_health(const httplib::Request& req, httplib::Response& res) {
85132
(void)req; // Unused parameter
86133

@@ -92,7 +139,7 @@ void RESTServer::handle_health(const httplib::Request& req, httplib::Response& r
92139

93140
res.set_content(response.dump(2), "application/json");
94141
} catch (const std::exception& e) {
95-
res.status = 500;
142+
res.status = StatusCode::InternalServerError_500;
96143
res.set_content(
97144
json{{"error", "Internal server error"}}.dump(),
98145
"application/json"
@@ -113,7 +160,7 @@ void RESTServer::handle_root(const httplib::Request& req, httplib::Response& res
113160

114161
res.set_content(response.dump(2), "application/json");
115162
} catch (const std::exception& e) {
116-
res.status = 500;
163+
res.status = StatusCode::InternalServerError_500;
117164
res.set_content(
118165
json{{"error", "Internal server error"}}.dump(),
119166
"application/json"
@@ -135,7 +182,7 @@ void RESTServer::handle_list_areas(const httplib::Request& req, httplib::Respons
135182

136183
res.set_content(areas_json.dump(2), "application/json");
137184
} catch (const std::exception& e) {
138-
res.status = 500;
185+
res.status = StatusCode::InternalServerError_500;
139186
res.set_content(
140187
json{{"error", "Internal server error"}}.dump(),
141188
"application/json"
@@ -157,7 +204,7 @@ void RESTServer::handle_list_components(const httplib::Request& req, httplib::Re
157204

158205
res.set_content(components_json.dump(2), "application/json");
159206
} catch (const std::exception& e) {
160-
res.status = 500;
207+
res.status = StatusCode::InternalServerError_500;
161208
res.set_content(
162209
json{{"error", "Internal server error"}}.dump(),
163210
"application/json"
@@ -174,7 +221,7 @@ void RESTServer::handle_area_components(const httplib::Request& req, httplib::Re
174221
try {
175222
// Extract area_id from URL path
176223
if (req.matches.size() < 2) {
177-
res.status = 400;
224+
res.status = StatusCode::BadRequest_400;
178225
res.set_content(
179226
json{{"error", "Invalid request"}}.dump(2),
180227
"application/json"
@@ -183,6 +230,22 @@ void RESTServer::handle_area_components(const httplib::Request& req, httplib::Re
183230
}
184231

185232
std::string area_id = req.matches[1];
233+
234+
// Validate area_id
235+
auto validation_result = validate_entity_id(area_id);
236+
if (!validation_result) {
237+
res.status = StatusCode::BadRequest_400;
238+
res.set_content(
239+
json{
240+
{"error", "Invalid area ID"},
241+
{"details", validation_result.error()},
242+
{"area_id", area_id}
243+
}.dump(2),
244+
"application/json"
245+
);
246+
return;
247+
}
248+
186249
const auto cache = node_->get_entity_cache();
187250

188251
// Check if area exists
@@ -195,7 +258,7 @@ void RESTServer::handle_area_components(const httplib::Request& req, httplib::Re
195258
}
196259

197260
if (!area_exists) {
198-
res.status = 404;
261+
res.status = StatusCode::NotFound_404;
199262
res.set_content(
200263
json{
201264
{"error", "Area not found"},
@@ -216,7 +279,7 @@ void RESTServer::handle_area_components(const httplib::Request& req, httplib::Re
216279

217280
res.set_content(components_json.dump(2), "application/json");
218281
} catch (const std::exception& e) {
219-
res.status = 500;
282+
res.status = StatusCode::InternalServerError_500;
220283
res.set_content(
221284
json{{"error", "Internal server error"}}.dump(),
222285
"application/json"
@@ -234,7 +297,7 @@ void RESTServer::handle_component_data(const httplib::Request& req, httplib::Res
234297
try {
235298
// Extract component_id from URL path
236299
if (req.matches.size() < 2) {
237-
res.status = 400;
300+
res.status = StatusCode::BadRequest_400;
238301
res.set_content(
239302
json{{"error", "Invalid request"}}.dump(2),
240303
"application/json"
@@ -243,9 +306,22 @@ void RESTServer::handle_component_data(const httplib::Request& req, httplib::Res
243306
}
244307

245308
component_id = req.matches[1];
246-
// TODO(mfaferek93): Add input validation for component_id
247-
// Should validate against ROS 2 naming conventions (alphanumeric, /, _)
248-
// and URL-decode if necessary
309+
310+
// Validate component_id
311+
auto validation_result = validate_entity_id(component_id);
312+
if (!validation_result) {
313+
res.status = StatusCode::BadRequest_400;
314+
res.set_content(
315+
json{
316+
{"error", "Invalid component ID"},
317+
{"details", validation_result.error()},
318+
{"component_id", component_id}
319+
}.dump(2),
320+
"application/json"
321+
);
322+
return;
323+
}
324+
249325
const auto cache = node_->get_entity_cache();
250326

251327
// Find component in cache
@@ -261,7 +337,7 @@ void RESTServer::handle_component_data(const httplib::Request& req, httplib::Res
261337
}
262338

263339
if (!component_found) {
264-
res.status = 404;
340+
res.status = StatusCode::NotFound_404;
265341
res.set_content(
266342
json{
267343
{"error", "Component not found"},
@@ -278,7 +354,7 @@ void RESTServer::handle_component_data(const httplib::Request& req, httplib::Res
278354

279355
res.set_content(component_data.dump(2), "application/json");
280356
} catch (const std::exception& e) {
281-
res.status = 500;
357+
res.status = StatusCode::InternalServerError_500;
282358
res.set_content(
283359
json{
284360
{"error", "Failed to retrieve component data"},

src/ros2_medkit_gateway/test/test_integration.test.py

Lines changed: 136 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,11 @@ def test_03_list_components(self):
220220
print(f'✓ Components test passed: {len(components)} components discovered')
221221

222222
def test_04_automotive_areas_discovery(self):
223-
"""Test that automotive areas are properly discovered."""
223+
"""
224+
Test that automotive areas are properly discovered.
225+
226+
@verifies REQ_INTEROP_003
227+
"""
224228
areas = self._get_json('/areas')
225229
area_ids = [area['id'] for area in areas]
226230

@@ -257,7 +261,11 @@ def test_05_area_components_success(self):
257261
)
258262

259263
def test_06_area_components_nonexistent_error(self):
260-
"""Test GET /areas/{area_id}/components returns 404 for nonexistent area."""
264+
"""
265+
Test GET /areas/{area_id}/components returns 404 for nonexistent area.
266+
267+
@verifies REQ_INTEROP_006
268+
"""
261269
response = requests.get(
262270
f'{self.BASE_URL}/areas/nonexistent/components', timeout=5
263271
)
@@ -386,3 +394,129 @@ def test_12_component_no_topics(self):
386394
self.assertIsInstance(data, list, 'Response should be an array even when empty')
387395

388396
print(f'✓ Component with no topics test passed: {len(data)} topics')
397+
398+
def test_13_invalid_component_id_special_chars(self):
399+
"""
400+
Test GET /components/{component_id}/data rejects special characters.
401+
402+
@verifies REQ_INTEROP_018
403+
"""
404+
# Test various invalid characters
405+
invalid_ids = [
406+
'component;drop', # SQL injection attempt
407+
'component<script>', # XSS attempt
408+
'component"test', # Quote
409+
'component`test', # Backtick
410+
'component$test', # Dollar sign
411+
'component|test', # Pipe
412+
'component&test', # Ampersand
413+
]
414+
415+
for invalid_id in invalid_ids:
416+
response = requests.get(
417+
f'{self.BASE_URL}/components/{invalid_id}/data',
418+
timeout=5
419+
)
420+
self.assertEqual(
421+
response.status_code,
422+
400,
423+
f'Expected 400 for component_id: {invalid_id}'
424+
)
425+
426+
data = response.json()
427+
self.assertIn('error', data)
428+
self.assertEqual(data['error'], 'Invalid component ID')
429+
self.assertIn('details', data)
430+
431+
print('✓ Invalid component ID special characters test passed')
432+
433+
def test_14_invalid_area_id_special_chars(self):
434+
"""
435+
Test GET /areas/{area_id}/components rejects special characters.
436+
437+
@verifies REQ_INTEROP_006
438+
"""
439+
# Test various invalid characters
440+
# Note: Forward slash is handled by URL routing, not validation
441+
invalid_ids = [
442+
'area;drop', # SQL injection attempt
443+
'area<script>', # XSS attempt
444+
'area"test', # Quote
445+
'area|test', # Pipe
446+
]
447+
448+
for invalid_id in invalid_ids:
449+
response = requests.get(
450+
f'{self.BASE_URL}/areas/{invalid_id}/components',
451+
timeout=5
452+
)
453+
self.assertEqual(
454+
response.status_code,
455+
400,
456+
f'Expected 400 for area_id: {invalid_id}'
457+
)
458+
459+
data = response.json()
460+
self.assertIn('error', data)
461+
self.assertEqual(data['error'], 'Invalid area ID')
462+
self.assertIn('details', data)
463+
464+
print('✓ Invalid area ID special characters test passed')
465+
466+
def test_15_valid_ids_with_underscores(self):
467+
"""
468+
Test that valid IDs with underscores are accepted (ROS 2 naming).
469+
470+
@verifies REQ_INTEROP_018
471+
"""
472+
# While these IDs don't exist in the test environment,
473+
# they should pass validation and return 404 (not 400)
474+
valid_ids = [
475+
'component_name', # Underscore
476+
'component_name_123', # Underscore and numbers
477+
'ComponentName', # CamelCase
478+
'component123', # Alphanumeric
479+
]
480+
481+
for valid_id in valid_ids:
482+
response = requests.get(
483+
f'{self.BASE_URL}/components/{valid_id}/data',
484+
timeout=5
485+
)
486+
# Should return 404 (not found) not 400 (invalid)
487+
self.assertEqual(
488+
response.status_code,
489+
404,
490+
f'Expected 404 for valid but nonexistent ID: {valid_id}'
491+
)
492+
493+
print('✓ Valid IDs with underscores test passed')
494+
495+
def test_16_invalid_ids_with_hyphens(self):
496+
"""
497+
Test that IDs with hyphens are rejected (not allowed in ROS 2 names).
498+
499+
@verifies REQ_INTEROP_018
500+
"""
501+
invalid_ids = [
502+
'component-name',
503+
'component-name-123',
504+
'my-component',
505+
]
506+
507+
for invalid_id in invalid_ids:
508+
response = requests.get(
509+
f'{self.BASE_URL}/components/{invalid_id}/data',
510+
timeout=5
511+
)
512+
self.assertEqual(
513+
response.status_code,
514+
400,
515+
f'Expected 400 for hyphenated ID: {invalid_id}'
516+
)
517+
518+
data = response.json()
519+
self.assertIn('error', data)
520+
self.assertEqual(data['error'], 'Invalid component ID')
521+
522+
print('✓ Invalid IDs with hyphens test passed')

0 commit comments

Comments
 (0)