Skip to content

ensure ruby 4 compatibility and address some security concerns - #162

Open
srosenhamer wants to merge 7 commits into
net-ssh:masterfrom
srosenhamer:security-and-ruby4
Open

ensure ruby 4 compatibility and address some security concerns#162
srosenhamer wants to merge 7 commits into
net-ssh:masterfrom
srosenhamer:security-and-ruby4

Conversation

@srosenhamer

Copy link
Copy Markdown

net-sftp: security hardening and Ruby 4.x compatibility


Security fixes

  • Path traversal / arbitrary local file write (lib/net/sftp/operations/download.rb) - added validate_entry_name!, rejecting any server-supplied filename that isn't a single path component. Previously only "." and ".." were filtered, so a malicious server returning ../../.ssh/authorized_keys during a recursive download got File.join + File.open(..., "wb") to write outside the target.
  • Symlink hardening at the two write sinks - make_directory refuses a pre-existing local symlink; open_sink uses O_NOFOLLOW where the platform defines it.
  • Unbounded parser loops - new Protocol::BoundedRead#read_bounded_count! validates server item counts against bytes actually remaining. Applied to parse_acl, parse_extended, and both parse_name_packet implementations. Measured: parse_acl built 500,000 structs from a 12-byte payload in 0.617s before; 0.025ms and a clean exception after.
  • Packet length cap - MAX_PACKET_LENGTH = 256KiB, matching OpenSSH. Previously an unvalidated uint32.
  • Second FXP_VERSION rejected - do_version now requires state == :init, so a mid-session version packet can't swap the protocol driver or orphan pending requests.
  • Unknown response ids ignored, not raised - one unsolicited packet no longer tears down the caller's event loop.
  • Transfer cleanup - callbacks routed through a wrapper that closes opened local files and aborts before re-raising.

Bug fixes

  • :version now actually pins - negotiation capped against the library constant instead of the offered version, so version: 1 against OpenSSH silently used v3. Found by the real-server run, not the unit suite (the mock server echoes back whatever the client offers).
  • loop_forever was broken - the alias sat above Session#loop, so it aliased private Kernel#loop and raised NoMethodError. Moved below the definition.
  • rescue Object in Net::SFTP.start narrowed, with a nil check on session and logging instead of an empty rescue ::Exception.
  • Unknown-type entries no longer abort a whole recursive download - v1-v3 servers may omit permissions, making directory? nil.

Ruby 4.x compatibility

  • Fixed two confirmed FrozenError sites: V04::Name#longname and the Operations::File buffer. Matched upstream PR Handle frozen strings #157's exact shape so a future merge won't conflict.
  • attr_accessor :name on both Name classes so Dir#glob assigns instead of mutating in place.
  • Added # frozen_string_literal: true to all 56 lib/test/build files.
  • Wrapped the tests' deliberate $/ and $\ assignments, which Ruby 4.0 deprecates.
  • bytes.length to bytesize in File#write (was materialising the byte array twice per write).

Build and packaging

  • Rakefile - guarded require "rdoc/task" behind LoadError (rdoc stopped being a default gem in 4.0 and was aborting every rake task); fixed --days 365*5 to 1825; dropped the undeclared hanna generator.
  • Gemfile - dropped the bundler "~> 2.1" pin, relaxed rake to >= 13.0, replaced the yanked codecov with simplecov, added bundler-audit and rdoc.
  • gemspec - added required_ruby_version = ">= 3.1" (previously unset), bounded minitest and mocha, collapsed two unreachable legacy branches.
  • CI - ubuntu-latest and checkout@v4 (the old ubuntu-18.04 image was deleted in 2022, so nothing had run in ~3.5 years); matrix 3.1 through 4.0; added a frozen-string-literal run and a bundle-audit job.

New API

  • Net::SFTP.start(..., min_version:) - refuses a server that negotiates below your required version.
  • Session#extensions - exposes what the server advertises in FXP_VERSION. Previously parsed and thrown away.

