Skip to content

Commit d64542f

Browse files
fix(supervisor-network): block h2c L7 tunnel escape (#1967)
Signed-off-by: ddurst <267424412+ddurst-nvidia@users.noreply.github.com>
1 parent 85c52bb commit d64542f

3 files changed

Lines changed: 444 additions & 22 deletions

File tree

crates/openshell-supervisor-network/src/l7/relay.rs

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,43 @@ fn emit_parse_rejection(ctx: &L7EvalContext, detail: &str, engine_type: &str) {
136136
emit_activity(ctx, true, "l7_parse_rejection");
137137
}
138138

139+
fn engine_type_for_protocol(protocol: L7Protocol) -> &'static str {
140+
match protocol {
141+
L7Protocol::Graphql => "l7-graphql",
142+
L7Protocol::Websocket => "l7-websocket",
143+
L7Protocol::Rest | L7Protocol::Sql => "l7",
144+
}
145+
}
146+
147+
async fn deny_h2c_upgrade_if_requested<C>(
148+
req: &crate::l7::provider::L7Request,
149+
config: &L7EndpointConfig,
150+
ctx: &L7EvalContext,
151+
client: &mut C,
152+
) -> Result<bool>
153+
where
154+
C: AsyncRead + AsyncWrite + Unpin + Send,
155+
{
156+
if !crate::l7::rest::request_is_h2c_upgrade(&req.raw_header) {
157+
return Ok(false);
158+
}
159+
160+
emit_parse_rejection(
161+
ctx,
162+
crate::l7::rest::UNSUPPORTED_H2C_UPGRADE_DETAIL,
163+
engine_type_for_protocol(config.protocol),
164+
);
165+
crate::l7::rest::RestProvider::default()
166+
.deny(
167+
req,
168+
&ctx.policy_name,
169+
crate::l7::rest::UNSUPPORTED_H2C_UPGRADE_DETAIL,
170+
client,
171+
)
172+
.await?;
173+
Ok(true)
174+
}
175+
139176
/// Run protocol-aware L7 inspection on a tunnel.
140177
///
141178
/// This replaces `copy_bidirectional` for L7-enabled endpoints.
@@ -239,6 +276,10 @@ where
239276
return Ok(());
240277
};
241278

279+
if deny_h2c_upgrade_if_requested(&req, config, ctx, client).await? {
280+
return Ok(());
281+
}
282+
242283
let graphql_info = if config.protocol == L7Protocol::Graphql {
243284
match crate::l7::graphql::inspect_graphql_request(
244285
client,
@@ -662,6 +703,10 @@ where
662703
}
663704
};
664705

706+
if deny_h2c_upgrade_if_requested(&req, config, ctx, client).await? {
707+
return Ok(());
708+
}
709+
665710
if close_if_stale(engine.generation_guard(), ctx) {
666711
return Ok(());
667712
}
@@ -933,6 +978,10 @@ where
933978
let req = parsed.request;
934979
let graphql_info = parsed.info;
935980

981+
if deny_h2c_upgrade_if_requested(&req, config, ctx, client).await? {
982+
return Ok(());
983+
}
984+
936985
if close_if_stale(engine.generation_guard(), ctx) {
937986
return Ok(());
938987
}

crates/openshell-supervisor-network/src/l7/rest.rs

Lines changed: 97 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,22 @@ use tracing::debug;
2222
const MAX_HEADER_BYTES: usize = 16384; // 16 KiB for HTTP headers
2323
const MAX_REWRITE_BODY_BYTES: usize = 256 * 1024;
2424
const RELAY_BUF_SIZE: usize = 8192;
25+
const HTTP_METHOD_PREFIXES: &[&[u8]] = &[
26+
b"GET ",
27+
b"HEAD ",
28+
b"POST ",
29+
b"PUT ",
30+
b"DELETE ",
31+
b"PATCH ",
32+
b"OPTIONS ",
33+
b"CONNECT ",
34+
b"TRACE ",
35+
];
36+
pub(crate) const HTTP2_PRIOR_KNOWLEDGE_PREFACE: &[u8] = b"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n";
37+
pub(crate) const UNSUPPORTED_H2C_UPGRADE_DETAIL: &str =
38+
"HTTP/2 cleartext upgrade (h2c) is not supported for L7-inspected endpoints";
39+
const MIN_HTTP2_PREFACE_DETECTION_BYTES: usize = 8;
40+
2541
/// Idle timeout for `relay_until_eof`. If no data arrives within this window
2642
/// the body is considered complete. Prevents blocking on servers that keep
2743
/// the TCP connection alive after the response body (common with CDN keep-alive).
@@ -913,6 +929,36 @@ pub(crate) fn request_is_websocket_upgrade(raw_header: &[u8]) -> bool {
913929
validate_websocket_upgrade_request(&raw_header[..header_end]).unwrap_or(false)
914930
}
915931

