fix: harden tunnel security and timeout handling

This commit is contained in:
fawney19
2026-05-23 14:17:04 +08:00
parent 74f7348529
commit 6447fda852
19 changed files with 571 additions and 92 deletions

View File

@@ -185,6 +185,12 @@ pub struct RequestMeta {
pub method: String,
pub url: String,
pub headers: std::collections::HashMap<String, String>,
#[serde(default, skip_serializing_if = "is_false")]
pub stream: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub request_timeout_ms: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub stream_first_byte_timeout_ms: Option<u64>,
#[serde(default = "default_timeout", deserialize_with = "deserialize_timeout")]
pub timeout: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]

View File

@@ -39,6 +39,8 @@ pub enum TunnelSecurityError {
MissingEncryptedFlag,
#[error("secure tunnel frame payload is too short")]
PayloadTooShort,
#[error("secure tunnel frame sequence is not the expected next value")]
UnexpectedSequence,
#[error("secure tunnel frame encryption failed")]
Encrypt,
#[error("secure tunnel frame decryption failed")]
@@ -51,6 +53,7 @@ pub struct SecureFrameCodec {
seal_prefix: [u8; 4],
open_prefix: [u8; 4],
next_sequence: AtomicU64,
next_open_sequence: AtomicU64,
}
impl SecureFrameCodec {
@@ -90,6 +93,7 @@ impl SecureFrameCodec {
seal_prefix,
open_prefix,
next_sequence: AtomicU64::new(0),
next_open_sequence: AtomicU64::new(0),
})
}
@@ -132,6 +136,10 @@ impl SecureFrameCodec {
let mut payload = frame.payload.clone();
let sequence = payload.get_u64();
let expected_sequence = self.next_open_sequence.load(Ordering::Relaxed);
if sequence != expected_sequence {
return Err(TunnelSecurityError::UnexpectedSequence);
}
let nonce_bytes = nonce_bytes(self.open_prefix, sequence);
let nonce = Nonce::from_slice(&nonce_bytes);
let clear_flags = frame.flags & !FLAG_ENCRYPTED;
@@ -146,6 +154,8 @@ impl SecureFrameCodec {
},
)
.map_err(|_| TunnelSecurityError::Decrypt)?;
self.next_open_sequence
.store(expected_sequence.wrapping_add(1), Ordering::Relaxed);
Ok(Frame::new(
frame.stream_id,
@@ -243,6 +253,68 @@ mod tests {
));
}
#[test]
fn secure_frame_rejects_replayed_sequence() {
let client = SecureFrameCodec::new(&test_key(), "session-1", TunnelSecurityRole::Client)
.expect("client codec");
let server = SecureFrameCodec::new(&test_key(), "session-1", TunnelSecurityRole::Server)
.expect("server codec");
let encrypted = client
.encrypt_frame(Frame::new(
1,
MsgType::RequestBody,
0,
Bytes::from_static(b"secret"),
))
.expect("encrypt");
let wire = Frame::decode(encrypted).expect("wire frame");
server.decrypt_frame(wire.clone()).expect("first decrypt");
assert!(matches!(
server.decrypt_frame(wire),
Err(TunnelSecurityError::UnexpectedSequence)
));
}
#[test]
fn secure_frame_rejects_out_of_order_sequence_without_advancing() {
let client = SecureFrameCodec::new(&test_key(), "session-1", TunnelSecurityRole::Client)
.expect("client codec");
let server = SecureFrameCodec::new(&test_key(), "session-1", TunnelSecurityRole::Server)
.expect("server codec");
let first = Frame::decode(
client
.encrypt_frame(Frame::new(
1,
MsgType::RequestBody,
0,
Bytes::from_static(b"first"),
))
.expect("encrypt first"),
)
.expect("first wire frame");
let second = Frame::decode(
client
.encrypt_frame(Frame::new(
1,
MsgType::RequestBody,
0,
Bytes::from_static(b"second"),
))
.expect("encrypt second"),
)
.expect("second wire frame");
assert!(matches!(
server.decrypt_frame(second),
Err(TunnelSecurityError::UnexpectedSequence)
));
assert_eq!(
server.decrypt_frame(first).expect("first decrypt").payload,
Bytes::from_static(b"first")
);
}
#[test]
fn secure_frame_uses_session_in_key_derivation() {
let session_a = "node-1:connection-a";

View File

@@ -9,11 +9,12 @@ use uuid::Uuid;
use super::types::{
bucket_start_unix_secs, build_tunnel_error_event_detail, build_tunnel_metrics_sample,
log_reported_tunnel_error_event, normalize_proxy_metadata,
reconcile_remote_config_after_heartbeat, ProxyNodeEventQuery, ProxyNodeHeartbeatMutation,
ProxyNodeManualCreateMutation, ProxyNodeManualUpdateMutation, ProxyNodeMetricsCleanupSummary,
ProxyNodeMetricsStep, ProxyNodeReadRepository, ProxyNodeRegistrationMutation,
ProxyNodeRemoteConfigMutation, ProxyNodeTrafficMutation, ProxyNodeTunnelStatusMutation,
ProxyNodeWriteRepository, StoredProxyFleetMetricsBucket, StoredProxyNode, StoredProxyNodeEvent,
preserve_proxy_metadata_tunnel_security, reconcile_remote_config_after_heartbeat,
ProxyNodeEventQuery, ProxyNodeHeartbeatMutation, ProxyNodeManualCreateMutation,
ProxyNodeManualUpdateMutation, ProxyNodeMetricsCleanupSummary, ProxyNodeMetricsStep,
ProxyNodeReadRepository, ProxyNodeRegistrationMutation, ProxyNodeRemoteConfigMutation,
ProxyNodeTrafficMutation, ProxyNodeTunnelStatusMutation, ProxyNodeWriteRepository,
StoredProxyFleetMetricsBucket, StoredProxyNode, StoredProxyNodeEvent,
StoredProxyNodeMetricsBucket, TunnelMetricsSample, PROXY_NODE_EVENT_TYPE_TUNNEL_ERROR,
};
use crate::DataLayerError;
@@ -590,6 +591,10 @@ impl ProxyNodeWriteRepository for InMemoryProxyNodeRepository {
mutation.proxy_metadata.as_ref(),
mutation.proxy_version.as_deref(),
);
let normalized_proxy_metadata = preserve_proxy_metadata_tunnel_security(
previous_proxy_metadata.as_ref(),
normalized_proxy_metadata,
);
if let Some(value) = normalized_proxy_metadata {
node.proxy_metadata = Some(value);
}

View File

@@ -4,11 +4,12 @@ use sqlx::{mysql::MySqlRow, Row};
use super::types::{
bucket_start_unix_secs, build_tunnel_error_event_detail, build_tunnel_metrics_sample,
log_reported_tunnel_error_event, normalize_proxy_metadata,
reconcile_remote_config_after_heartbeat, ProxyNodeEventQuery, ProxyNodeHeartbeatMutation,
ProxyNodeManualCreateMutation, ProxyNodeManualUpdateMutation, ProxyNodeMetricsCleanupSummary,
ProxyNodeMetricsStep, ProxyNodeReadRepository, ProxyNodeRegistrationMutation,
ProxyNodeRemoteConfigMutation, ProxyNodeTrafficMutation, ProxyNodeTunnelStatusMutation,
ProxyNodeWriteRepository, StoredProxyFleetMetricsBucket, StoredProxyNode, StoredProxyNodeEvent,
preserve_proxy_metadata_tunnel_security, reconcile_remote_config_after_heartbeat,
ProxyNodeEventQuery, ProxyNodeHeartbeatMutation, ProxyNodeManualCreateMutation,
ProxyNodeManualUpdateMutation, ProxyNodeMetricsCleanupSummary, ProxyNodeMetricsStep,
ProxyNodeReadRepository, ProxyNodeRegistrationMutation, ProxyNodeRemoteConfigMutation,
ProxyNodeTrafficMutation, ProxyNodeTunnelStatusMutation, ProxyNodeWriteRepository,
StoredProxyFleetMetricsBucket, StoredProxyNode, StoredProxyNodeEvent,
StoredProxyNodeMetricsBucket, PROXY_NODE_EVENT_TYPE_TUNNEL_ERROR,
};
use crate::driver::mysql::MysqlPool;
@@ -779,10 +780,15 @@ WHERE is_manual = 0
if let Some(value) = mutation.avg_latency_ms {
node.avg_latency_ms = Some(value);
}
if let Some(value) = normalize_proxy_metadata(
let normalized_proxy_metadata = normalize_proxy_metadata(
mutation.proxy_metadata.as_ref(),
mutation.proxy_version.as_deref(),
) {
);
let normalized_proxy_metadata = preserve_proxy_metadata_tunnel_security(
previous_proxy_metadata.as_ref(),
normalized_proxy_metadata,
);
if let Some(value) = normalized_proxy_metadata {
node.proxy_metadata = Some(value);
}
if let Some(value) = mutation.total_requests_delta.filter(|value| *value > 0) {

View File

@@ -6,11 +6,12 @@ use sqlx::{postgres::PgRow, PgPool, Postgres, QueryBuilder, Row};
use super::types::{
bucket_start_unix_secs, build_tunnel_error_event_detail, build_tunnel_metrics_sample,
log_reported_tunnel_error_event, normalize_proxy_metadata,
reconcile_remote_config_after_heartbeat, ProxyNodeEventQuery, ProxyNodeHeartbeatMutation,
ProxyNodeManualCreateMutation, ProxyNodeManualUpdateMutation, ProxyNodeMetricsCleanupSummary,
ProxyNodeMetricsStep, ProxyNodeReadRepository, ProxyNodeRegistrationMutation,
ProxyNodeRemoteConfigMutation, ProxyNodeTrafficMutation, ProxyNodeTunnelStatusMutation,
ProxyNodeWriteRepository, StoredProxyFleetMetricsBucket, StoredProxyNode, StoredProxyNodeEvent,
preserve_proxy_metadata_tunnel_security, reconcile_remote_config_after_heartbeat,
ProxyNodeEventQuery, ProxyNodeHeartbeatMutation, ProxyNodeManualCreateMutation,
ProxyNodeManualUpdateMutation, ProxyNodeMetricsCleanupSummary, ProxyNodeMetricsStep,
ProxyNodeReadRepository, ProxyNodeRegistrationMutation, ProxyNodeRemoteConfigMutation,
ProxyNodeTrafficMutation, ProxyNodeTunnelStatusMutation, ProxyNodeWriteRepository,
StoredProxyFleetMetricsBucket, StoredProxyNode, StoredProxyNodeEvent,
StoredProxyNodeMetricsBucket, TunnelMetricsSample, PROXY_NODE_EVENT_TYPE_TUNNEL_ERROR,
};
use crate::{
@@ -1174,6 +1175,10 @@ impl ProxyNodeWriteRepository for SqlxProxyNodeRepository {
mutation.proxy_metadata.as_ref(),
mutation.proxy_version.as_deref(),
);
let normalized_proxy_metadata = preserve_proxy_metadata_tunnel_security(
existing.proxy_metadata.as_ref(),
normalized_proxy_metadata,
);
sqlx::query(APPLY_HEARTBEAT_SQL)
.bind(&mutation.node_id)

View File

@@ -4,11 +4,12 @@ use sqlx::{sqlite::SqliteRow, QueryBuilder, Row, Sqlite};
use super::types::{
bucket_start_unix_secs, build_tunnel_error_event_detail, build_tunnel_metrics_sample,
log_reported_tunnel_error_event, normalize_proxy_metadata,
reconcile_remote_config_after_heartbeat, ProxyNodeEventQuery, ProxyNodeHeartbeatMutation,
ProxyNodeManualCreateMutation, ProxyNodeManualUpdateMutation, ProxyNodeMetricsCleanupSummary,
ProxyNodeMetricsStep, ProxyNodeReadRepository, ProxyNodeRegistrationMutation,
ProxyNodeRemoteConfigMutation, ProxyNodeTrafficMutation, ProxyNodeTunnelStatusMutation,
ProxyNodeWriteRepository, StoredProxyFleetMetricsBucket, StoredProxyNode, StoredProxyNodeEvent,
preserve_proxy_metadata_tunnel_security, reconcile_remote_config_after_heartbeat,
ProxyNodeEventQuery, ProxyNodeHeartbeatMutation, ProxyNodeManualCreateMutation,
ProxyNodeManualUpdateMutation, ProxyNodeMetricsCleanupSummary, ProxyNodeMetricsStep,
ProxyNodeReadRepository, ProxyNodeRegistrationMutation, ProxyNodeRemoteConfigMutation,
ProxyNodeTrafficMutation, ProxyNodeTunnelStatusMutation, ProxyNodeWriteRepository,
StoredProxyFleetMetricsBucket, StoredProxyNode, StoredProxyNodeEvent,
StoredProxyNodeMetricsBucket, PROXY_NODE_EVENT_TYPE_TUNNEL_ERROR,
};
use crate::driver::sqlite::SqlitePool;
@@ -770,10 +771,15 @@ WHERE is_manual = 0
if let Some(value) = mutation.avg_latency_ms {
node.avg_latency_ms = Some(value);
}
if let Some(value) = normalize_proxy_metadata(
let normalized_proxy_metadata = normalize_proxy_metadata(
mutation.proxy_metadata.as_ref(),
mutation.proxy_version.as_deref(),
) {
);
let normalized_proxy_metadata = preserve_proxy_metadata_tunnel_security(
previous_proxy_metadata.as_ref(),
normalized_proxy_metadata,
);
if let Some(value) = normalized_proxy_metadata {
node.proxy_metadata = Some(value);
}
if let Some(value) = mutation.total_requests_delta.filter(|value| *value > 0) {

View File

@@ -485,6 +485,34 @@ pub fn normalize_proxy_metadata(
}
}
pub fn preserve_proxy_metadata_tunnel_security(
previous_proxy_metadata: Option<&Value>,
next_proxy_metadata: Option<Value>,
) -> Option<Value> {
let Some(tunnel_security) = previous_proxy_metadata
.and_then(|value| value.get("tunnel_security"))
.filter(|value| value.is_object())
.cloned()
else {
return next_proxy_metadata;
};
match next_proxy_metadata {
Some(Value::Object(mut metadata)) => {
metadata
.entry("tunnel_security".to_string())
.or_insert(tunnel_security);
Some(Value::Object(metadata))
}
Some(value) => Some(value),
None => {
let mut metadata = serde_json::Map::new();
metadata.insert("tunnel_security".to_string(), tunnel_security);
Some(Value::Object(metadata))
}
}
}
fn extract_tunnel_metrics_counters(
proxy_metadata: Option<&Value>,
) -> Option<TunnelMetricsCounters> {
@@ -772,10 +800,10 @@ mod tests {
use super::{
bucket_start_unix_secs, build_tunnel_error_event_detail, build_tunnel_metrics_sample,
normalize_proxy_node_scheduling_state, proxy_node_accepts_new_tunnels,
proxy_reported_version, reconcile_remote_config_after_heartbeat,
remote_config_scheduling_state, remote_config_upgrade_target, ProxyNodeMetricsStep,
StoredProxyNode,
normalize_proxy_node_scheduling_state, preserve_proxy_metadata_tunnel_security,
proxy_node_accepts_new_tunnels, proxy_reported_version,
reconcile_remote_config_after_heartbeat, remote_config_scheduling_state,
remote_config_upgrade_target, ProxyNodeMetricsStep, StoredProxyNode,
};
#[test]
@@ -934,6 +962,40 @@ mod tests {
);
}
#[test]
fn preserves_secure_tunnel_metadata_across_heartbeat_metadata_refresh() {
let previous = json!({
"version": "1.0.0",
"tunnel_security": {
"mode": "non_tls_required",
"encryption_key": "BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc="
}
});
let next = json!({
"version": "1.0.1",
"tunnel_metrics": {"connect_successes": 1}
});
let merged = preserve_proxy_metadata_tunnel_security(Some(&previous), Some(next))
.expect("metadata should remain present");
assert_eq!(
merged
.pointer("/tunnel_security/mode")
.and_then(|v| v.as_str()),
Some("non_tls_required")
);
assert_eq!(
merged
.pointer("/tunnel_security/encryption_key")
.and_then(|v| v.as_str()),
Some("BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc=")
);
assert_eq!(
merged.pointer("/tunnel_metrics/connect_successes"),
Some(&json!(1))
);
}
#[test]
fn maps_timestamps_to_metric_buckets() {
assert_eq!(

View File

@@ -609,6 +609,9 @@ fn relay_envelope() -> Vec<u8> {
"content-type".to_string(),
"application/json".to_string(),
)]),
stream: false,
request_timeout_ms: None,
stream_first_byte_timeout_ms: None,
timeout: 30,
follow_redirects: None,
http1_only: false,

View File

@@ -117,6 +117,9 @@ fn relay_envelope() -> Vec<u8> {
"content-type".to_string(),
"application/json".to_string(),
)]),
stream: true,
request_timeout_ms: None,
stream_first_byte_timeout_ms: None,
timeout: 30,
follow_redirects: None,
http1_only: false,

View File

@@ -253,6 +253,9 @@ fn relay_envelope() -> Vec<u8> {
"content-type".to_string(),
"application/json".to_string(),
)]),
stream: false,
request_timeout_ms: None,
stream_first_byte_timeout_ms: None,
timeout: 30,
follow_redirects: None,
http1_only: false,