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..7ce8a3b877 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. Invalid targets are rejected before a local +listener opens. + ## 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; 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"