Skip to content

Commit df3cde7

Browse files
committed
reverse_tunnels: add validation in the network filter
Signed-off-by: Rohit Agrawal <[email protected]>
1 parent 199458f commit df3cde7

File tree

8 files changed

+692
-47
lines changed

8 files changed

+692
-47
lines changed

api/envoy/extensions/filters/network/reverse_tunnel/v3/reverse_tunnel.proto

Lines changed: 75 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,27 +19,98 @@ option (udpa.annotations.file_status).package_version_status = ACTIVE;
1919
// Reverse Tunnel Network Filter :ref:`configuration overview <config_network_filters_reverse_tunnel>`.
2020
// [#extension: envoy.filters.network.reverse_tunnel]
2121

22+
// Validation configuration for reverse tunnel identifiers.
23+
// Validates the node ID and cluster ID extracted from reverse tunnel handshake headers
24+
// against expected values specified using format strings.
25+
message Validation {
26+
// Format string to extract the expected node identifier for validation.
27+
// The formatted value is compared against the ``x-envoy-reverse-tunnel-node-id`` header
28+
// from the incoming handshake request. If they do not match, the connection is rejected
29+
// with HTTP ``403 Forbidden``.
30+
//
31+
// Supports Envoy's :ref:`command operators <config_access_log_command_operators>`:
32+
//
33+
// * ``%DYNAMIC_METADATA(namespace:key)%``: Extract expected value from dynamic metadata.
34+
// * ``%FILTER_STATE(key)%``: Extract expected value from filter state.
35+
// * ``%DOWNSTREAM_REMOTE_ADDRESS%``: Use downstream connection IP address.
36+
// * Plain strings: Use a static expected value.
37+
//
38+
// If empty, node ID validation is skipped.
39+
//
40+
// Example using dynamic metadata allowlist:
41+
//
42+
// .. code-block:: yaml
43+
//
44+
// node_id_format: "%DYNAMIC_METADATA(envoy.reverse_tunnel.allowlist:expected_node_id)%"
45+
//
46+
string node_id_format = 1 [(validate.rules).string = {max_len: 1024}];
47+
48+
// Format string to extract the expected cluster identifier for validation.
49+
// The formatted value is compared against the ``x-envoy-reverse-tunnel-cluster-id`` header
50+
// from the incoming handshake request. If they do not match, the connection is rejected
51+
// with HTTP ``403 Forbidden``.
52+
//
53+
// Supports the same :ref:`command operators <config_access_log_command_operators>` as
54+
// ``node_id_format``.
55+
//
56+
// If empty, cluster ID validation is skipped.
57+
//
58+
// Example using filter state:
59+
//
60+
// .. code-block:: yaml
61+
//
62+
// cluster_id_format: "%FILTER_STATE(expected_cluster_id)%"
63+
//
64+
string cluster_id_format = 2 [(validate.rules).string = {max_len: 1024}];
65+
66+
// Whether to emit validation results as dynamic metadata.
67+
// When enabled, the filter emits metadata under the namespace specified by
68+
// ``dynamic_metadata_namespace`` containing:
69+
//
70+
// * ``node_id``: The actual node ID from the handshake request.
71+
// * ``cluster_id``: The actual cluster ID from the handshake request.
72+
// * ``validation_result``: Either ``allowed`` or ``denied``.
73+
//
74+
// This metadata can be used by subsequent filters or for access logging.
75+
// Defaults to ``false``.
76+
bool emit_dynamic_metadata = 3;
77+
78+
// Namespace for emitted dynamic metadata when ``emit_dynamic_metadata`` is ``true``.
79+
// If not specified, defaults to ``envoy.filters.network.reverse_tunnel``.
80+
string dynamic_metadata_namespace = 4 [(validate.rules).string = {max_len: 255}];
81+
}
82+
2283
// Configuration for the reverse tunnel network filter.
2384
// This filter handles reverse tunnel connection acceptance and rejection by processing
2485
// HTTP requests where required identification values are provided via HTTP headers.
86+
// [#next-free-field: 6]
2587
message ReverseTunnel {
2688
// Ping interval for health checks on established reverse tunnel connections.
27-
// If not specified, defaults to 2 seconds.
89+
// If not specified, defaults to ``2 seconds``.
2890
google.protobuf.Duration ping_interval = 1 [(validate.rules).duration = {
2991
lte {seconds: 300}
3092
gte {nanos: 1000000}
3193
}];
3294

3395
// Whether to automatically close connections after processing reverse tunnel requests.
34-
// When set to true, connections are closed after acceptance or rejection.
35-
// When set to false, connections remain open for potential reuse. Defaults to false.
96+
//
97+
// * When set to ``true``, connections are closed after acceptance or rejection.
98+
// * When set to ``false``, connections remain open for potential reuse.
99+
//
100+
// Defaults to ``false``.
36101
bool auto_close_connections = 2;
37102

38103
// HTTP path to match for reverse tunnel requests.
39-
// If not specified, defaults to "/reverse_connections/request".
104+
// If not specified, defaults to ``/reverse_connections/request``.
40105
string request_path = 3 [(validate.rules).string = {min_len: 1 max_len: 255 ignore_empty: true}];
41106

42107
// HTTP method to match for reverse tunnel requests.
43108
// If not specified (``METHOD_UNSPECIFIED``), this defaults to ``GET``.
44109
config.core.v3.RequestMethod request_method = 4 [(validate.rules).enum = {defined_only: true}];
110+
111+
// Optional validation configuration for node and cluster identifiers.
112+
// If specified, the filter validates the ``x-envoy-reverse-tunnel-node-id`` and
113+
// ``x-envoy-reverse-tunnel-cluster-id`` headers against expected values extracted
114+
// using format strings. Requests that fail validation are rejected with HTTP ``403 Forbidden``.
115+
Validation validation = 5;
45116
}