Removed

  • setup.rb (1,331 lines) - 2004-era installer, broken since Ruby 2.2, referenced by nothing, LGPL inside an MIT gem. Also added to the gemspec reject pattern.
  • Manifest - stale hoe-era artifact.

Tests and docs

  • +9 traversal tests, +8 parser-limit tests (test/test_parser_limits.rb), +5 session tests, +1 sink-cleanup test.
  • README requirements/install rewritten; CHANGES.txt records everything including the expired-cert known issue.

Not fixed, and not fixable here

  • Terrapin (CVE-2023-48795) is unmitigated in net-ssh. No strict-KEX implementation exists in net-ssh 7.3.2's lib/, and its defaults put the two vulnerable ETM MACs first with chacha20-poly1305 at the front of the cipher list. Mitigation is caller-side: pass explicit hmac:/encryption: lists excluding those.
  • rake release stays blocked. The signing cert expired 2023-09-22 and regenerating it needs a private key that isn't in this repo.

Commits

9f7bf10  Make the :version option actually pin the protocol version
bbf61d8  Remove dead weight and repair release plumbing
6332af6  Make the gem safe under frozen string literals
33f5811  Harden the session state machine and transfer cleanup
e9ff1d2  Bound server-supplied item counts and packet lengths
c90cdb2  Reject unsafe server-supplied filenames during recursive download
12a6a91  Repair build tooling for modern Ruby and RubyGems

Steve Rosenhamer and others added 7 commits August 6, 2026 10:10
The build has not run since 2022. Every task was broken in some way:

- Gemfile pinned bundler "~> 2.1" and rake "~> 12.0"; the current toolchain
  is bundler 4.x and rake 13.x, so `bundle install` could not resolve at all.
  Drop the bundler pin (declaring bundler in a Gemfile is discouraged) and
  relax rake to >= 13.0.
- rdoc stopped being a default gem in Ruby 4.0, so the Rakefile's unconditional
  `require "rdoc/task"` aborted *every* rake task, including `rake test`. Guard
  it behind a LoadError rescue and declare rdoc as a development dependency.
  Also drop the 'hanna' generator, which was never a declared dependency and
  made `rake rdoc` fail out of the box.
- Replace the yanked, unmaintained codecov gem with plain simplecov.
- CI targeted ubuntu-18.04, a runner image GitHub deleted in December 2022, so
  no job has even been scheduled in ~3.5 years. Move to ubuntu-latest,
  actions/checkout@v4, and bundler-cache, and test 3.1 through 4.0 rather than
  2.5 through 3.1 (all of which are now EOL). Add a frozen-string-literal run
  and a bundle-audit job.
- Declare required_ruby_version (previously unset entirely) and put upper
  bounds on the minitest and mocha development dependencies. Collapse the two
  unreachable legacy RubyGems branches in the gemspec.
- test_start.rb declared a mocha expectation with keywords while Net::SFTP.start
  passes ssh_options positionally. That match breaks under mocha's strict
  keyword matching; brace the hash as the sibling test already does.

Baseline after this commit: 433 runs, 1201 assertions, 0 failures on Ruby 4.0.1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A filename in an FXP_NAME response is chosen entirely by the server, and the
only sanitisation in the library was an exact-string comparison against "."
and "..". Everything else was joined straight onto the local destination and
handed to Dir.mkdir and File.open.

So a malicious or compromised server answering readdir with the single name
"../../.ssh/authorized_keys" during

    sftp.download!(remote, "/home/user/dl", recursive: true)

produced File.join("/home/user/dl", "../../.ssh/authorized_keys"), which the
kernel resolves to /home/.ssh/authorized_keys. That is an arbitrary local file
write, with content the server chooses, at the client process's privileges.
Absolute names were not the vector -- File.join keeps those under the root --
".." was.

validate_entry_name! now requires each entry to be a plain path component.
Comparing against File.basename rejects "..", embedded separators and absolute
paths in one check, and is correct on Windows too, where File.basename also
understands "\". "." and ".." keep being skipped silently, since they are
legitimate entries in any listing.

