Skip to content

Commit b73e7ea

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 a7ebf3a commit b73e7ea

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
@@ -13,6 +13,21 @@ use miette::Result;
1313
use std::future::Future;
1414
use tokio::io::{AsyncRead, AsyncWrite};
1515

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

5570
/// Forward an allowed request to upstream and relay the response back.
5671
///
57-
/// Returns `true` if the upstream connection is reusable (keep-alive),
58-
/// `false` if it was consumed (e.g. read-until-EOF or `Connection: close`).
72+
/// Returns a [`RelayOutcome`] indicating whether the connection is
73+
/// reusable (keep-alive), consumed, or has been upgraded (101 Switching
74+
/// Protocols) and must be relayed as raw bidirectional TCP.
5975
fn relay<C, U>(
6076
&self,
6177
req: &L7Request,
6278
client: &mut C,
6379
upstream: &mut U,
64-
) -> impl Future<Output = Result<bool>> + Send
80+
) -> impl Future<Output = Result<RelayOutcome>> + Send
6581
where
6682
C: AsyncRead + AsyncWrite + Unpin + Send,
6783
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.
@@ -134,20 +134,42 @@ where
134134

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

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

0 commit comments

Comments
 (0)