Skip to content

Commit 5cc8e2f

Browse files
committed
fix(network): preserve chunked request boundaries
Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com>
1 parent 484f076 commit 5cc8e2f

2 files changed

Lines changed: 198 additions & 29 deletions

File tree

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

Lines changed: 136 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -3140,15 +3140,20 @@ where
31403140
if let Some(end) = find_crlf(&parse_buf, pos) {
31413141
break end;
31423142
}
3143-
let n = reader.read(&mut read_buf).await.into_diagnostic()?;
3144-
if n == 0 {
3145-
return Err(miette!("Chunked body ended before chunk-size line"));
3146-
}
3147-
if let Some(guard) = generation_guard {
3148-
guard.ensure_current()?;
3149-
}
3150-
writer.write_all(&read_buf[..n]).await.into_diagnostic()?;
3151-
parse_buf.extend_from_slice(&read_buf[..n]);
3143+
let target_len = parse_buf
3144+
.len()
3145+
.checked_add(1)
3146+
.ok_or_else(|| miette!("Chunked body size overflow"))?;
3147+
relay_chunked_until_len(
3148+
reader,
3149+
writer,
3150+
&mut read_buf,
3151+
&mut parse_buf,
3152+
target_len,
3153+
generation_guard,
3154+
"Chunked body ended before chunk-size line",
3155+
)
3156+
.await?;
31523157
};
31533158

31543159
let size_line = std::str::from_utf8(&parse_buf[pos..size_line_end])
@@ -3172,15 +3177,20 @@ where
31723177
if let Some(end) = find_crlf(&parse_buf, pos) {
31733178
break end;
31743179
}
3175-
let n = reader.read(&mut read_buf).await.into_diagnostic()?;
3176-
if n == 0 {
3177-
return Err(miette!("Chunked body ended before trailer terminator"));
3178-
}
3179-
if let Some(guard) = generation_guard {
3180-
guard.ensure_current()?;
3181-
}
3182-
writer.write_all(&read_buf[..n]).await.into_diagnostic()?;
3183-
parse_buf.extend_from_slice(&read_buf[..n]);
3180+
let target_len = parse_buf
3181+
.len()
3182+
.checked_add(1)
3183+
.ok_or_else(|| miette!("Chunked trailer size overflow"))?;
3184+
relay_chunked_until_len(
3185+
reader,
3186+
writer,
3187+
&mut read_buf,
3188+
&mut parse_buf,
3189+
target_len,
3190+
generation_guard,
3191+
"Chunked body ended before trailer terminator",
3192+
)
3193+
.await?;
31843194
};
31853195

31863196
let trailer_line = &parse_buf[pos..trailer_end];
@@ -3207,17 +3217,16 @@ where
32073217
.checked_add(2)
32083218
.ok_or_else(|| miette!("Chunk size overflow"))?;
32093219

3210-
while parse_buf.len() < chunk_with_crlf_end {
3211-
let n = reader.read(&mut read_buf).await.into_diagnostic()?;
3212-
if n == 0 {
3213-
return Err(miette!("Chunked body ended mid-chunk"));
3214-
}
3215-
if let Some(guard) = generation_guard {
3216-
guard.ensure_current()?;
3217-
}
3218-
writer.write_all(&read_buf[..n]).await.into_diagnostic()?;
3219-
parse_buf.extend_from_slice(&read_buf[..n]);
3220-
}
3220+
relay_chunked_until_len(
3221+
reader,
3222+
writer,
3223+
&mut read_buf,
3224+
&mut parse_buf,
3225+
chunk_with_crlf_end,
3226+
generation_guard,
3227+
"Chunked body ended mid-chunk",
3228+
)
3229+
.await?;
32213230
if &parse_buf[chunk_end..chunk_with_crlf_end] != b"\r\n" {
32223231
return Err(miette!("Chunk missing terminating CRLF"));
32233232
}
@@ -3233,6 +3242,43 @@ where
32333242
}
32343243
}
32353244