source/extensions/filters/network/reverse_tunnel/BUILD

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,13 +27,16 @@ envoy_cc_library(
2727
hdrs = ["reverse_tunnel_filter.h"],
2828
deps = [
2929
"//envoy/buffer:buffer_interface",
30+
"//envoy/formatter:substitution_formatter_interface",
3031
"//envoy/http:codec_interface",
3132
"//envoy/network:connection_interface",
3233
"//envoy/network:filter_interface",
3334
"//envoy/ssl:connection_interface",
3435
"//envoy/thread_local:thread_local_interface",
3536
"//source/common/buffer:buffer_lib",
3637
"//source/common/common:logger_lib",
38+
"//source/common/formatter:substitution_format_string_lib",
39+
"//source/common/formatter:substitution_formatter_lib",
3740
"//source/common/http:codes_lib",
3841
"//source/common/http:header_map_lib",
3942
"//source/common/http:headers_lib",
@@ -51,7 +54,9 @@ envoy_cc_library(
5154
"//source/extensions/bootstrap/reverse_tunnel/upstream_socket_interface:reverse_tunnel_acceptor_includes",
5255
"//source/extensions/bootstrap/reverse_tunnel/upstream_socket_interface:reverse_tunnel_acceptor_lib",
5356
"//source/extensions/bootstrap/reverse_tunnel/upstream_socket_interface:upstream_socket_manager_lib",
57+
"//source/server:generic_factory_context_lib",
5458
"//source/server:null_overload_manager_lib",
59+
"@envoy_api//envoy/config/core/v3:pkg_cc_proto",
5560
"@envoy_api//envoy/extensions/filters/network/reverse_tunnel/v3:pkg_cc_proto",
5661
],
5762
)

source/extensions/filters/network/reverse_tunnel/config.cc

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,12 @@ namespace ReverseTunnel {
1010
Network::FilterFactoryCb ReverseTunnelFilterConfigFactory::createFilterFactoryFromProtoTyped(
1111
const envoy::extensions::filters::network::reverse_tunnel::v3::ReverseTunnel& proto_config,
1212
Server::Configuration::FactoryContext& context) {
13-
auto config = std::make_shared<ReverseTunnelFilterConfig>(proto_config, context);
13+
auto config_or_error = ReverseTunnelFilterConfig::create(proto_config, context);
14+
if (!config_or_error.ok()) {
15+
throw EnvoyException(std::string(config_or_error.status().message()));
16+
}
17+
auto config = config_or_error.value();
18+
1419
// Capture scope and overload manager pointers to avoid dangling references.
1520
Stats::Scope* scope = &context.scope();
1621
Server::OverloadManager* overload_manager = &context.serverFactoryContext().overloadManager();

source/extensions/filters/network/reverse_tunnel/reverse_tunnel_filter.cc

Lines changed: 147 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
#include "source/extensions/filters/network/reverse_tunnel/reverse_tunnel_filter.h"
22

33
#include "envoy/buffer/buffer.h"
4+
#include "envoy/config/core/v3/substitution_format_string.pb.h"
45
#include "envoy/network/connection.h"
56
#include "envoy/server/overload/overload_manager.h"
67

78
#include "source/common/buffer/buffer_impl.h"
9+
#include "source/common/config/datasource.h"
10+
#include "source/common/formatter/substitution_format_string.h"
11+
#include "source/common/formatter/substitution_formatter.h"
812
#include "source/common/http/codes.h"
913
#include "source/common/http/header_map_impl.h"
1014
#include "source/common/http/headers.h"
@@ -15,6 +19,7 @@
1519
#include "source/extensions/bootstrap/reverse_tunnel/upstream_socket_interface/reverse_tunnel_acceptor.h"
1620
#include "source/extensions/bootstrap/reverse_tunnel/upstream_socket_interface/reverse_tunnel_acceptor_extension.h"
1721
#include "source/extensions/bootstrap/reverse_tunnel/upstream_socket_interface/upstream_socket_manager.h"
22+
#include "source/server/generic_factory_context.h"
1823

1924
namespace Envoy {
2025
namespace Extensions {
@@ -29,9 +34,59 @@ ReverseTunnelFilter::ReverseTunnelStats::generateStats(const std::string& prefix
2934
}
3035

3136
// ReverseTunnelFilterConfig implementation.
37+
absl::StatusOr<std::shared_ptr<ReverseTunnelFilterConfig>> ReverseTunnelFilterConfig::create(
38+
const envoy::extensions::filters::network::reverse_tunnel::v3::ReverseTunnel& proto_config,
39+
Server::Configuration::FactoryContext& context) {
40+
41+
Formatter::FormatterConstSharedPtr node_id_formatter;
42+
Formatter::FormatterConstSharedPtr cluster_id_formatter;
43+
44+
// Create formatters for validation if configured.
45+
if (proto_config.has_validation()) {
46+
Server::GenericFactoryContextImpl generic_context(context.serverFactoryContext(),
47+
context.messageValidationVisitor());
48+
49+
const auto& validation = proto_config.validation();
50+
51+
// Create node_id formatter if configured.
52+
if (!validation.node_id_format().empty()) {
53+
envoy::config::core::v3::SubstitutionFormatString node_id_format_config;
54+
node_id_format_config.mutable_text_format_source()->set_inline_string(
55+
validation.node_id_format());
56+
57+
auto formatter_or_error = Formatter::SubstitutionFormatStringUtils::fromProtoConfig(
58+
node_id_format_config, generic_context);
59+
if (!formatter_or_error.ok()) {
60+
return absl::InvalidArgumentError(fmt::format("Failed to parse node_id_format: {}",
61+
formatter_or_error.status().message()));
62+
}
63+
node_id_formatter = std::move(formatter_or_error.value());
64+
}
65+
66+
// Create cluster_id formatter if configured.
67+
if (!validation.cluster_id_format().empty()) {
68+
envoy::config::core::v3::SubstitutionFormatString cluster_id_format_config;
69+
cluster_id_format_config.mutable_text_format_source()->set_inline_string(
70+
validation.cluster_id_format());
71+
72+
auto formatter_or_error = Formatter::SubstitutionFormatStringUtils::fromProtoConfig(
73+
cluster_id_format_config, generic_context);
74+
if (!formatter_or_error.ok()) {
75+
return absl::InvalidArgumentError(fmt::format("Failed to parse cluster_id_format: {}",
76+
formatter_or_error.status().message()));
77+
}
78+
cluster_id_formatter = std::move(formatter_or_error.value());
79+
}
80+
}
81+
82+
return std::shared_ptr<ReverseTunnelFilterConfig>(new ReverseTunnelFilterConfig(
83+
proto_config, std::move(node_id_formatter), std::move(cluster_id_formatter)));
84+
}
85+
3286
ReverseTunnelFilterConfig::ReverseTunnelFilterConfig(
3387
const envoy::extensions::filters::network::reverse_tunnel::v3::ReverseTunnel& proto_config,
34-
Server::Configuration::FactoryContext&)
88+
Formatter::FormatterConstSharedPtr node_id_formatter,
89+
Formatter::FormatterConstSharedPtr cluster_id_formatter)
3590
: ping_interval_(proto_config.has_ping_interval()
3691
? std::chrono::milliseconds(
3792
DurationUtil::durationToMilliseconds(proto_config.ping_interval()))
@@ -46,7 +101,78 @@ ReverseTunnelFilterConfig::ReverseTunnelFilterConfig(
46101
method = envoy::config::core::v3::GET;
47102
}
48103
return envoy::config::core::v3::RequestMethod_Name(method);
49-
}()) {}
104+
}()),
105+
node_id_formatter_(std::move(node_id_formatter)),
106+
cluster_id_formatter_(std::move(cluster_id_formatter)),
107+
emit_dynamic_metadata_(proto_config.has_validation() &&
108+
proto_config.validation().emit_dynamic_metadata()),
109+
dynamic_metadata_namespace_(
110+
proto_config.has_validation() &&
111+
!proto_config.validation().dynamic_metadata_namespace().empty()
112+
? proto_config.validation().dynamic_metadata_namespace()
113+
: "envoy.filters.network.reverse_tunnel") {}
114+
115+
bool ReverseTunnelFilterConfig::validateIdentifiers(
116+
absl::string_view node_id, absl::string_view cluster_id,
117+
const StreamInfo::StreamInfo& stream_info) const {
118+
119+
// If no validation configured, pass validation.
120+
if (!node_id_formatter_ && !cluster_id_formatter_) {
121+
return true;
122+
}
123+
124+
// Validate node_id if formatter is configured.
125+
if (node_id_formatter_) {
126+
const std::string expected_node_id = node_id_formatter_->formatWithContext({}, stream_info);
127+
if (!expected_node_id.empty() && expected_node_id != node_id) {
128+
ENVOY_LOG(debug, "reverse_tunnel: node_id validation failed. Expected: '{}', Actual: '{}'",
129+
expected_node_id, node_id);
130+
return false;
131+
}
132+
}
133+
134+
// Validate cluster_id if formatter is configured.
135+
if (cluster_id_formatter_) {
136+
const std::string expected_cluster_id =
137+
cluster_id_formatter_->formatWithContext({}, stream_info);
138+
if (!expected_cluster_id.empty() && expected_cluster_id != cluster_id) {
139+
ENVOY_LOG(debug, "reverse_tunnel: cluster_id validation failed. Expected: '{}', Actual: '{}'",
140+
expected_cluster_id, cluster_id);
141+
return false;
142+
}
143+
}
144+
145+
return true;
146+
}
147+
148+
void ReverseTunnelFilterConfig::emitValidationMetadata(
149+
absl::string_view node_id, absl::string_view cluster_id, bool validation_passed,
150+
const StreamInfo::StreamInfo& stream_info) const {
151+
if (!emit_dynamic_metadata_) {
152+
return;
153+
}
154+
155+
Protobuf::Struct metadata;
156+
auto& fields = *metadata.mutable_fields();
157+
158+
// Emit actual identifiers.
159+
fields["node_id"].set_string_value(std::string(node_id));
160+
fields["cluster_id"].set_string_value(std::string(cluster_id));
161+
162+
// Emit validation result.
163+
fields["validation_result"].set_string_value(validation_passed ? "allowed" : "denied");
164+
165+
// StreamInfo::setDynamicMetadata is not const, so we need to cast away constness.
166+
// This is safe because we're modifying dynamic metadata, which is mutable by design.
167+
const_cast<StreamInfo::StreamInfo&>(stream_info)
168+
.setDynamicMetadata(dynamic_metadata_namespace_, metadata);
169+
170+
ENVOY_LOG(trace,
171+
"reverse_tunnel: emitted dynamic metadata to namespace '{}': node_id={}, "
172+
"cluster_id={}, validation_result={}",
173+
dynamic_metadata_namespace_, node_id, cluster_id,
174+
validation_passed ? "allowed" : "denied");
175+
}
50176

51177
// ReverseTunnelFilter implementation.
52178
ReverseTunnelFilter::ReverseTunnelFilter(ReverseTunnelFilterConfigSharedPtr config,
@@ -191,6 +317,25 @@ void ReverseTunnelFilter::RequestDecoderImpl::processIfComplete(bool end_stream)
191317
const absl::string_view cluster_id = cluster_vals[0]->value().getStringView();
192318
const absl::string_view tenant_id = tenant_vals[0]->value().getStringView();
193319

320+
// Validate node_id and cluster_id if validation is configured.
321+
const auto& connection = parent_.read_callbacks_->connection();
322+
const bool validation_passed =
323+
parent_.config_->validateIdentifiers(node_id, cluster_id, connection.streamInfo());
324+
325+
// Emit validation metadata if configured.
326+
parent_.config_->emitValidationMetadata(node_id, cluster_id, validation_passed,
327+
connection.streamInfo());
328+
329+
if (!validation_passed) {
330+
parent_.stats_.validation_failed_.inc();
331+
ENVOY_CONN_LOG(debug, "reverse_tunnel: validation failed for node '{}', cluster '{}'",
332+
parent_.read_callbacks_->connection(), node_id, cluster_id);
333+
sendLocalReply(Http::Code::Forbidden, "Validation failed", nullptr, absl::nullopt,
334+
"reverse_tunnel_validation_failed");
335+
parent_.read_callbacks_->connection().close(Network::ConnectionCloseType::FlushWrite);
336+
return;
337+
}
338+
194339
// Respond with 200 OK.
195340
auto resp_headers = Http::ResponseHeaderMapImpl::create();
196341
resp_headers->setStatus(200);

0 commit comments

Comments
 (0)