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
- fix
_readableState.ended — this is a clear compat bug, independent of any policy question
- 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
- 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.
ssh2/ SFTP handshakes fail inside the Node runtimewas 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
ssh2andssh2-sftp-clientboth fail insidesecure-exec@0.2.1. i tracked it down to three separate runtime issues:ssh2's wasmpoly1305crypto init fails immediatelynet.Socketis missing_readableState.endedssh2treats socket as non-writable, never writes handshake packetsWebAssembly.instantiate()hangs even after enabling WASMModule+Instanceworks, but the async API path doesn'tall 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
expected:
readyprinted, handshake completesactual on stock
0.2.1:WebAssembly.instantiate(): Wasm code generation disallowed by embedderactual after enabling WASM:
Timed out while waiting for handshakeenvironment
secure-exec@0.2.1v24.14.1packages/secure-exec/tests/e2e-docker/dockerfiles/sshd.Dockerfilefinding 1: WASM is disabled by default
native/v8-runtime/src/execution.rs:called from
session.rson every isolate creation/restore.ssh2uses a wasmpoly1305module during crypto init. when WASM is disabled,ssh2'srequire('./protocol/crypto').initnever resolves, and the immediate error is: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.endedis missing on the bridgednet.Socketpackages/nodejs/src/bridge/network.ts(and the compiledbridge.js):ssh2'sisWritable()(ssh2/lib/utils.js):since
endedisundefined,undefined === falseisfalse, soisWritable()returnsfalse.ssh2then skips allsock.write()calls — it thinks the socket is closed.this is why the debug output looks like everything is working but the handshake never progresses:
fix: add
ended: falseto_readableStateand set it totrueon end/close/destroy:and in
_closeLoopbackReadable(),_pumpBridgeReads()null-chunk handler, anddestroy():this is a concrete compat bug regardless of the WASM policy question.
finding 3:
WebAssembly.instantiate()hangs inside the sandboxafter locally enabling WASM in the V8 runtime:
new WebAssembly.Module(bytes)new WebAssembly.Instance(module, imports)await WebAssembly.instantiate(bytes, imports)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):
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.Dockerfilefixture.ssh2exec('echo hi && whoami')→hi\ntestuser✅ssh2-sftp-clientmkdir/put/get/list/delete/rmdir— all ✅debug output after fix:
note on upstream e2e suite
i did try to run the repo's docker e2e tests locally but hit a separate build issue:
the
nodejsbuild fails because@secure-exec/core/internal/shared/global-exposureresolves to./dist/shared/global-exposure.js, which isn't present in the built@secure-exec/coreoutput. 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
_readableState.ended— this is a clear compat bug, independent of any policy questionWebAssembly.instantiate()async hang — likely a separate runtime bug in how async wasm compilation is handled inside the isolatei'm happy to split these into separate issues if that's easier to track.