Skip to content

fix: direct-tcpip forwarding targets are resolved locally, breaking names only resolvable from the server #257

Description

@inureyes

Surfaced by the address family work in #246 and #248. The v2.4.0 release notes already list the best-effort nature of forwarding target filtering as a known issue; this issue is about the underlying cause of that caveat.

Problem / Background

bssh resolves -L and -D forwarding targets on the local machine and sends a literal IP address in the SSH direct-tcpip channel-open request. OpenSSH sends the target hostname exactly as the user wrote it and lets the server resolve it.

The divergence breaks any target whose name resolves only from the server's network position, which is one of the primary reasons to use a bastion in the first place. It is also the underlying reason the -4/-6 filtering added in v2.4.0 is only advisory for the far end: the filter exists precisely because bssh resolves locally, and the "advisory" caveat exists because the server may disagree with whatever the client resolved.

All line references below are against main at 47ca657 (tag v2.4.0).

Technical Analysis

Resolution happens locally, before the channel is opened

src/ssh/tokio_client/channel_manager.rs, direct_tcpip_targets (lines 150 to 167), calls target.to_socket_addrs() and then applies the address family filter to the result:

let resolved = target
    .to_socket_addrs()
    .map_err(super::Error::AddressInvalid)?;
let targets = address_family.filter(resolved);

to_socket_addrs() performs name resolution through the client's resolver. If the name does not resolve on the client, this returns an error before any SSH traffic is sent.

What goes on the wire is an IP literal, never the name

open_direct_tcpip_channel_with_family (lines 203 to 240) iterates the resolved SocketAddr values and calls (lines 226 to 231):

.channel_open_direct_tcpip(
    target.ip().to_string(),
    target.port().into(),
    src.0.clone(),
    src.1,
)

target.ip().to_string() is the decisive detail: the "host to connect" field of the channel request is always a numeric address, never the hostname the user typed.

The doc comment at lines 195 to 202 already acknowledges the consequence:

The target address is resolved locally only to pick which address to name in the channel-open request; the remote sshd performs the actual connect and may resolve the name differently. Filtering here is therefore a best-effort hint, not a guarantee, and it is why port forwarding documents -4 / -6 as advisory for the far end.

So the behavior is known and documented. What was never decided is whether it is the right default.

Affected call sites

Path Where the target string is built Where it is sent
-L local forwarding src/forwarding/local.rs line 380, let target = format!("{remote_host}:{remote_port}"); inside handle_connection (starts at line 367) lines 381 to 382
-D SOCKS5, domain destination (ATYP 0x03) src/forwarding/dynamic/socks.rs line 198, format!("{domain}:{port}") line 219
Jump chain hop 2 and later src/jump/chain/tunnel.rs line 100 same call
Destination reached through a jump chain src/jump/chain/tunnel.rs line 241 same call

For -L, remote_host is the literal string parsed out of the -L specification (src/forwarding/spec.rs lines 55 and 71), so the user-supplied name survives all the way to direct_tcpip_targets and is then discarded in favor of whatever the local resolver returned. For SOCKS5, domain is the hostname the SOCKS client asked for, which is the whole point of ATYP 0x03: the client is explicitly delegating resolution.

The jump chain rows are the paths #248 made family-aware. They inherit the same resolution model.

What OpenSSH does

