Skip to content

Include directive leaks Host/Match state into parent config, silently skipping subsequent directives #810

@sethholmes

Description

@sethholmes

Summary

When a config file pulled in via Include ends inside a non-matching Host/Match block, that non-matching state leaks back into the parent file. Every subsequent non-conditional directive in the parent — including further Include directives — is then silently skipped. OpenSSH does not behave this way: it processes the rest of the parent file normally.

The practical impact is that asyncssh can silently ignore ProxyJump, ProxyCommand, IdentityFile, etc. that OpenSSH applies, leading to failed or misrouted connections that are very hard to diagnose — the directives simply vanish, with no error raised.

Environment

  • asyncssh 2.23.0
  • Python 3.10
  • Reproduced on macOS, but the cause is platform-independent.

Minimal reproduction

Three files (main uses absolute includes for clarity):

main

Include /tmp/asshbug/inc1
Include /tmp/asshbug/inc2

inc1 — ends with a Host block that does not match the target host:

Host doesnotmatch
    User nobody

inc2 — the rule we actually care about:

Host targethost
    ProxyJump jump.example.net
import asyncssh
opts = asyncssh.SSHClientConnectionOptions(host="targethost", config=["/tmp/asshbug/main"])
print(opts.tunnel)   # ProxyJump resolved by asyncssh
ProxyJump for targethost
asyncssh 2.23.0 None (incorrect)
ssh -G -F main targethost (OpenSSH) jump.example.net (correct)
asyncssh reading inc2 directly (config=["/tmp/asshbug/inc2"]) jump.example.net (correct)

The rule in inc2 is correct in isolation — the non-matching Host block at the end of the previous include (inc1) is what causes Include inc2 to be skipped.

Root cause

In asyncssh/config.py:

  • parse() sets self._matching = True on entry (~line 399), but neither parse() nor _include() saves/restores the caller's _matching state.
  • _include() (~line 151) saves and restores self._path around the recursive self.parse(path) call (~lines 156 and 175), but does not do the same for self._matching.
  • So when an included file's final Host/Match block does not match, parse() returns with self._matching = False, and that value persists back in the parent file.
  • The per-directive gate (~line 444) then drops everything non-conditional for the remainder of the parent file:
if not self._matching and loption not in self._conditionals:  # _conditionals = {'host', 'match'}
    continue

Since Include is not a conditional keyword, the parent's next Include (and any other non-Host/Match directive) is silently skipped.

Suggested fix

In _include(), save and restore self._matching (and likely self._final) around the recursive parse() loop, mirroring the existing self._path save/restore — so that a Host/Match block inside an included file does not leak its active state back into the including file. This matches OpenSSH's observed behavior, where an Include does not suppress processing of subsequent directives in the parent.

Notes

Encountered in the wild with a user ~/.ssh/config that does:

Include teleport_config       # ends with a non-matching `Host *.teleport` block
Include ssh-config-max.txt    # contains Host 10.x.x.* -> ProxyJump ...  (silently skipped)

OpenSSH read all three files and resolved the ProxyJump; asyncssh stopped after the first include and dialed the target directly (connect timeout). Inserting a bare Host * line before the second Include works around it by resetting _matching to True.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions