From 84eafe716ff3a4073e140060c3f838b06cdcc4c1 Mon Sep 17 00:00:00 2001 From: Shiju Date: Tue, 3 Nov 2026 17:11:03 +0530 Subject: [PATCH 1/3] fix(forward): reject invalid TCP targets before local bind An invalid TCP relay target can reach the gateway or open a local listener before failing. This rejects it in the CLI and TypeScript SDK first, then preserves the normalized target through the handle and relay request. The gateway continues to validate every target. Signed-off-by: Shiju --- crates/openshell-cli/src/run.rs | 39 ++++++++++++++++++++- crates/openshell-core/src/net.rs | 25 +++++++++++++ crates/openshell-server/src/grpc/sandbox.rs | 38 ++++++++------------ docs/how-it-works/sandboxes/overview.mdx | 12 +++++++ sdk/typescript/src/client.test.ts | 32 +++++++++++++++++ sdk/typescript/src/client.ts | 15 +++++++- 6 files changed, 135 insertions(+), 26 deletions(-) diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index eb224685ed..7a11264b3e 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -2166,6 +2166,13 @@ pub async fn service_forward_tcp( tls: &TlsOptions, workspace: &str, ) -> Result<()> { + let target_host = openshell_core::net::normalize_tcp_forward_host(target_host) + .map_err(|message| miette::miette!(message))?; + if target_port == 0 { + return Err(miette::miette!( + "tcp target port must be between 1 and 65535" + )); + } let (bind_addr, bind_port) = parse_tcp_forward_spec(local, target_port)?; let mut client = grpc_client(server, tls).await?; @@ -2214,7 +2221,7 @@ pub async fn service_forward_tcp( let mut client = client.clone(); let sandbox_name = sandbox_name.clone(); let sandbox_workspace = sandbox_workspace.clone(); - let target_host = target_host.to_string(); + let target_host = target_host.clone(); let service_id = format!("service-forward:{name}:{target_host}:{target_port}"); let fatal_tx = fatal_tx.clone(); let legacy_session_tokens = legacy_session_tokens.clone(); @@ -7135,6 +7142,36 @@ mod tests { }; use openshell_core::proto::TcpForwardFrame; + #[tokio::test] + async fn forward_rejects_invalid_target_before_gateway_or_local_bind() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let local = listener.local_addr().unwrap().to_string(); + for (host, port, message) in [ + ("example.com", 8080, "tcp target host must be loopback"), + ("192.0.2.1", 8080, "tcp target host must be loopback"), + ("::ffff:127.0.0.1", 8080, "tcp target host must be loopback"), + ("", 8080, "tcp target host is required"), + ( + "127.0.0.1", + 0, + "tcp target port must be between 1 and 65535", + ), + ] { + let error = super::service_forward_tcp( + "invalid gateway", + "sandbox", + Some(&local), + host, + port, + &crate::tls::TlsOptions::default(), + "default", + ) + .await + .unwrap_err(); + assert_eq!(error.to_string(), message); + } + } + #[test] fn lifecycle_wait_fails_fast_on_phases_that_block_the_target() { use super::lifecycle_phase_blocks_target; diff --git a/crates/openshell-core/src/net.rs b/crates/openshell-core/src/net.rs index a9bbc23217..bfa694d339 100644 --- a/crates/openshell-core/src/net.rs +++ b/crates/openshell-core/src/net.rs @@ -19,6 +19,31 @@ use ipnet::{IpNet, Ipv4Net, Ipv6Net}; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; use tokio::net::TcpStream; +/// Normalize a TCP forward destination without DNS resolution. +/// +/// Forward targets must be sandbox loopback addresses. `localhost` uses IPv4 +/// loopback consistently across clients and the gateway. +/// +/// # Errors +/// Returns an error for an empty host or a non-loopback destination. +pub fn normalize_tcp_forward_host(host: &str) -> Result { + let host = host.trim(); + if host.is_empty() { + return Err("tcp target host is required"); + } + if host.eq_ignore_ascii_case("localhost") { + return Ok("127.0.0.1".to_string()); + } + let ip: IpAddr = host + .parse() + .map_err(|_| "tcp target host must be loopback")?; + if ip.is_loopback() { + Ok(ip.to_string()) + } else { + Err("tcp target host must be loopback") + } +} + /// Check if a hostname is a known cloud metadata hostname that resolves to an /// always-blocked metadata service. /// diff --git a/crates/openshell-server/src/grpc/sandbox.rs b/crates/openshell-server/src/grpc/sandbox.rs index a797da2589..d8942d97b7 100644 --- a/crates/openshell-server/src/grpc/sandbox.rs +++ b/crates/openshell-server/src/grpc/sandbox.rs @@ -51,7 +51,6 @@ use openshell_core::{GetResourceVersion, ObjectId, ObjectName, ObjectWorkspace}; use prost::Message; use prost_types::{Struct, Value, value::Kind}; use std::collections::{HashMap, HashSet}; -use std::net::IpAddr; use std::pin::Pin; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; @@ -3111,28 +3110,12 @@ fn validate_tcp_forward_target(target: &TcpRelayTarget) -> Result Result { - if host.is_empty() { - return Err(Status::invalid_argument("tcp target host is required")); - } - if host.eq_ignore_ascii_case("localhost") { - return Ok("127.0.0.1".to_string()); - } - - let ip: IpAddr = host - .parse() - .map_err(|_| Status::invalid_argument("tcp target host must be loopback"))?; - if ip.is_loopback() { - Ok(ip.to_string()) - } else { - Err(Status::invalid_argument("tcp target host must be loopback")) - } + openshell_core::net::normalize_tcp_forward_host(&target.host) + .map_err(Status::invalid_argument) + .map(|host| TcpRelayTarget { + host, + port: target.port, + }) } async fn bridge_forward_tcp_stream( @@ -4972,7 +4955,14 @@ mod tests { #[test] fn tcp_forward_init_allows_loopback_targets() { - for host in ["127.0.0.1", "::1", "localhost"] { + for host in [ + "127.0.0.1", + "127.2.3.4", + "::1", + "0:0:0:0:0:0:0:1", + " localhost ", + "LOCALHOST", + ] { let init = TcpForwardInit { sandbox: "sbx".to_string(), workspace: String::new(), diff --git a/docs/how-it-works/sandboxes/overview.mdx b/docs/how-it-works/sandboxes/overview.mdx index 2ac6a4f69d..2b6812c0fd 100644 --- a/docs/how-it-works/sandboxes/overview.mdx +++ b/docs/how-it-works/sandboxes/overview.mdx @@ -876,6 +876,18 @@ openshell sandbox create --from registry.example.com/your-org/claude-agent:lates +### Forward a TCP service through the gateway + +Use the gateway's TCP relay to forward a loopback service: + +```shell +openshell forward service my-sandbox --target-host 127.0.0.1 --target-port 8080 --local 0 +``` + +The target host must be a loopback IP address or `localhost`, and the target +port must be between 1 and 65535. The CLI and TypeScript SDK reject invalid +targets before opening a local listener. The gateway also validates each target. + ## SSH Config Each sandbox has its own SSH host key. The key stays the same when the diff --git a/sdk/typescript/src/client.test.ts b/sdk/typescript/src/client.test.ts index 86fdfa3972..441492dd54 100644 --- a/sdk/typescript/src/client.test.ts +++ b/sdk/typescript/src/client.test.ts @@ -2167,6 +2167,38 @@ describe('ssh sessions', () => { }); describe('forward', () => { + it('rejects invalid targets before gateway access or binding a local port', async () => { + let reads = 0; + const sandbox = client({ + getSandbox: () => { + reads++; + return readySandbox('sb', 'id'); + }, + }); + for (const targetHost of ['example.com', '192.0.2.1', '::ffff:127.0.0.1', '::1%lo0', '[::1]', '']) { + await expect(sandbox.forward('sb', { targetHost, targetPort: 9000 })).rejects.toMatchObject({ + code: 'invalid_config', + message: expect.stringContaining('tcp target host'), + }); + } + for (const targetPort of [0, -1, 65536, 1.5, Number.NaN]) { + await expect(sandbox.forward('sb', { targetPort })).rejects.toMatchObject({ + code: 'invalid_config', + message: '[invalid_config] tcp target port must be between 1 and 65535', + }); + } + expect(reads).toBe(0); + for (const [targetHost, normalized] of [ + [' LOCALHOST ', '127.0.0.1'], + ['127.2.3.4', '127.2.3.4'], + ['0:0:0:0:0:0:0:1', '::1'], + ]) { + const handle = await sandbox.forward('sb', { targetHost, targetPort: 9000 }); + expect(handle.targetHost).toBe(normalized); + await handle.close(); + } + }); + it('binds a local port and relays bytes both ways, minting + revoking a token', async () => { let sshReq: ScopedRequest = {}; let revokedToken: string | undefined; diff --git a/sdk/typescript/src/client.ts b/sdk/typescript/src/client.ts index 205796f1bd..441701e18b 100644 --- a/sdk/typescript/src/client.ts +++ b/sdk/typescript/src/client.ts @@ -44,6 +44,16 @@ import { heldUntilReady, OWNER_REPLICA_HEADER, OWNER_REQUEST_HEADER, routedStrea import { validateSshResponse } from './ssh-validate.js'; import { buildTransport, type ConnectOptions } from './transport.js'; +// Match the gateway's loopback-only forward target contract without DNS. +function normalizeForwardHost(value: string): string { + const host = value.trim(); + if (!host) throw new SdkError('invalid_config', 'tcp target host is required'); + if (host.toLowerCase() === 'localhost') return '127.0.0.1'; + if (net.isIPv4(host) && host.startsWith('127.')) return host; + if (net.isIPv6(host) && !host.includes('%') && new URL(`http://[${host}]`).hostname === '[::1]') return '::1'; + throw new SdkError('invalid_config', 'tcp target host must be loopback'); +} + function durationFromSeconds(seconds: number) { if (!Number.isFinite(seconds) || seconds < 0) { throw new RangeError('timeoutSecs must be a finite, non-negative number'); @@ -1736,8 +1746,11 @@ export class SandboxClient { // the `init` (TCP target + token), relay bytes both ways in ~64 KiB chunks, // and revoke the token on close. Process-lifetime only. async forward(name: string, opts: ForwardOptions): Promise { - const targetHost = opts.targetHost ?? '127.0.0.1'; + const targetHost = normalizeForwardHost(opts.targetHost ?? '127.0.0.1'); const targetPort = opts.targetPort; + if (!Number.isInteger(targetPort) || targetPort < 1 || targetPort > 65535) { + throw new SdkError('invalid_config', 'tcp target port must be between 1 and 65535'); + } const localHost = opts.localHost ?? '127.0.0.1'; const localPort = opts.localPort ?? 0; From 1b5c4c66a26cd1c27ade0a8fee00a178a45de2f8 Mon Sep 17 00:00:00 2001 From: Shiju Date: Wed, 4 Nov 2026 12:41:14 +0530 Subject: [PATCH 2/3] docs: simplify invalid forward target guidance Signed-off-by: Shiju --- docs/how-it-works/sandboxes/overview.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/how-it-works/sandboxes/overview.mdx b/docs/how-it-works/sandboxes/overview.mdx index 2b6812c0fd..7ce8a3b877 100644 --- a/docs/how-it-works/sandboxes/overview.mdx +++ b/docs/how-it-works/sandboxes/overview.mdx @@ -885,8 +885,8 @@ openshell forward service my-sandbox --target-host 127.0.0.1 --target-port 8080 ``` The target host must be a loopback IP address or `localhost`, and the target -port must be between 1 and 65535. The CLI and TypeScript SDK reject invalid -targets before opening a local listener. The gateway also validates each target. +port must be between 1 and 65535. Invalid targets are rejected before a local +listener opens. ## SSH Config From 1bcb153ea0889e33e73eb173ed2d8aeb1144db33 Mon Sep 17 00:00:00 2001 From: Matthew Grossman Date: Tue, 3 Nov 2026 23:14:00 +0000 Subject: [PATCH 3/3] fix(tests): pin the k3s installer to a release (#5223) get.k3s.io serves the latest install.sh from k3s master, so the pinned checksum broke when upstream changed the script. Download install.sh from the release tag matching k3s_version instead and pin its checksum. Signed-off-by: Matthew Grossman Signed-off-by: Shiju --- tests/tmachine/ansible/playbooks/k3s.yaml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/tmachine/ansible/playbooks/k3s.yaml b/tests/tmachine/ansible/playbooks/k3s.yaml index 8666b2ac6f..d27c6c487b 100644 --- a/tests/tmachine/ansible/playbooks/k3s.yaml +++ b/tests/tmachine/ansible/playbooks/k3s.yaml @@ -21,10 +21,12 @@ state: present update_cache: true + # get.k3s.io serves the latest script, so pin the one from the k3s_version + # release tag. Update the checksum whenever k3s_version changes. - name: Download K3s installer ansible.builtin.get_url: - url: https://get.k3s.io - checksum: sha256:e5cc3b3d9dfc1662c2d9be6da5abc9a4cd317d6abc3a5ffc02e3dd3248207fee + url: "https://raw.githubusercontent.com/k3s-io/k3s/v{{ k3s_version | urlencode }}/install.sh" + checksum: sha256:3ce239d57d43b2d836d2b561043433e6decae8b9dc41f5d13908c0fafb0340cd dest: /tmp/install-k3s.sh mode: "0755"