Skip to content

Commit 6a914fc

Browse files
committed
fix: address CORS code review feedback
- Introduce CorsConfigBuilder with fluent interface for configuration - Move CORS validation (credentials + wildcard) into builder - Extract config.cpp for builder implementation - Remove dead code (unreachable empty origins check) - Fix OPTIONS preflight: no Max-Age for disallowed origins - Use StatusCode::NoContent_204 instead of magic number - Fix YAML comment: wildcard headers not supported - Consolidate CORS tests into single file with 10 test cases - Fix pep257 docstring length warnings
1 parent 617d30a commit 6a914fc

File tree

8 files changed

+275
-164
lines changed

8 files changed

+275
-164
lines changed

src/ros2_medkit_gateway/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ include_directories(include)
3030
# Gateway node executable
3131
add_executable(gateway_node
3232
src/main.cpp
33+
src/config.cpp
3334
src/gateway_node.cpp
3435
src/rest_server.cpp
3536
src/discovery_manager.cpp

src/ros2_medkit_gateway/config/gateway_params.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,15 +32,15 @@ ros2_medkit_gateway:
3232
# List of allowed origins
3333
# Examples: ["http://localhost:5173", "https://example.com"]
3434
# Use ["*"] to allow all origins (not recommended for production)
35-
# Empty list = allow all origins (not recommended for production)
35+
# Empty list = CORS disabled
3636
allowed_origins: []
3737

3838
# Allowed HTTP methods for CORS requests
3939
# Default: ["GET", "PUT", "OPTIONS"]
4040
allowed_methods: ["GET", "PUT", "OPTIONS"]
4141

4242
# Allowed headers in CORS requests
43-
# Use ["*"] to allow all headers
43+
# List the allowed headers explicitly (wildcard "*" is not supported)
4444
allowed_headers: ["Content-Type", "Accept"]
4545

4646
# Whether to allow credentials (cookies, authorization headers)

src/ros2_medkit_gateway/include/ros2_medkit_gateway/config.hpp

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,35 @@ struct CorsConfig {
2929
std::vector<std::string> allowed_headers;
3030
bool allow_credentials{false};
3131
int max_age_seconds{86400};
32+
33+
// Pre-built header values for performance
34+
std::string methods_header;
35+
std::string headers_header;
36+
};
37+
38+
/**
39+
* @brief Builder for CorsConfig with fluent interface
40+
*
41+
* Usage:
42+
* auto config = CorsConfigBuilder()
43+
* .with_origins({"http://localhost:5173"})
44+
* .with_methods({"GET", "PUT", "OPTIONS"})
45+
* .with_headers({"Content-Type", "Accept"})
46+
* .with_credentials(true)
47+
* .with_max_age(3600)
48+
* .build();
49+
*/
50+
class CorsConfigBuilder {
51+
public:
52+
CorsConfigBuilder & with_origins(std::vector<std::string> origins);
53+
CorsConfigBuilder & with_methods(std::vector<std::string> methods);
54+
CorsConfigBuilder & with_headers(std::vector<std::string> headers);
55+
CorsConfigBuilder & with_credentials(bool credentials);
56+
CorsConfigBuilder & with_max_age(int seconds);
57+
CorsConfig && build();
58+
59+
private:
60+
CorsConfig config_;
3261
};
3362

3463
} // namespace ros2_medkit_gateway

src/ros2_medkit_gateway/include/ros2_medkit_gateway/rest_server.hpp

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ class GatewayNode;
3030

