Skip to content

ssh2 / SFTP handshakes fail inside the Node runtime #71

@werkamsus

Description

@werkamsus

ssh2 / SFTP handshakes fail inside the Node runtime

was trying to test compatibility for ssh + sftp libraries with secure-exec and ran into the following issues + found (potential?) fixes, depending on how well these reconcile with the security model

summary

ssh2 and ssh2-sftp-client both fail inside secure-exec@0.2.1. i tracked it down to three separate runtime issues:

# issue impact severity
1 WASM code generation is disabled by default ssh2's wasm poly1305 crypto init fails immediately 🔴 blocking
2 bridged net.Socket is missing _readableState.ended ssh2 treats socket as non-writable, never writes handshake packets 🔴 blocking
3 WebAssembly.instantiate() hangs even after enabling WASM sync Module+Instance works, but the async API path doesn't 🟡 secondary

all three need to be addressed for SSH/SFTP to work. after local patches for all three, i got full SSH exec + SFTP transfers working against a live OpenSSH server.

repro

import { NodeRuntime, NodeFileSystem, allowAll, createNodeDriver, createNodeRuntimeDriverFactory } from 'secure-exec'

const runtime = new NodeRuntime({
  systemDriver: createNodeDriver({
    filesystem: new NodeFileSystem(),
    useDefaultNetwork: true,
    permissions: { ...allowAll },
  }),
  runtimeDriverFactory: createNodeRuntimeDriverFactory(),
})

await runtime.exec(`
  const { Client } = await import('ssh2')
  const client = new Client()
  client.on('ready', () => { console.log('ready'); client.end() })
  client.on('error', (err) => { console.error(err.message) })
  client.connect({
    host: '127.0.0.1', port: 22,
    username: 'testuser', password: 'testpass',
    readyTimeout: 10000,
  })
`, { mode: 'run', filePath: '/tmp/repro.mjs' })

expected: ready printed, handshake completes

actual on stock 0.2.1: WebAssembly.instantiate(): Wasm code generation disallowed by embedder

actual after enabling WASM: Timed out while waiting for handshake

environment

  • secure-exec@0.2.1
  • Node v24.14.1
  • macOS arm64
  • SSH server: local OpenSSH container built from packages/secure-exec/tests/e2e-docker/dockerfiles/sshd.Dockerfile

finding 1: WASM is disabled by default

native/v8-runtime/src/execution.rs:

extern "C" fn deny_wasm_code_generation(
    _context: v8::Local<v8::Context>,
    _source: v8::Local<v8::String>,
) -> bool {
    false
}

pub fn disable_wasm(isolate: &mut v8::OwnedIsolate) {
    isolate.set_allow_wasm_code_generation_callback(deny_wasm_code_generation);
}

called from session.rs on every isolate creation/restore.

ssh2 uses a wasm poly1305 module during crypto init. when WASM is disabled, ssh2's require('./protocol/crypto').init never resolves, and the immediate error is:

WebAssembly.instantiate(): Wasm code generation disallowed by embedder

this isn't specific to ssh2 — any npm package that uses wasm internally will hit the same wall.

question for maintainers: is disabling WASM an intentional security hardening choice, or an oversight? if intentional, it would help to document which Node packages are known-incompatible.


finding 2: _readableState.ended is missing on the bridged net.Socket

packages/nodejs/src/bridge/network.ts (and the compiled bridge.js):

// current
_readableState = { endEmitted: false };

ssh2's isWritable() (ssh2/lib/utils.js):

isWritable: (stream) => {
  return (
    stream
    && stream.writable
    && stream._readableState
    && stream._readableState.ended === false
  );
}

since ended is undefined, undefined === false is false, so isWritable() returns false. ssh2 then skips all sock.write() calls — it thinks the socket is closed.

this is why the debug output looks like everything is working but the handshake never progresses:

debug=Remote ident: 'SSH-2.0-OpenSSH_9.6'
debug=Outbound: Sending KEXINIT        ← logged by ssh2 protocol layer
                                       ← but sock.write() is never called
CLIENT error Timed out while waiting for handshake

fix: add ended: false to _readableState and set it to true on end/close/destroy:

// proposed
_readableState = { endEmitted: false, ended: false };

and in _closeLoopbackReadable(), _pumpBridgeReads() null-chunk handler, and destroy():

this._readableState.ended = true;

this is a concrete compat bug regardless of the WASM policy question.


finding 3: WebAssembly.instantiate() hangs inside the sandbox

after locally enabling WASM in the V8 runtime:

API works?
new WebAssembly.Module(bytes)
new WebAssembly.Instance(module, imports)
await WebAssembly.instantiate(bytes, imports) ❌ hangs / CPU timeout

the sync path works fine. the async instantiate() path appears to hang inside the sandbox, which suggests something about how the runtime handles the async compilation microtask / promise resolution.

workaround (what i used to validate the rest):

const nativeInstantiate = WebAssembly.instantiate.bind(WebAssembly)

WebAssembly.instantiate = async function (source, imports) {
  if (source instanceof WebAssembly.Module) {
    return new WebAssembly.Instance(source, imports)
  }
  const bytes = source instanceof Uint8Array ? source
    : ArrayBuffer.isView(source) ? new Uint8Array(source.buffer, source.byteOffset, source.byteLength)
    : source instanceof ArrayBuffer ? new Uint8Array(source)
    : null

  if (bytes) {
    const module = new WebAssembly.Module(bytes)
    return { module, instance: new WebAssembly.Instance(module, imports) }
  }
  return nativeInstantiate(source, imports)
}

this isn't a real fix — it's a shim that sidesteps the broken async path. the runtime should probably handle WebAssembly.instantiate() natively.


validation after local patches

after addressing all three issues locally, i tested against a live OpenSSH container built from the repo's own sshd.Dockerfile fixture.

ssh2

  • handshake ✅
  • password auth ✅
  • exec('echo hi && whoami')hi\ntestuser

ssh2-sftp-client

mkdir / put / get / list / delete / rmdir — all ✅

debug output after fix:

debug=Remote ident: 'SSH-2.0-OpenSSH_9.6'
debug=Outbound: Sending KEXINIT
debug=Inbound: Handshake in progress
debug=Handshake completed
debug=Inbound: Received USERAUTH_SUCCESS
ready

note on upstream e2e suite

i did try to run the repo's docker e2e tests locally but hit a separate build issue:

pnpm --filter @secure-exec/core build     # ✅ succeeded
pnpm --filter @secure-exec/nodejs build   # ❌ failed

the nodejs build fails because @secure-exec/core/internal/shared/global-exposure resolves to ./dist/shared/global-exposure.js, which isn't present in the built @secure-exec/core output. so i couldn't validate against the official test runner — the findings above come from direct repro against the published package + source inspection.


suggested path forward

  1. fix _readableState.ended — this is a clear compat bug, independent of any policy question
  2. clarify WASM policy — if disabled by default is intentional, document the compatibility impact. if not, consider changing the default or making it opt-in via env/config
  3. investigate WebAssembly.instantiate() async hang — likely a separate runtime bug in how async wasm compilation is handled inside the isolate

i'm happy to split these into separate issues if that's easier to track.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions