Skip to content

Refuse channels the server opens back, and type the startup command into the shell - #148

Open
Sadykhzadeh wants to merge 6 commits into
fix/session-lock-scopefrom
security/refuse-server-channels
Open

Sadykhzadeh wants to merge 6 commits into
fix/session-lock-scopefrom
security/refuse-server-channels

Conversation

@Sadykhzadeh

@Sadykhzadeh Sadykhzadeh commented Oct 16, 2026 •

Copy link
Copy Markdown
Member

Closes #147. Stacked on #140 (session.rs). Merge #140 first; this PR's base then becomes main.

What this changes

Server-initiated channels are refused

SshHandler now overrides every server_channel_open_* hook russh 0.63.3 offers and rejects each one with AdministrativelyProhibited:

  • agent forward
  • X11
  • forwarded-tcpip, forwarded-streamlocal
  • direct-tcpip, direct-streamlocal
  • session

Unknown channel types were already refused, since russh's should_accept_unknown_server_channel defaults to false. Clavyn requests no forwarding of any kind, so no legitimate flow is affected. If agent forwarding is added later, it has to opt back in here, deliberately.

The startup command is typed into the shell

A session channel takes one of shell, exec or subsystem. OpenSSH ignores an exec that arrives after the shell, so startup_command never ran. It is now written into the shell followed by \r, the bytes Enter sends, which matches what "startup command" means in a terminal client: the shell starts and the command runs in it.

Tests

core/tests/server_requests.rs runs against an in-process russh server.

  • channels_the_server_opens_back_are_refused: once the shell starts, the server tries to open agent, x11, forwarded-tcpip and session channels, and each open must fail.
  • the_startup_command_is_typed_into_the_shell: the server must receive exactly tmux attach\r on the shell channel.

Both fail on the base branch and pass here.

Test plan

  • cargo test -p clavyn-core: all suites pass (118 + 5 + 32 + 2 + 3 + 9)
  • node scripts/verify/check-comments.mjs: ok
  • CI

View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.

@greptile-apps

greptile-apps Bot commented Oct 16, 2026 •

Copy link
Copy Markdown

TREX TREX could not finish testing

  • Plan what to test
  • Set up the environment and test the changed flows
  • Post test results

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Reject server-initiated SSH channels and run startup commands in the shell

🐞 Bug fix 🧪 Tests 🕐 20-40 Minutes

Grey Divider

AI Description

• Reject server-initiated SSH channels to prevent unused channels accumulating on client
 connections.
• Type startup commands into the interactive shell so they run after the shell starts.
• Add loopback-server tests for channel refusal and exact startup-command bytes.
Diagram

sequenceDiagram
    participant Manager as Session Manager
    participant Server as SSH Server
    participant Handler as SSH Handler
    participant Shell as Remote Shell
    Manager->>Server: Request PTY and shell
    Server->>Shell: Start shell
    Server->>Handler: Open channel back
    Handler-->>Server: Reject channel
    Manager->>Server: Send startup command + CR
    Server->>Shell: Deliver input
Loading
High-Level Assessment

The approach fits an interactive terminal client: refuse unsolicited channels at russh's handler boundary and send the startup command through the existing shell. An exec request would not run after shell startup, while a separate exec channel would not run inside that shell.

Files changed (3) +288 / -4

Bug fix (2) +97 / -4
connection.rsRefuse all known server-initiated channel types +91/-1

Refuse all known server-initiated channel types

• SshHandler overrides seven russh channel-open hooks and rejects each request as AdministrativelyProhibited. A shared helper keeps the rejection behavior consistent.

core/src/connection.rs

session.rsSend startup commands as shell input +6/-3

Send startup commands as shell input

• After requesting the shell, SessionManager writes a configured startup command followed by a carriage return instead of issuing an exec request on the same channel.

core/src/session.rs

Tests (1) +191 / -0
server_requests.rsTest reverse-channel refusal and startup input +191/-0

Test reverse-channel refusal and startup input

• Adds an in-process russh server that checks refusal of agent, X11, forwarded-TCP and session channels. A second test verifies that the shell receives exactly 'tmux attach\r'; the other three overridden channel types are not exercised here.

core/tests/server_requests.rs

@qodo-code-review

qodo-code-review Bot commented Oct 16, 2026 •

Copy link
Copy Markdown

Code Review by Qodo


Comment thread core/src/session.rs Resolved
…nto the shell

russh's client handler accepts every channel a server opens towards the
client: agent and X11 forwarding, forwarded and direct TCP/streamlocal,
even a session. Clavyn requests none of those, and an accepted channel
that nothing reads stays registered for the life of the connection, so a
server could open them without limit. SshHandler now rejects them all.

The host startup command was sent as an exec request after the shell
request on the same channel. A channel takes one of shell, exec or
subsystem, and OpenSSH ignores an exec on a channel that is already
running a shell, so the command never ran. It is now written into the
shell followed by a carriage return, as if typed.
The command was written before the reader task started, so a long
command whose echo filled the receive window could stall the connect
waiting on its own output. It now goes through the session's ordinary
write path after the reader is up, and a command that cannot be sent is
logged instead of failing a session that is otherwise usable.
@Sadykhzadeh
Sadykhzadeh force-pushed the security/refuse-server-channels branch from 0f286f8 to 8bc2f93 Compare October 17, 2026 06:44
Unknown server-initiated channel types were refused only because russh's
should_accept_unknown_server_channel happens to default to false. The
dependency is a caret requirement, so a patch release could change that
default or add a typed hook whose default accepts, reopening the leak the
other refusals close. The handler now answers both unknown-type hooks
itself, and a unit test pins the refusal regardless of russh's default.
The startup write looked the session up by id again after inserting it.
A shell that exits at once frees the id, so a session that took it in
that gap would receive this one's command. The write now goes through the
Arc that was just inserted, which also keeps the id reserved until the
send is over. write() shares the same send path.
The refusal test opened only agent, x11, forwarded-tcpip and session
channels, so dropping the forwarded-streamlocal, direct-tcpip or
direct-streamlocal override would have passed unnoticed. It now opens all
seven types russh can open back.

The in-process loopback server, its config, the password host and the
known-hosts setup were copied across server_requests, session_isolation
and ssh_roundtrip. They now live in tests/common/loopback.rs.
The command was written into the PTY right after the shell request, before
the login shell had run its startup files. Anything there that reads
input could take it, and the terminal echoed it in the middle of the login
banner and again after the prompt. It now waits for the shell's output to
start and then pause for a second, which is how a shell sitting at its
prompt looks from outside, with a five-second cap so a shell that prints
nothing, or never stops, still gets it. The wait runs on its own task, so
the connect returns as before.

The command takes the session's first write turn before the session is
reachable, so input sent while it waits reaches the shell after it, in
order, instead of being spliced in front of it.
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.

2 participants