Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions crates/openshell-supervisor-network/src/l7/relay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,43 @@ fn emit_parse_rejection(ctx: &L7EvalContext, detail: &str, engine_type: &str) {
emit_activity(ctx, true, "l7_parse_rejection");
}

fn engine_type_for_protocol(protocol: L7Protocol) -> &'static str {
match protocol {
L7Protocol::Graphql => "l7-graphql",
L7Protocol::Websocket => "l7-websocket",
L7Protocol::Rest | L7Protocol::Sql => "l7",
}
}

async fn deny_h2c_upgrade_if_requested<C>(
req: &crate::l7::provider::L7Request,
config: &L7EndpointConfig,
ctx: &L7EvalContext,
client: &mut C,
) -> Result<bool>
where
C: AsyncRead + AsyncWrite + Unpin + Send,
{
if !crate::l7::rest::request_is_h2c_upgrade(&req.raw_header) {
return Ok(false);
}

emit_parse_rejection(
ctx,
crate::l7::rest::UNSUPPORTED_H2C_UPGRADE_DETAIL,
engine_type_for_protocol(config.protocol),
);
crate::l7::rest::RestProvider::default()
.deny(
req,
&ctx.policy_name,
crate::l7::rest::UNSUPPORTED_H2C_UPGRADE_DETAIL,
client,
)
.await?;
Ok(true)
}

/// Run protocol-aware L7 inspection on a tunnel.
///
/// This replaces `copy_bidirectional` for L7-enabled endpoints.
Expand Down Expand Up @@ -239,6 +276,10 @@ where
return Ok(());
};

if deny_h2c_upgrade_if_requested(&req, config, ctx, client).await? {
return Ok(());
}

let graphql_info = if config.protocol == L7Protocol::Graphql {
match crate::l7::graphql::inspect_graphql_request(
client,
Expand Down Expand Up @@ -662,6 +703,10 @@ where
}
};

if deny_h2c_upgrade_if_requested(&req, config, ctx, client).await? {
return Ok(());
}

if close_if_stale(engine.generation_guard(), ctx) {
return Ok(());
}
Expand Down Expand Up @@ -933,6 +978,10 @@ where
let req = parsed.request;
let graphql_info = parsed.info;

if deny_h2c_upgrade_if_requested(&req, config, ctx, client).await? {
return Ok(());
}

if close_if_stale(engine.generation_guard(), ctx) {
return Ok(());
}
Expand Down
109 changes: 97 additions & 12 deletions crates/openshell-supervisor-network/src/l7/rest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,22 @@ use tracing::debug;
const MAX_HEADER_BYTES: usize = 16384; // 16 KiB for HTTP headers
const MAX_REWRITE_BODY_BYTES: usize = 256 * 1024;
const RELAY_BUF_SIZE: usize = 8192;
const HTTP_METHOD_PREFIXES: &[&[u8]] = &[
b"GET ",
b"HEAD ",
b"POST ",
b"PUT ",
b"DELETE ",
b"PATCH ",
b"OPTIONS ",
b"CONNECT ",
b"TRACE ",
];
pub(crate) const HTTP2_PRIOR_KNOWLEDGE_PREFACE: &[u8] = b"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n";
pub(crate) const UNSUPPORTED_H2C_UPGRADE_DETAIL: &str =
"HTTP/2 cleartext upgrade (h2c) is not supported for L7-inspected endpoints";
const MIN_HTTP2_PREFACE_DETECTION_BYTES: usize = 8;

/// Idle timeout for `relay_until_eof`. If no data arrives within this window
/// the body is considered complete. Prevents blocking on servers that keep
/// the TCP connection alive after the response body (common with CDN keep-alive).
Expand Down Expand Up @@ -913,6 +929,36 @@ pub(crate) fn request_is_websocket_upgrade(raw_header: &[u8]) -> bool {
validate_websocket_upgrade_request(&raw_header[..header_end]).unwrap_or(false)
}

