Skip to content

Commit 90084e7

Browse files
committed
fix(sandbox): relay WebSocket frames after HTTP 101 Switching Protocols
The L7 REST proxy treats 101 Switching Protocols as a generic 1xx informational response via is_bodiless_response(), forwarding the headers and returning to the HTTP parsing loop. After a 101, the connection has been upgraded (e.g. to WebSocket) and subsequent bytes are protocol frames, not HTTP requests. The relay loop either blocks or silently drops them. This patch: - Adds RelayOutcome::Upgraded variant to signal protocol upgrades - Detects 101 responses before the generic 1xx handler in relay_response(), capturing any overflow bytes read past the headers - Switches relay_rest() and relay_passthrough_with_credentials() to raw bidirectional TCP copy (tokio::io::copy_bidirectional) after receiving an Upgraded outcome - Adds a test verifying 101 response handling and overflow capture This enables WebSocket connections (OpenClaw node meshes, Discord/Slack bots) to work from inside fully sandboxed environments. Fixes: #652 Related: NVIDIA/NemoClaw#409 Signed-off-by: David Peden <davidpeden3@gmail.com>
1 parent 2538bea commit 90084e7

File tree

3 files changed

+179
-38
lines changed

3 files changed

+179
-38
lines changed

crates/openshell-sandbox/src/l7/provider.rs

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,21 @@ use std::collections::HashMap;
1414
use std::future::Future;
1515
use tokio::io::{AsyncRead, AsyncWrite};
1616

17+
/// Outcome of relaying a single HTTP request/response pair.
18+
#[derive(Debug)]
19+
pub enum RelayOutcome {
20+
/// Connection is reusable for further HTTP requests (keep-alive).
21+
Reusable,
22+
/// Connection was consumed (e.g. read-until-EOF or `Connection: close`).
23+
Consumed,
24+
/// Server responded with 101 Switching Protocols.
25+
/// The connection has been upgraded (e.g. to WebSocket) and must be
26+
/// relayed as raw bidirectional TCP from this point forward.
27+
/// Contains any overflow bytes read from upstream after the 101 headers
28+
/// that must be forwarded to the client before switching to copy mode.
29+
Upgraded { overflow: Vec<u8> },
30+
}
31+
1732
/// Body framing for HTTP requests/responses.
1833
#[derive(Debug, Clone, Copy)]
1934
pub enum BodyLength {
@@ -57,14 +72,15 @@ pub trait L7Provider: Send + Sync {
5772

5873
/// Forward an allowed request to upstream and relay the response back.
5974
///
60-
/// Returns `true` if the upstream connection is reusable (keep-alive),
61-
/// `false` if it was consumed (e.g. read-until-EOF or `Connection: close`).
75+
/// Returns a [`RelayOutcome`] indicating whether the connection is
76+
/// reusable (keep-alive), consumed, or has been upgraded (101 Switching
77+
/// Protocols) and must be relayed as raw bidirectional TCP.
6278
fn relay<C, U>(
6379
&self,
6480
req: &L7Request,
6581
client: &mut C,
6682
upstream: &mut U,
67-
) -> impl Future<Output = Result<bool>> + Send
83+
) -> impl Future<Output = Result<RelayOutcome>> + Send
6884
where
6985
C: AsyncRead + AsyncWrite + Unpin + Send,
7086
U: AsyncRead + AsyncWrite + Unpin + Send;

crates/openshell-sandbox/src/l7/relay.rs

Lines changed: 52 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,12 @@
77
//! Parses each request within the tunnel, evaluates it against OPA policy,
88
//! and either forwards or denies the request.
99
10-
use crate::l7::provider::L7Provider;
10+
use crate::l7::provider::{L7Provider, RelayOutcome};
1111
use crate::l7::{EnforcementMode, L7EndpointConfig, L7Protocol, L7RequestInfo};
1212
use crate::secrets::SecretResolver;
1313
use miette::{IntoDiagnostic, Result, miette};
1414
use std::sync::{Arc, Mutex};
15-
use tokio::io::{AsyncRead, AsyncWrite};
15+
use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt};
1616
use tracing::{debug, info, warn};
1717

1818
/// Context for L7 request policy evaluation.
@@ -136,20 +136,42 @@ where
136136

137137
if allowed || config.enforcement == EnforcementMode::Audit {
138138
// Forward request to upstream and relay response
139-
let reusable = crate::l7::rest::relay_http_request_with_resolver(
139+
let outcome = crate::l7::rest::relay_http_request_with_resolver(
140140
&req,
141141
client,
142142
upstream,
143143
ctx.secret_resolver.as_deref(),
144144
)
145145
.await?;
146-
if !reusable {
147-
debug!(
148-
host = %ctx.host,
149-
port = ctx.port,
150-
"Upstream connection not reusable, closing L7 relay"
151-
);
152-
return Ok(());
146+
match outcome {
147+
RelayOutcome::Reusable => {} // continue loop
148+
RelayOutcome::Consumed => {
149+
debug!(
150+
host = %ctx.host,
151+
port = ctx.port,
152+
"Upstream connection not reusable, closing L7 relay"
153+
);
154+
return Ok(());
155+
}
156+
RelayOutcome::Upgraded { overflow } => {
157+
info!(
158+
host = %ctx.host,
159+
port = ctx.port,
160+
overflow_bytes = overflow.len(),
161+
"101 Switching Protocols — switching to raw bidirectional relay"
162+
);
163+
// Forward any overflow bytes from the upgrade response
164+
if !overflow.is_empty() {
165+
client.write_all(&overflow).await.into_diagnostic()?;
166+
client.flush().await.into_diagnostic()?;
167+
}
168+
// Switch to raw bidirectional TCP copy for the upgraded
169+
// protocol (WebSocket, HTTP/2, etc.)
170+
tokio::io::copy_bidirectional(client, upstream)
171+
.await
172+
.into_diagnostic()?;
173+
return Ok(());
174+
}
153175
}
154176
} else {
155177
// Enforce mode: deny with 403 and close connection
@@ -281,12 +303,29 @@ where
281303
// Forward request with credential rewriting and relay the response.
282304
// relay_http_request_with_resolver handles both directions: it sends
283305
// the request upstream and reads the response back to the client.
284-
let reusable =
306+
let outcome =
285307
crate::l7::rest::relay_http_request_with_resolver(&req, client, upstream, resolver)
286308
.await?;
287309

288-
if !reusable {
289-
break;
310+
match outcome {
311+
RelayOutcome::Reusable => {} // continue loop
312+
RelayOutcome::Consumed => break,
313+
RelayOutcome::Upgraded { overflow } => {
314+
info!(
315+
host = %ctx.host,
316+
port = ctx.port,
317+
overflow_bytes = overflow.len(),
318+
"101 Switching Protocols — switching to raw bidirectional relay"
319+
);
320+
if !overflow.is_empty() {
321+
client.write_all(&overflow).await.into_diagnostic()?;
322+
client.flush().await.into_diagnostic()?;
323+
}
324+
tokio::io::copy_bidirectional(client, upstream)
325+
.await
326+
.into_diagnostic()?;
327+
return Ok(());
328+
}
290329
}
291330
}
292331

0 commit comments

Comments
 (0)