3245+
/// Read and forward only the bytes needed to reach `target_len`.
3246+
///
3247+
/// Limiting each socket read to the current chunked framing boundary prevents
3248+
/// a pipelined request from being consumed and written upstream as part of the
3249+
/// authorized request body.
3250+
async fn relay_chunked_until_len<R, W>(
3251+
reader: &mut R,
3252+
writer: &mut W,
3253+
read_buf: &mut [u8; RELAY_BUF_SIZE],
3254+
parse_buf: &mut Vec<u8>,
3255+
target_len: usize,
3256+
generation_guard: Option<&PolicyGenerationGuard>,
3257+
eof_message: &'static str,
3258+
) -> Result<()>
3259+
where
3260+
R: AsyncRead + Unpin,
3261+
W: AsyncWrite + Unpin,
3262+
{
3263+
while parse_buf.len() < target_len {
3264+
let remaining = target_len - parse_buf.len();
3265+
let to_read = remaining.min(read_buf.len());
3266+
let n = reader
3267+
.read(&mut read_buf[..to_read])
3268+
.await
3269+
.into_diagnostic()?;
3270+
if n == 0 {
3271+
return Err(miette!(eof_message));
3272+
}
3273+
if let Some(guard) = generation_guard {
3274+
guard.ensure_current()?;
3275+
}
3276+
writer.write_all(&read_buf[..n]).await.into_diagnostic()?;
3277+
parse_buf.extend_from_slice(&read_buf[..n]);
3278+
}
3279+
Ok(())
3280+
}
3281+
32363282
fn find_crlf(buf: &[u8], start: usize) -> Option<usize> {
32373283
buf.get(start..)?
32383284
.windows(2)
@@ -4553,6 +4599,67 @@ mod tests {
45534599
}
45544600
}
45554601

4602+
#[tokio::test]
4603+
async fn relay_chunked_leaves_pipelined_request_for_next_policy_decision() {
4604+
let chunked_body = b"0\r\n\r\n";
4605+
let pipelined_request =
4606+
b"DELETE /blocked HTTP/1.1\r\nHost: example.com\r\nContent-Length: 0\r\n\r\n";
4607+
let mut wire = chunked_body.to_vec();
4608+
wire.extend_from_slice(pipelined_request);
4609+
4610+
let (mut relay_reader, mut client_writer) = tokio::io::duplex(4096);
4611+
client_writer.write_all(&wire).await.unwrap();
4612+
let (mut relay_writer, mut upstream_reader) = tokio::io::duplex(4096);
4613+
4614+
relay_chunked(&mut relay_reader, &mut relay_writer, &[], None)
4615+
.await
4616+
.expect("chunked body should relay");
4617+
4618+
let mut remaining = vec![0; pipelined_request.len()];
4619+
relay_reader.read_exact(&mut remaining).await.unwrap();
4620+
assert_eq!(remaining, pipelined_request);
4621+
4622+
drop(relay_writer);
4623+
let mut forwarded = Vec::new();
4624+
upstream_reader.read_to_end(&mut forwarded).await.unwrap();
4625+
assert_eq!(forwarded, chunked_body);
4626+
}
4627+
4628+
#[tokio::test]
4629+
async fn relay_chunked_with_forwarded_prefix_and_trailers_preserves_pipeline_boundary() {
4630+
let already_forwarded = b"3\r\na";
4631+
let body_remainder = b"bc\r\n0\r\nX-Checksum: abc123\r\n\r\n";
4632+
let pipelined_request =
4633+
b"DELETE /blocked HTTP/1.1\r\nHost: example.com\r\nContent-Length: 0\r\n\r\n";
4634+
let mut later_read = body_remainder.to_vec();
4635+
later_read.extend_from_slice(pipelined_request);
4636+
4637+
let (mut relay_reader, mut client_writer) = tokio::io::duplex(4096);
4638+
client_writer.write_all(&later_read).await.unwrap();
4639+
let (mut relay_writer, mut upstream_reader) = tokio::io::duplex(4096);
4640+
relay_writer.write_all(already_forwarded).await.unwrap();
4641+
4642+
relay_chunked(
4643+
&mut relay_reader,
4644+
&mut relay_writer,
4645+
already_forwarded,
4646+
None,
4647+
)
4648+
.await
4649+
.expect("chunked body with trailers should relay");
4650+
4651+
let mut remaining = vec![0; pipelined_request.len()];
4652+
relay_reader.read_exact(&mut remaining).await.unwrap();
4653+
assert_eq!(remaining, pipelined_request);
4654+
4655+
drop(relay_writer);
4656+
let mut forwarded = Vec::new();
4657+
upstream_reader.read_to_end(&mut forwarded).await.unwrap();
4658+
let mut expected = already_forwarded.to_vec();
4659+
expected.extend_from_slice(body_remainder);
4660+
assert_eq!(forwarded, expected);
4661+
}
4662+
45564663
#[test]
45574664
fn parse_no_body() {
45584665
let headers = "GET /api HTTP/1.1\r\nHost: example.com\r\n\r\n";

‎e2e/rust/tests/proxy_egress_pipeline.rs‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1499,6 +1499,68 @@ print("TRANSPARENT_PIPELINE_DENIED")
14991499
assert!(!observed.contains("/blocked"));
15001500
}
15011501

