Skip to content

Commit 0cecb54

Browse files
authored
fix(cli): bracket IPv6 bind literals in SSH forwards (#2552)
ForwardSpec accepts IPv6 bind addresses, but ssh_forward_arg() emitted them unbracketed (e.g. ::1:8080:127.0.0.1:8080), which OpenSSH rejects as a bad local forwarding specification. access_url() likewise produced invalid URLs like http://::1:8080/. Extract a shared bracket_ipv6_host() helper and use it in ssh_forward_arg(), access_url() (via format_gateway_url), and format_gateway_url() so IPv6 literals are bracketed consistently. Fixes #2279 Signed-off-by: Russell Bryant <rbryant@redhat.com>
1 parent eb380d7 commit 0cecb54

1 file changed

Lines changed: 49 additions & 9 deletions

File tree

‎crates/openshell-core/src/forward.rs‎

Lines changed: 49 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
99
use crate::paths::{create_dir_restricted, xdg_config_dir};
1010
use miette::{IntoDiagnostic, Result, WrapErr};
11+
use std::borrow::Cow;
1112
use std::net::TcpListener;
1213
use std::path::PathBuf;
1314
use std::process::Command;
@@ -580,18 +581,28 @@ impl ForwardSpec {
580581
}
581582

582583
/// The SSH `-L` local-forward argument: `bind_addr:port:127.0.0.1:port`.
584+
///
585+
/// IPv6 bind literals are bracketed (`::1` → `[::1]`) because OpenSSH
586+
/// rejects an unbracketed IPv6 address in a forward specification.
583587
pub fn ssh_forward_arg(&self) -> String {
584-
format!("{}:{}:127.0.0.1:{}", self.bind_addr, self.port, self.port)
588+
format!(
589+
"{}:{}:127.0.0.1:{}",
590+
bracket_ipv6_host(&self.bind_addr),
591+
self.port,
592+
self.port
593+
)
585594
}
586595

587596
/// A human-readable URL for the forwarded port.
588597
pub fn access_url(&self) -> String {
598+
// Wildcard binds are not connectable targets, so display a reachable
599+
// loopback host instead.
589600
let host = if self.bind_addr == "0.0.0.0" || self.bind_addr == "::" {
590601
"localhost"
591602
} else {
592603
&self.bind_addr
593604
};
594-
format!("http://{host}:{}/", self.port)
605+
format!("{}/", format_gateway_url("http", host, self.port))
595606
}
596607
}
597608

@@ -747,18 +758,24 @@ pub fn resolve_ssh_gateway(
747758
(gateway_host.to_string(), gateway_port)
748759
}
749760

750-
/// Format a gateway URL, bracketing IPv6 literals when needed.
751-
pub fn format_gateway_url(scheme: &str, host: &str, port: u16) -> String {
752-
let host = if host
761+
/// Bracket a bare IPv6 literal (e.g. `::1` → `[::1]`) so it can be embedded in
762+
/// `host:port` syntax. Non-IPv6 hosts (DNS names, IPv4) and already-bracketed
763+
/// literals are returned unchanged.
764+
fn bracket_ipv6_host(host: &str) -> Cow<'_, str> {
765+
if host
753766
.parse::<std::net::IpAddr>()
754767
.is_ok_and(|ip| ip.is_ipv6())
755768
&& !host.starts_with('[')
756769
{
757-
format!("[{host}]")
770+
Cow::Owned(format!("[{host}]"))
758771
} else {
759-
host.to_string()
760-
};
761-
format!("{scheme}://{host}:{port}")
772+
Cow::Borrowed(host)
773+
}
774+
}
775+
776+
/// Format a gateway URL, bracketing IPv6 literals when needed.
777+
pub fn format_gateway_url(scheme: &str, host: &str, port: u16) -> String {
778+
format!("{scheme}://{}:{port}", bracket_ipv6_host(host))
762779
}
763780

764781
/// Shell-escape a value for use inside a `ProxyCommand` string.
@@ -1413,6 +1430,17 @@ mod tests {
14131430
assert_eq!(spec.ssh_forward_arg(), "127.0.0.1:8080:127.0.0.1:8080");
14141431
}
14151432

1433+
#[test]
1434+
fn forward_spec_ssh_forward_arg_brackets_ipv6_literal() {
1435+
// OpenSSH rejects an unbracketed IPv6 bind address in a `-L`
1436+
// specification; the literal must be wrapped in brackets.
1437+
let spec = ForwardSpec::parse("::1:8080").unwrap();
1438+
assert_eq!(spec.ssh_forward_arg(), "[::1]:8080:127.0.0.1:8080");
1439+
1440+
let spec = ForwardSpec::parse(":::8080").unwrap();
1441+
assert_eq!(spec.ssh_forward_arg(), "[::]:8080:127.0.0.1:8080");
1442+
}
1443+
14161444
#[test]
14171445
fn ssh_forward_command_matches_exact_l_argument() {
14181446
let command = "ssh -o ProxyCommand=openshell ssh-proxy --sandbox-id sbx-1 -N -L 80:127.0.0.1:80 sandbox";
@@ -1663,6 +1691,18 @@ mod tests {
16631691
assert_eq!(spec.access_url(), "http://localhost:8080/");
16641692
}
16651693

1694+
#[test]
1695+
fn forward_spec_access_url_ipv6() {
1696+
// A specific IPv6 loopback literal must be bracketed for a valid URL.
1697+
let spec = ForwardSpec::parse("::1:8080").unwrap();
1698+
assert_eq!(spec.access_url(), "http://[::1]:8080/");
1699+
1700+
// The IPv6 wildcard bind is not a connectable target, so it maps to a
1701+
// reachable host for display.
1702+
let spec = ForwardSpec::parse(":::8080").unwrap();
1703+
assert_eq!(spec.access_url(), "http://localhost:8080/");
1704+
}
1705+
16661706
#[test]
16671707
fn forward_spec_display() {
16681708
let spec = ForwardSpec::parse("8080").unwrap();

0 commit comments

Comments
 (0)