3131
class RESTServer {
3232
public:
33-
RESTServer(GatewayNode * node, const std::string & host, int port, const CorsConfig & cors_config = {});
33+
RESTServer(GatewayNode * node, const std::string & host, int port, const CorsConfig & cors_config);
3434
~RESTServer();
3535

3636
void start();
@@ -60,10 +60,6 @@ class RESTServer {
6060
int port_;
6161
CorsConfig cors_config_;
6262

63-
// Pre-built CORS header values (built once in constructor for performance)
64-
std::string cors_methods_header_;
65-
std::string cors_headers_header_;
66-
6763
std::unique_ptr<httplib::Server> server_;
6864
};
6965

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
// Copyright 2025 bburda
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
#include "ros2_medkit_gateway/config.hpp"
16+
17+
#include <algorithm>
18+
#include <stdexcept>
19+
#include <utility>
20+
21+
namespace ros2_medkit_gateway {
22+
23+
CorsConfigBuilder & CorsConfigBuilder::with_origins(std::vector<std::string> origins) {
24+
config_.allowed_origins = std::move(origins);
25+
return *this;
26+
}
27+
28+
CorsConfigBuilder & CorsConfigBuilder::with_methods(std::vector<std::string> methods) {
29+
config_.allowed_methods = std::move(methods);
30+
return *this;
31+
}
32+
33+
CorsConfigBuilder & CorsConfigBuilder::with_headers(std::vector<std::string> headers) {
34+
config_.allowed_headers = std::move(headers);
35+
return *this;
36+
}
37+
38+
CorsConfigBuilder & CorsConfigBuilder::with_credentials(bool credentials) {
39+
config_.allow_credentials = credentials;
40+
return *this;
41+
}
42+
43+
CorsConfigBuilder & CorsConfigBuilder::with_max_age(int seconds) {
44+
config_.max_age_seconds = seconds;
45+
return *this;
46+
}
47+
48+
CorsConfig && CorsConfigBuilder::build() {
49+
// Enable CORS only if origins are configured
50+
config_.enabled = !config_.allowed_origins.empty();
51+
52+
if (config_.enabled) {
53+
// Validate: credentials cannot be used with wildcard origin
54+
if (config_.allow_credentials) {
55+
auto has_wildcard = std::find(config_.allowed_origins.begin(), config_.allowed_origins.end(), "*");
56+
if (has_wildcard != config_.allowed_origins.end()) {
57+
throw std::invalid_argument("CORS: allow_credentials cannot be true when allowed_origins contains '*'");
58+
}
59+
}
60+
61+
// Build cached header strings
62+
for (const auto & method : config_.allowed_methods) {
63+
if (!config_.methods_header.empty()) {
64+
config_.methods_header += ", ";
65+
}
66+
config_.methods_header += method;
67+
}
68+
69+
for (const auto & header : config_.allowed_headers) {
70+
if (!config_.headers_header.empty()) {
71+
config_.headers_header += ", ";
72+
}
73+
config_.headers_header += header;
74+
}
75+
}
76+
77+
return std::move(config_);
78+
}
79+
80+
} // namespace ros2_medkit_gateway

src/ros2_medkit_gateway/src/gateway_node.cpp

Lines changed: 9 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -38,26 +38,15 @@ GatewayNode::GatewayNode() : Node("ros2_medkit_gateway") {
3838
server_port_ = get_parameter("server.port").as_int();
3939
refresh_interval_ms_ = get_parameter("refresh_interval_ms").as_int();
4040

41-
// Get CORS configuration
42-
cors_config_.allowed_origins = get_parameter("cors.allowed_origins").as_string_array();
43-
cors_config_.allowed_methods = get_parameter("cors.allowed_methods").as_string_array();
44-
cors_config_.allowed_headers = get_parameter("cors.allowed_headers").as_string_array();
45-
cors_config_.allow_credentials = get_parameter("cors.allow_credentials").as_bool();
46-
cors_config_.max_age_seconds = get_parameter("cors.max_age_seconds").as_int();
47-
48-
// CORS is enabled only if allowed_origins is configured
49-
// Treat missing/empty origins as disabled for security
50-
cors_config_.enabled = !cors_config_.allowed_origins.empty();
51-
52-
// Validate CORS configuration: credentials cannot be used with wildcard origin
53-
if (cors_config_.enabled && cors_config_.allow_credentials) {
54-
for (const auto & origin : cors_config_.allowed_origins) {
55-
if (origin == "*") {
56-
RCLCPP_ERROR(get_logger(), "CORS: allow_credentials cannot be true when allowed_origins contains '*'");
57-
throw std::runtime_error("Invalid CORS configuration");
58-
}
59-
}
60-
}
41+
// Build CORS configuration using builder pattern
42+
// Throws std::invalid_argument if configuration is invalid
43+
cors_config_ = CorsConfigBuilder()
44+
.with_origins(get_parameter("cors.allowed_origins").as_string_array())
45+
.with_methods(get_parameter("cors.allowed_methods").as_string_array())
46+
.with_headers(get_parameter("cors.allowed_headers").as_string_array())
47+
.with_credentials(get_parameter("cors.allow_credentials").as_bool())
48+
.with_max_age(get_parameter("cors.max_age_seconds").as_int())
49+
.build();
6150

6251
// Validate port range
6352
if (server_port_ < 1024 || server_port_ > 65535) {
@@ -95,9 +84,6 @@ GatewayNode::GatewayNode() : Node("ros2_medkit_gateway") {
9584
}
9685
origins_str += origin;
9786
}
98-
if (origins_str.empty()) {
99-
origins_str = "* (all)";
100-
}
10187

10288
std::string methods_str;
10389
for (const auto & method : cors_config_.allowed_methods) {

src/ros2_medkit_gateway/src/rest_server.cpp

Lines changed: 16 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -31,33 +31,23 @@ RESTServer::RESTServer(GatewayNode * node, const std::string & host, int port, c
3131
: node_(node), host_(host), port_(port), cors_config_(cors_config) {
3232
server_ = std::make_unique<httplib::Server>();
3333

34-
// Pre-build CORS header strings once (performance optimization)
35-
for (const auto & method : cors_config_.allowed_methods) {
36-
if (!cors_methods_header_.empty()) {
37-
cors_methods_header_ += ", ";
38-
}
39-
cors_methods_header_ += method;
40-
}
41-
for (const auto & header : cors_config_.allowed_headers) {
42-
if (!cors_headers_header_.empty()) {
43-
cors_headers_header_ += ", ";
44-
}
45-
cors_headers_header_ += header;
46-
}
47-
4834
// Set up pre-routing handler for CORS (only if enabled)
4935
if (cors_config_.enabled) {
5036
server_->set_pre_routing_handler([this](const httplib::Request & req, httplib::Response & res) {
5137
std::string origin = req.get_header_value("Origin");
52-
if (!origin.empty() && is_origin_allowed(origin)) {
38+
bool origin_allowed = !origin.empty() && is_origin_allowed(origin);
39+
40+
if (origin_allowed) {
5341
set_cors_headers(res, origin);
5442
}
5543

5644
// Handle preflight OPTIONS requests
45+
// Only set Max-Age and return 204 for allowed origins
5746
if (req.method == "OPTIONS") {
58-
// Set Max-Age only for preflight responses
59-
res.set_header("Access-Control-Max-Age", std::to_string(cors_config_.max_age_seconds));
60-
res.status = 204; // No Content
47+
if (origin_allowed) {
48+
res.set_header("Access-Control-Max-Age", std::to_string(cors_config_.max_age_seconds));
49+
}
50+
res.status = StatusCode::NoContent_204;
6151
return httplib::Server::HandlerResponse::Handled;
6252
}
6353
return httplib::Server::HandlerResponse::Unhandled;
@@ -602,12 +592,12 @@ void RESTServer::handle_component_topic_publish(const httplib::Request & req, ht
602592
void RESTServer::set_cors_headers(httplib::Response & res, const std::string & origin) const {
603593
res.set_header("Access-Control-Allow-Origin", origin);
604594

605-
// Use pre-built header strings (built once in constructor)
606-
if (!cors_methods_header_.empty()) {
607-
res.set_header("Access-Control-Allow-Methods", cors_methods_header_);
595+
// Use pre-built header strings from CorsConfig
596+
if (!cors_config_.methods_header.empty()) {
597+
res.set_header("Access-Control-Allow-Methods", cors_config_.methods_header);
608598
}
609-
if (!cors_headers_header_.empty()) {
610-
res.set_header("Access-Control-Allow-Headers", cors_headers_header_);
599+
if (!cors_config_.headers_header.empty()) {
600+
res.set_header("Access-Control-Allow-Headers", cors_config_.headers_header);
611601
}
612602

613603
// Set credentials header if enabled
@@ -617,12 +607,10 @@ void RESTServer::set_cors_headers(httplib::Response & res, const std::string & o
617607
}
618608

619609
bool RESTServer::is_origin_allowed(const std::string & origin) const {
620-
// If no origins configured, allow all (for development)
621-
if (cors_config_.allowed_origins.empty()) {
622-
return true;
623-
}
624-
625610
// Check if origin matches any allowed origin
611+
// Note: Wildcard "*" is allowed here but credentials+wildcard is blocked at startup
612+
// (see gateway_node.cpp validation). When wildcard is used, we echo back the actual
613+
// origin for security, as browsers require exact origin match with credentials.
626614
for (const auto & allowed : cors_config_.allowed_origins) {
627615
if (allowed == "*" || allowed == origin) {
628616
return true;

0 commit comments

Comments
 (0)