932+
pub(crate) fn request_is_h2c_upgrade(raw_header: &[u8]) -> bool {
933+
let header_end = raw_header
934+
.windows(4)
935+
.position(|w| w == b"\r\n\r\n")
936+
.map_or(raw_header.len(), |p| p + 4);
937+
let Ok(header_str) = std::str::from_utf8(&raw_header[..header_end]) else {
938+
return false;
939+
};
940+
941+
let mut upgrade_h2c = false;
942+
let mut connection_upgrade = false;
943+
944+
for line in header_str.lines().skip(1) {
945+
let Some((name, value)) = line.split_once(':') else {
946+
continue;
947+
};
948+
let name = name.trim();
949+
let value = value.trim();
950+
if name.eq_ignore_ascii_case("upgrade") && header_value_contains_token(value, "h2c") {
951+
upgrade_h2c = true;
952+
}
953+
if name.eq_ignore_ascii_case("connection") && header_value_contains_token(value, "upgrade")
954+
{
955+
connection_upgrade = true;
956+
}
957+
}
958+
959+
upgrade_h2c && connection_upgrade
960+
}
961+
916962
fn rewrite_websocket_extensions_for_mode(
917963
raw_header: &[u8],
918964
mode: WebSocketExtensionMode,
@@ -1962,18 +2008,27 @@ where
19622008
///
19632009
/// Checks for common HTTP methods at the start of the stream.
19642010
pub fn looks_like_http(peek: &[u8]) -> bool {
1965-
const METHODS: &[&[u8]] = &[
1966-
b"GET ",
1967-
b"HEAD ",
1968-
b"POST ",
1969-
b"PUT ",
1970-
b"DELETE ",
1971-
b"PATCH ",
1972-
b"OPTIONS ",
1973-
b"CONNECT ",
1974-
b"TRACE ",
1975-
];
1976-
METHODS.iter().any(|m| peek.starts_with(m))
2011+
HTTP_METHOD_PREFIXES
2012+
.iter()
2013+
.any(|method| peek.starts_with(method))
2014+
}
2015+
2016+
pub(crate) fn could_be_http_request_prefix(peek: &[u8]) -> bool {
2017+
!peek.is_empty()
2018+
&& HTTP_METHOD_PREFIXES
2019+
.iter()
2020+
.any(|method| peek.len() < method.len() && method.starts_with(peek))
2021+
}
2022+
2023+
pub fn looks_like_http2_prior_knowledge(peek: &[u8]) -> bool {
2024+
peek.len() >= MIN_HTTP2_PREFACE_DETECTION_BYTES
2025+
&& HTTP2_PRIOR_KNOWLEDGE_PREFACE.starts_with(peek)
2026+
}
2027+
2028+
pub(crate) fn could_be_http2_prior_knowledge_prefix(peek: &[u8]) -> bool {
2029+
!peek.is_empty()
2030+
&& peek.len() < MIN_HTTP2_PREFACE_DETECTION_BYTES
2031+
&& HTTP2_PRIOR_KNOWLEDGE_PREFACE.starts_with(peek)
19772032
}
19782033

19792034
/// Check if an IO error represents a benign connection close.
@@ -2919,10 +2974,26 @@ mod tests {
29192974
assert!(looks_like_http(b"GET / HTTP/1.1\r\n"));
29202975
assert!(looks_like_http(b"POST /api HTTP/1.1\r\n"));
29212976
assert!(looks_like_http(b"DELETE /foo HTTP/1.1\r\n"));
2977+
assert!(could_be_http_request_prefix(b"GE"));
2978+
assert!(!could_be_http_request_prefix(b"GET "));
29222979
assert!(!looks_like_http(b"\x00\x00\x00\x08")); // Postgres
2980+
assert!(!looks_like_http(HTTP2_PRIOR_KNOWLEDGE_PREFACE));
29232981
assert!(!looks_like_http(b"HELLO")); // Unknown
29242982
}
29252983

2984+
#[test]
2985+
fn http2_prior_knowledge_detection() {
2986+
assert!(looks_like_http2_prior_knowledge(
2987+
HTTP2_PRIOR_KNOWLEDGE_PREFACE
2988+
));
2989+
assert!(looks_like_http2_prior_knowledge(
2990+
&HTTP2_PRIOR_KNOWLEDGE_PREFACE[..8]
2991+
));
2992+
assert!(could_be_http2_prior_knowledge_prefix(b"PRI * H"));
2993+
assert!(!looks_like_http2_prior_knowledge(b"PRI * H"));
2994+
assert!(!looks_like_http2_prior_knowledge(b"PRI / HTTP/1.1\r\n"));
2995+
}
2996+
29262997
#[test]
29272998
fn test_parse_status_code() {
29282999
assert_eq!(
@@ -4160,6 +4231,20 @@ mod tests {
41604231
assert!(!validate_websocket_upgrade_request(raw).expect("h2c request should parse"));
41614232
}
41624233

4234+
#[test]
4235+
fn h2c_upgrade_detection_requires_upgrade_token_and_connection_upgrade() {
4236+
let raw = b"GET /h2c HTTP/1.1\r\nHost: example.com\r\nUpgrade: h2c\r\nConnection: keep-alive, Upgrade\r\nHTTP2-Settings: AAMAAABkAAQAAP__\r\n\r\n";
4237+
assert!(request_is_h2c_upgrade(raw));
4238+
4239+
let missing_connection = b"GET /h2c HTTP/1.1\r\nHost: example.com\r\nUpgrade: h2c\r\n\r\n";
4240+
assert!(!request_is_h2c_upgrade(missing_connection));
4241+
4242+
let websocket = format!(
4243+
"GET /ws HTTP/1.1\r\nHost: example.com\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: {VALID_WS_KEY}\r\nSec-WebSocket-Version: 13\r\n\r\n"
4244+
);
4245+
assert!(!request_is_h2c_upgrade(websocket.as_bytes()));
4246+
}
4247+
41634248
#[test]
41644249
fn strip_websocket_extensions_removes_extension_negotiation() {
41654250
let raw = format!(

0 commit comments

Comments
 (0)