pub(crate) fn request_is_h2c_upgrade(raw_header: &[u8]) -> bool {
let header_end = raw_header
.windows(4)
.position(|w| w == b"\r\n\r\n")
.map_or(raw_header.len(), |p| p + 4);
let Ok(header_str) = std::str::from_utf8(&raw_header[..header_end]) else {
return false;
};

let mut upgrade_h2c = false;
let mut connection_upgrade = false;

for line in header_str.lines().skip(1) {
Comment thread
johntmyers marked this conversation as resolved.
let Some((name, value)) = line.split_once(':') else {
continue;
};
let name = name.trim();
let value = value.trim();
if name.eq_ignore_ascii_case("upgrade") && header_value_contains_token(value, "h2c") {
upgrade_h2c = true;
}
if name.eq_ignore_ascii_case("connection") && header_value_contains_token(value, "upgrade")
{
connection_upgrade = true;
}
}

upgrade_h2c && connection_upgrade
}

fn rewrite_websocket_extensions_for_mode(
raw_header: &[u8],
mode: WebSocketExtensionMode,
Expand Down Expand Up @@ -1962,18 +2008,27 @@ where
///
/// Checks for common HTTP methods at the start of the stream.
pub fn looks_like_http(peek: &[u8]) -> bool {
const METHODS: &[&[u8]] = &[
b"GET ",
b"HEAD ",
b"POST ",
b"PUT ",
b"DELETE ",
b"PATCH ",
b"OPTIONS ",
b"CONNECT ",
b"TRACE ",
];
METHODS.iter().any(|m| peek.starts_with(m))
HTTP_METHOD_PREFIXES
.iter()
.any(|method| peek.starts_with(method))
}

pub(crate) fn could_be_http_request_prefix(peek: &[u8]) -> bool {
!peek.is_empty()
&& HTTP_METHOD_PREFIXES
.iter()
.any(|method| peek.len() < method.len() && method.starts_with(peek))
}

pub fn looks_like_http2_prior_knowledge(peek: &[u8]) -> bool {
peek.len() >= MIN_HTTP2_PREFACE_DETECTION_BYTES
&& HTTP2_PRIOR_KNOWLEDGE_PREFACE.starts_with(peek)
}

pub(crate) fn could_be_http2_prior_knowledge_prefix(peek: &[u8]) -> bool {
!peek.is_empty()
&& peek.len() < MIN_HTTP2_PREFACE_DETECTION_BYTES
&& HTTP2_PRIOR_KNOWLEDGE_PREFACE.starts_with(peek)
}

/// Check if an IO error represents a benign connection close.
Expand Down Expand Up @@ -2919,10 +2974,26 @@ mod tests {
assert!(looks_like_http(b"GET / HTTP/1.1\r\n"));
assert!(looks_like_http(b"POST /api HTTP/1.1\r\n"));
assert!(looks_like_http(b"DELETE /foo HTTP/1.1\r\n"));
assert!(could_be_http_request_prefix(b"GE"));
assert!(!could_be_http_request_prefix(b"GET "));
assert!(!looks_like_http(b"\x00\x00\x00\x08")); // Postgres
assert!(!looks_like_http(HTTP2_PRIOR_KNOWLEDGE_PREFACE));
assert!(!looks_like_http(b"HELLO")); // Unknown
}

#[test]
fn http2_prior_knowledge_detection() {
assert!(looks_like_http2_prior_knowledge(
HTTP2_PRIOR_KNOWLEDGE_PREFACE
));
assert!(looks_like_http2_prior_knowledge(
&HTTP2_PRIOR_KNOWLEDGE_PREFACE[..8]
));
assert!(could_be_http2_prior_knowledge_prefix(b"PRI * H"));
assert!(!looks_like_http2_prior_knowledge(b"PRI * H"));
assert!(!looks_like_http2_prior_knowledge(b"PRI / HTTP/1.1\r\n"));
}

#[test]
fn test_parse_status_code() {
assert_eq!(
Expand Down Expand Up @@ -4160,6 +4231,20 @@ mod tests {
assert!(!validate_websocket_upgrade_request(raw).expect("h2c request should parse"));
}

#[test]
fn h2c_upgrade_detection_requires_upgrade_token_and_connection_upgrade() {
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";
assert!(request_is_h2c_upgrade(raw));

let missing_connection = b"GET /h2c HTTP/1.1\r\nHost: example.com\r\nUpgrade: h2c\r\n\r\n";
assert!(!request_is_h2c_upgrade(missing_connection));

let websocket = format!(
"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"
);
assert!(!request_is_h2c_upgrade(websocket.as_bytes()));
}

#[test]
fn strip_websocket_extensions_removes_extension_negotiation() {
let raw = format!(
Expand Down
Loading
Loading