Two related weaknesses at the same sinks:

- Dir.mkdir was guarded by File.directory?, which follows symlinks, so a
  symlink planted at the destination silently accepted the check and every
  child file was written through it. make_directory now refuses a symlink.
- File.open(path, "wb") followed and truncated an existing symlink target.
  open_sink now passes O_NOFOLLOW where the platform defines it.

Verified: removing the validate_entry_name! call fails the six new
unsafe-name tests; restoring it passes all 442.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Several SFTP structures are encoded as a 32-bit count followed by that many
items. The count is chosen entirely by the server and can be up to
4,294,967,295. Net::SSH::Buffer clamps reads at end-of-buffer and returns nil
rather than raising, so these loops did not terminate early on truncated
input -- they just spun, allocating.

Measured against the previous code: a 12-byte payload declaring 500,000 ACL
entries allocated 500,000 structs in 0.617s with no exception. At the maximum
count that is ~4.3 billion structs, i.e. hundreds of gigabytes and an OOM
kill, from a packet an attacker can send in a single frame. parse_extended is
the same shape but flat in memory, so it hangs the SSH event loop instead.
Both are reachable from any ordinary stat!, dir.entries, or recursive
download.

Add Protocol::BoundedRead#read_bounded_count!, which validates the count
against the bytes actually remaining in the buffer -- a count larger than the
buffer could possibly hold cannot be legitimate -- and apply it to:

  - V04::Attributes.parse_acl
  - V01::Attributes.parse_extended
  - V01::Base#parse_name_packet
  - V04::Base#parse_name_packet

Also cap accepted packet length at 256KiB, matching OpenSSH's sftp-server.
Previously session.rb read a raw uint32 length and would buffer toward 4GiB,
or wait indefinitely for bytes that never arrive, with no way to reclaim it.

The same 500,000-entry ACL input now raises in 0.025ms. The new tests assert
both the exception and a wall-clock bound, so a regression surfaces as a
failure rather than a hung CI job.

450 runs, 1276 assertions, 0 failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four ways a server could disrupt or crash a client:

FXP_VERSION was dispatched at any point in the session, not just during the
handshake. A second one swapped the protocol driver mid-flight, discarded
@pending_requests -- orphaning any caller blocked in Request#wait, forever --
and then raised NoMethodError on the already-cleared @on_ready list.
do_version now refuses to run unless the session is still :init.

dispatch_request raised "no such request" for any unrecognised response id,
so one small unsolicited or duplicated packet tore down the caller's event
loop. Unknown ids are now logged and ignored.

Version negotiation is min(server_version, 6) and the server alone picks the
number, with no floor and no signal to the caller. A server declaring v1
silently put the client on the v1 driver, where rename, readlink, symlink and
link are all NotImplementedError. Add an opt-in :min_version, defaulting to
nil so existing behaviour is unchanged.

Transfer callbacks run inside a Net::SSH channel callback and every one of
them can raise StatusException, with no ensure anywhere: the exception unwound
through the event loop leaving local file handles open, partial files on disk,
and @Active never decremented. Route the handlers through a wrapper that
aborts and cleans up before re-raising, and track the files the downloader
opened itself so abort! can close them. Caller-supplied IO objects are left
alone.

Two smaller things in the same code:

- The extensions hash parsed out of FXP_VERSION was assigned to a local and
  thrown away. Expose it as Session#extensions.
- Protocol versions 1-3 make the permissions bits optional, so Name#directory?
  returns nil when the server omits them. Such an entry was attempted as a
  file and, if it was really a directory, the failed open aborted the entire
  recursive download. Skip the ambiguous entry and keep going.

456 runs, 1291 assertions, 0 failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two confirmed FrozenError sites under --enable-frozen-string-literal:

- V04::Name#longname seeded from the literal "d"/"l"/"-" and then appended to
  it 14 times. This one already emitted a chilled-string deprecation warning
  on plain Ruby 4.0.1.
