Skip to content

fix(ssh): expand ~/.ssh/config tokens with the scope each keyword allows (#2687) - #2689

Merged
datlechin merged 2 commits into
mainfrom
fix/ssh-config-token-expansion
Sep 9, 2026
Merged

fix(ssh): expand ~/.ssh/config tokens with the scope each keyword allows (#2687)#2689
datlechin merged 2 commits into
mainfrom
fix/ssh-config-token-expansion

Conversation

@datlechin

Copy link
Copy Markdown
Member

Fixes #2687.

The bug

~/.ssh/config containing

Host *.*
    Hostname %h

made every SSH-tunnelled connection to a dotted host fail with

SSH tunnel creation failed: DNS resolution failed for %h: nodename nor servname provided, or not known

The two characters %h reached getaddrinfo verbatim.

Root cause

SSHConfigResolver.resolveTarget took the raw HostName value as the connect host, and SSHTokenContext.expand was wired into only two places: the identity-file list and Match exec. There was no per-keyword token scope, no ${VAR} handling, and no tilde handling outside identity files.

It was not limited to tunnels. LibSSH2SFTPSession.open goes through the same buildAuthenticatedChain, so remote-file and backup transfers failed the same way.

Approach

Expansion is a property of the keyword, and nothing in the code said so. SSHTokenScope now encodes the four sentences ssh_config(5) devotes to this, and one left-to-right scanner replaces the old chain of replacingOccurrences calls. That chain re-scanned its own output, so a home directory containing the characters %h came back with the hostname spliced into it, and it needed a U+FFFF sentinel to protect %%. Both problems go away with a single pass.

Hostname is expanded inside the block loop rather than after it, because Match host has to compare against the substituted value. That is what OpenSSH's own match_cfg_line does.

Everything asserted here was measured against OpenSSH_10.3p1, not read off the man page.

Also fixed

The audit of the two files this rewrites found nine more divergences, each with its own ssh -G reproduction:

  • A trailing comment became part of the value, so HostName db.example.com # production produced %h (Tokens) in ~/.ssh/config not supported #2687's exact error from a second cause, and Port 7777 # c silently fell back to 22.
  • Host patterns were matched against a substituted HostName, so a wildcard block for a private domain applied to an alias that never mentioned it.
  • Match exec ran with %p and %r unexpanded, so nc -z %h %p failed and silently dropped whatever the block set.
  • Include naming several files globbed the whole line as one pattern and read none of them, with nothing logged.
  • Include inside a Host block leaked its directives to every connection: a production key reached a staging session.
  • The same file included from a second Host block contributed nothing, because the cycle guard never forgot a file it had finished reading.
  • Match !host prod-db matched every host, including the one it named, so a dev key was offered to production.
  • Match final overrode values earlier blocks had set, and a later block replaced the identity-file list instead of appending to it. ssh is first-wins across both passes.
  • Host a,b was read as a list. Only Match host takes commas; on a Host line the comma is an ordinary character.

Plus a Match exec whose command ignores SIGTERM hung the connect path forever: the timeout sent SIGTERM and then blocked in waitUntilExit. Measured, trap '' TERM; sleep 300 never returned.

Review findings folded in

A security pass found no new vulnerability but flagged two things this change introduced, both fixed and both now covered by a test: ProxyJump was expanded before it was split on commas, so a username containing a comma could add a jump host the config never named; and an unevaluable Match exec reported "did not hold", which satisfied a negated criterion by its own failure.

A Codex review found seven more, all confirmed against ssh before acting:

  • ${VAR} was expanded in Match exec, Hostname and ProxyJump. ssh expands it only in the file keywords and hands the command to the shell intact, so test x${MODE:-dev} = xdev looked up a variable literally named MODE:-dev and failed the tunnel.
  • Match !final and !canonical were routed to the pass where their un-negated form is true, so they could never apply.
  • Match exec saw only values accumulated so far, so %p was empty when the port was the implicit 22 and ignored a port set on the connection.
  • %j used the first jump host; ssh uses the last, the hop nearest the target, which changes every multi-hop %C.
  • HostKeyAlias was never parsed, so %k always fell back to the original host.
  • A ${ with no closing brace was copied through instead of reported.
  • The parity script forced DEVELOPER_DIR to Xcode-beta, so it failed before running on a machine with only the standard Xcode.

Deliberately not adopted

ssh lowercases the resolved hostname. HostKeyStore keys known_hosts on a case-sensitive [host]:port, so adopting it would fire "SSH Host Key Changed" on every mixed-case trusted entry. Include's host-dependent tokens are also out: the config is parsed once and shared by every connection, before any host is known, so those are skipped with a log line rather than resolved against the wrong target.

Verification

scripts/check-ssh-config-parity.sh is committed with the fix. It builds the real parser and resolver with swiftc and diffs 16 fixtures against the ssh on the machine, so a future change re-checks the behaviour instead of trusting a transcription.

Run against the code before this change it reports 8 of 12 fixtures disagreeing, including hostname %h. After it reports 16 of 16 agreeing.

  • build: PASS
  • test: PASS, 222 executed, 222 passed, across every suite that owns a type this touches
  • lint: no violations in the changed files (two pre-existing legacy_swiftui_aspect_ratio hits in SupportView.swift and ImportFromAppSourcePicker.swift are untouched by this branch)
  • docs: both check-writing-style.sh and check-docs-against-source.py pass

No UI automation: ssh_config resolution is headless and has no deterministic UI surface. SSHPathUtilitiesTests was removed because every case it held is reproduced and extended in the new SSHConfigTokensTests and SSHPathExpansionTests.

Verified in an isolated worktree, because the main checkout holds another session's in-flight work.

https://claude.ai/code/session_01H3kBkbE5KT2gYwy3xFW7UQ

@mintlify

mintlify Bot commented Sep 9, 2026

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated
TablePro 🟢 Ready View Preview Sep 9, 2026, 5:41 AM

💡 Tip: Enable Automations to automatically generate PRs for you.

Signed-off-by: Ngô Quốc Đạt <datlechin@gmail.com>
@datlechin
datlechin merged commit e9c9d65 into main Sep 9, 2026
5 of 6 checks passed
@datlechin
datlechin deleted the fix/ssh-config-token-expansion branch September 9, 2026 05:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

%h (Tokens) in ~/.ssh/config not supported

1 participant