1502+
#[tokio::test]
1503+
#[serial(proxy_egress_pipeline)]
1504+
async fn chunked_pipeline_is_authorized_separately_before_reaching_upstream() {
1505+
let server = PipelineProbeServer::start()
1506+
.await
1507+
.expect("start pipeline probe server");
1508+
let endpoint_options = r#" protocol: rest
1509+
enforcement: enforce
1510+
rules:
1511+
- allow:
1512+
method: POST
1513+
path: "/allowed""#;
1514+
let policy = write_policy(TEST_SERVER_HOST, server.port, endpoint_options)
1515+
.expect("write pipeline policy");
1516+
let policy_path = policy_path(&policy);
1517+
let script = format!(
1518+
r#"
1519+
import socket
1520+
1521+
target = "{host}:{port}"
1522+
first = (
1523+
f"POST /allowed HTTP/1.1\r\n"
1524+
f"Host: {{target}}\r\nTransfer-Encoding: chunked\r\nConnection: keep-alive\r\n\r\n"
1525+
f"0\r\n\r\n"
1526+
)
1527+
second = (
1528+
f"DELETE /blocked HTTP/1.1\r\n"
1529+
f"Host: {{target}}\r\nContent-Length: 0\r\n\r\n"
1530+
)
1531+
with socket.create_connection(({host:?}, {port}), timeout=10) as sock:
1532+
sock.sendall((first + second).encode())
1533+
response = b""
1534+
while True:
1535+
chunk = sock.recv(4096)
1536+
if not chunk:
1537+
break
1538+
response += chunk
1539+
responses = response.count(b"HTTP/1.1 ")
1540+
first_status = response.split(b"\r\n", 1)[0]
1541+
if responses != 2 or b" 200 " not in first_status or b"HTTP/1.1 403 Forbidden" not in response:
1542+
raise RuntimeError(f"unexpected chunked pipeline response: {{response!r}}")
1543+
print("CHUNKED_PIPELINE_DENIED")
1544+
"#,
1545+
host = TEST_SERVER_HOST,
1546+
port = server.port,
1547+
);
1548+
1549+
let guard = SandboxGuard::create(&["--policy", &policy_path, "--", "python3", "-c", &script])
1550+
.await
1551+
.expect("sandbox create");
1552+
assert!(
1553+
guard.create_output.contains("CHUNKED_PIPELINE_DENIED"),
1554+
"transparent HTTP stream did not evaluate the chunked pipeline separately:\n{}",
1555+
guard.create_output
1556+
);
1557+
1558+
let observed = String::from_utf8(server.observed_request()).expect("upstream HTTP request");
1559+
assert!(observed.starts_with("POST /allowed HTTP/1.1\r\n"));
1560+
assert!(observed.ends_with("0\r\n\r\n"));
1561+
assert!(!observed.contains("/blocked"));
1562+
}
1563+
15021564
#[tokio::test]
15031565
#[serial(proxy_egress_pipeline)]
15041566
async fn http_credentials_are_rewritten_in_transparent_headers_and_bodies() {

0 commit comments

Comments
 (0)