- Operations::File assigned @buffer = "" at four sites and then did
  @buffer << data, which killed read, gets, readline and eof? -- the whole
  synchronous file API.

A third site, Dir#glob's e.name.replace(...), reached into a Name and mutated
its string in place. It worked only because Net::SSH::Buffer#read_string
happens to return an unfrozen string.

Fixed in the same shape as the upstream PR (net-ssh#157, open since
2024) rather than a nicer-looking rewrite, so that a future merge from
upstream does not conflict: unary plus on the seed literals, and
attr_accessor :name on both Name classes so glob can assign instead of mutate.

Then add # frozen_string_literal: true to all 56 lib, test and build files.
The suite now passes identically with and without
--enable-frozen-string-literal, and emits no chilled-string warnings.

Two other Ruby 4.0 items found while getting the run clean:

- Ruby 4.0 deprecates assigning a non-nil value to $/ and $\. The library only
  reads them, mirroring ::IO, which is fine; it was test_file.rb's setup that
  warned on every test. Those assignments are deliberate, so wrap them rather
  than drop the coverage.
- Operations::File#write called data.bytes.length twice per write, each call
  materialising the whole byte array. bytesize is O(1) and is what
  download.rb already used.

The one remaining warning in the run comes from mocha requiring 'cgi', which
Ruby 4.0 removed; that is a dependency issue, not ours.

456 runs, 1291 assertions, 0 failures on both Ruby 3.4.4 and 4.0.1, in both
frozen and unfrozen modes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
setup.rb was Minero Aoki's 2004 installer, committed once in 2008 and never
touched. Nothing referenced it, it had been non-functional since Ruby 2.2
removed ::Config::CONFIG, and it shipped in the published gem -- putting an
LGPL-2.1 file inside a gem that declares spec.license = "MIT". If anyone did
run it, its `system` calls source their command strings from a config.save in
the current directory. Deleted, and also added to the gemspec reject pattern
so a stale working copy cannot reintroduce it.

Manifest was a hoe-era artifact listing a file that does not exist and
omitting most of the ones that do. The gemspec uses `git ls-files`. Deleted.

Net::SFTP.start used `rescue Object`, whose handler then called
session.shutdown! on a variable that is nil when Net::SSH.start itself raised;
the resulting NoMethodError was caught by an empty `rescue ::Exception` and
swallowed. Narrow to `rescue ::Exception` with an explicit nil check, and log
rather than silently discard a shutdown failure. The broad rescue is
deliberate -- an Interrupt during setup must still tear down the session --
and the original exception is always re-raised.

`alias :loop_forever :loop` sat above the definition of Session#loop, so it
actually aliased Kernel#loop and inherited its private visibility. Calling
sftp.loop_forever raised NoMethodError: private method. Moved below the
definition.

Rakefile: `gem cert --days 365*5` is not shell arithmetic; gem cert coerces
the argument with to_i, giving 365. That is exactly why the certificate got a
one-year life instead of the intended five. Also `raise Exception` -> a
RuntimeError, and drop the duplicated release prerequisite.

Document the expired signing certificate in both the README and CHANGES,
since the high-security install the README recommends cannot work until
someone with the signing key regenerates it.

456 runs, 1291 assertions, 0 failures. bundle-audit: no vulnerabilities found.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
FXP_INIT was sent with `@version || HIGHEST_PROTOCOL_VERSION_SUPPORTED`, but
do_version then computed the negotiated version as
`[server_version, HIGHEST_PROTOCOL_VERSION_SUPPORTED].min` -- capping against
the constant rather than against what was actually offered.

Real servers answer FXP_INIT with their own version regardless of the client's
offer; OpenSSH always replies 3. So Net::SFTP.start(host, user, {}, version: 1)
against OpenSSH negotiated min(3, 6) = 3 and loaded the v3 driver. The
:version option pinned nothing.

The existing unit test did not catch this because its scripted server echoes
back the same version the client offered. Found by running against a real
OpenSSH sftp-server.

Cap against the offered version instead.

457 runs, 1293 assertions, 0 failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

1 participant