Verified against openssh-portable channels.c on master (function names are stable; line numbers drift):

  1. channel_setup_fwd_listener_tcpip sets host = fwd->connect_host (the literal string from the -L specification) and stores it verbatim: c->path = xstrdup(host);. The only getaddrinfo call in that function resolves the listen address, not the connect target.

  2. port_open_helper writes that stored string straight into the channel request:

    if (strcmp(rtype, "direct-tcpip") == 0) {
            /* target host, port */
            if ((r = sshpkt_put_cstring(ssh, c->path)) != 0 ||
                (r = sshpkt_put_u32(ssh, c->host_port)) != 0)
  3. channel_decode_socks5 sets c->path = xstrdup(dest_addr) for the domain form, and only runs inet_ntop to produce a literal for the IPv4 and IPv6 address forms. A SOCKS5 domain request is forwarded as a domain.

  4. channel_connect_stdio_fwd, which backs ssh -W host:port (the mechanism ProxyJump uses), also does c->path = xstrdup(host_to_connect);.

  5. ssh->chanctxt->IPv4or6, which carries the AddressFamily setting, is consulted only in getaddrinfo hints for sockets the local process opens. It never touches the string sent as a forwarded target.

Two supporting documentation points:

  • RFC 4254 section 7.2 explicitly allows either form: "The 'host to connect' may be either a domain name or a numeric IP address." Both bssh and OpenSSH are protocol-conformant. This is a behavioral choice, not a spec violation.
  • sshd_config(5) PermitOpen states that "no pattern matching or address lookups are performed on supplied names", confirming that sshd matches the literal string the client sent against the configured destinations.

Impact

1. Split-horizon DNS (the common case)

A name like db.internal that resolves only inside the server's network fails on the client with a resolution error, even though the entire purpose of -L is that the server can reach it. The equivalent OpenSSH command works. This is not an edge case: internal-only names behind a bastion are a mainstream use of port forwarding.

The failure is also badly signposted. It surfaces as a local resolution error, which points the user at their own DNS configuration rather than at a client design choice, so the natural debugging path leads nowhere.

2. Resolvable locally, but to a different host

A name that resolves on the client to an address unreachable from the server, or to a genuinely different machine (a name that maps to one private address on the local network and another one remotely), tunnels to the wrong host or fails to connect. Nothing reports that the client's and the server's views of the name differ, so a wrong-host outcome can go unnoticed.

3. Server-side logging and access control

sshd sees a request for a numeric address rather than the requested hostname. Hostname-based logging records the literal, and any hostname-based PermitOpen rule on the server behaves differently than an administrator would expect after testing with an OpenSSH client. A PermitOpen db.internal:5432 rule that works for OpenSSH does not match a bssh request, because bssh sends the address and PermitOpen does no address lookups.

4. Round-robin and multi-address targets

Local resolution collapses the choice to whatever the client's resolver returned first, rather than letting the server select among the addresses it sees. Load-balanced targets lose their balancing, and a target whose address set differs by vantage point loses the server's (correct) view of it.

Proposed Solution

There is a real tension to state before the options. Local resolution is what makes -4/-6 filtering possible at all for forwarded targets: once the hostname is sent as written, the server picks the family and the client has no way to influence it. Any fix has to make this tradeoff explicitly rather than silently swapping one behavior for the other.

Option A (recommended): send the name by default, resolve locally only under a forced family

Send the hostname exactly as written (OpenSSH-compatible) whenever the address family is Any. Resolve locally and filter only when a family is explicitly forced with -4, -6, or AddressFamily inet|inet6.

This makes the common case correct while keeping the family feature working in the only case where it is meaningful: the user asked for a specific family, and accepting a locally-chosen address of that family is a better approximation of the request than sending a name and hoping. The cost is that the family filter no longer applies when no family is forced, which is exactly the case where it had nothing to filter for.

Option A preserves every acceptance criterion from #246 and #248, since both are about what happens under a forced family, and fixes the split-horizon case, which only arises when no family is forced.

Option B: always send the name, drop local family filtering for forwarded targets

Full OpenSSH parity, at the cost of narrowing the documented guarantee: -4/-6 would govern only bssh's own connections and its listener bind addresses, never forwarded targets. Simpler and more predictable, but it walks back part of what #246 and #248 delivered.

Option C: keep current behavior, document the divergence prominently

Status quo made explicit. Not recommended: it leaves a mainstream bastion workflow broken relative to OpenSSH, with a misleading error message.

Option A is the recommendation.

Acceptance Criteria

  • A -L forward whose target hostname does not resolve on the client but does resolve on the server succeeds, with the hostname sent as written in the direct-tcpip request.
  • SOCKS5 ATYP 0x03 domain destinations follow the same rule as -L.
  • Jump chain hops past the first, and the destination reached through a chain, follow the same rule.
  • The -4/-6 and AddressFamily guarantees established by fix: Wire up -4/-6 address family flags (currently parsed but ignored) #246 and feat: apply address family preference to jump hops beyond the first #248 continue to hold in whatever form the chosen option defines, with no silent narrowing.
  • docs/man/bssh.1 states the actual guarantee precisely, replacing the current "best-effort hint" paragraph under "What the constraint only hints at" (around line 1190) with the resolution model that is actually implemented.
  • ARCHITECTURE.md describes the resolution model, updating the "Scope" table in the "Address Family Preference" section (around lines 780 to 790), whose -L / SOCKS5 -D target and jump chain rows currently describe the advisory behavior.
  • The doc comment on open_direct_tcpip_channel_with_family (src/ssh/tokio_client/channel_manager.rs lines 195 to 202) is updated to match the implemented behavior.
  • A test covers a target name that is unresolvable locally and asserts that the name reaches the channel-open request rather than producing a local resolution error.
  • A test covers the forced-family path and asserts what is sent under -4 and -6.
  • AddressFamily::Any behavior for the primary connect path, the first jump hop, and forwarding listeners is unchanged. Only the forwarded-target behavior moves.
  • The change is integrated into the real forwarding and jump chain code paths, not added as a helper that no caller uses.

Technical Considerations

The change is concentrated in open_direct_tcpip_channel_with_family, which needs to keep the target's hostname (available through the ToSocketAddrsWithHostname bound it already carries) instead of discarding it after resolution. Under Option A the function branches on address_family.is_forced(): forced keeps today's resolve-filter-iterate loop, unforced sends target.hostname() and the port in a single channel-open attempt. Error::NoAddressForFamily stays reachable on the forced path only.

The retry loop over multiple candidate addresses disappears on the unforced path, since there is one request and the server does the iterating. That is the correct division of labor, and it matches OpenSSH, but it does mean a per-address failure is no longer distinguishable from a resolution failure on the client side. The error text should account for that.

src/jump/chain/tunnel.rs has a second, independent local resolution in resolve_handler_address (line 42 onward), which builds the SocketAddr handed to ClientHandler for host key verification context and display. That one is not on the wire and is already documented as best-effort with a fallback, so it is out of scope here, but whoever implements this should confirm the two do not drift into inconsistency: after the change, the address recorded for known_hosts diagnostics may be one the server never connected to.

Two related address family forwarding gaps are open in the same area and touch adjacent code: #255 (SOCKS4 ignores -4/-6 entirely) and #256 (SOCKS5 rejects IPv6 destination literals). All three change how a forwarded destination is turned into a channel-open request, so whoever lands one second should rebase rather than develop in parallel.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions