diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml index ca781b3..20dc340 100644 --- a/.github/workflows/nodejs.yml +++ b/.github/workflows/nodejs.yml @@ -16,7 +16,7 @@ jobs: strategy: matrix: - node-version: [20.x, 22.x, 24.x, 25.x] + node-version: [20.x, 22.x, 24.x, 25.x, 26.x] steps: - uses: actions/checkout@v6 diff --git a/CHANGELOG.md b/CHANGELOG.md index ea31d85..637830f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Unreleased + +- Fixed: Implicit FTPS transfers against servers requiring TLS session reuse failed with "425 ... TLS session of data connection not resumed" on Node.js with the CVE-2026-48934 fix (22.23.0+, 24.17.0+, 26.3.1+). + ## 6.0.1 - Fixed: Use and renew TLS 1.3 session tickets for data connections. diff --git a/src/Client.ts b/src/Client.ts index 842d12b..cb61df0 100644 --- a/src/Client.ts +++ b/src/Client.ts @@ -143,6 +143,15 @@ export class Client { */ connectImplicitTLS(host = "localhost", port = 21, tlsOptions: TLSConnectionOptions = {}): Promise { this.ftp.reset() + // Remember the host identity so future data connections can resume this TLS + // session. Since Node.js v24.17.0 (CVE-2026-48934) a reusable TLS session is + // bound to the host it was authenticated for, and resumption is refused when + // the resuming connection's identity (servername ?? host ?? socket._host) + // differs. Data connections connect to the PASV/EPSV target, so without an + // explicit host they'd resume under a different (or missing) identity and the + // server would report the session as not resumed. This mirrors what `access` + // already does for explicit TLS, see https://github.com/patrickjuchli/basic-ftp/issues/166. + tlsOptions.host = tlsOptions.host ?? host this.ftp.socket = connectTLS( port, host, diff --git a/test/implicitTlsReuseSpec.js b/test/implicitTlsReuseSpec.js new file mode 100644 index 0000000..528eb5f --- /dev/null +++ b/test/implicitTlsReuseSpec.js @@ -0,0 +1,203 @@ +const { describe, it, beforeEach, afterEach } = require("node:test"); +const assert = require("assert"); +const crypto = require("crypto"); +const tls = require("tls"); +const { Readable } = require("stream"); +const { Client } = require("../dist"); + +// Self-signed cert for 127.0.0.1 / localhost, used only by these tests. +const KEY = `-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDHft4AIbaI7wwQ +Cyoe71a2+x9gRawzwRefPC8SepL65/sU9Ch+j5E22YRYixLCLAFvWCxyM8gjYtt9 +gtbGdDk2ce/TbRXvctC+QUpMxPDRlV+yaq5PPxYSmTPf+pRbttpwXSB//miNHs05 +B2xY9umWcdiqWz0epyveVUWihIoqbWQ7g35JXalYTZIoVf1dd7L9nfoXYE8PnrAb +bW6p7OvUfJOzwo+SQW9MA6jJPtK1plnNWMOkzs9LiCL/x3myr68yZPVub9JRYbny +0Gldnxa+exxqc53IJ8qDd/jXzgZhZKnMVPM2R0GWDGA/wTQzs4tE6FqTB+IXU93f +d5Aj+LHXAgMBAAECggEAUN8x0NzZ2f4KJlDYVO0SeqAnsofcDKj40gD8ViHhhpxX +MUHfTpsVs6YPHDPYuWVMeZ5FelBakFnJf8J0HcRM0zDyF4QP0d96Fr5yB9gIhfXn +cvUDT7XT1VtM5731uY5RVB60h56TNK4pZLaJKjGCNQ5W1oyhJNWBF6L61SyktBin +nxD6nOax6QyvPpVom1khzPVx6oNp3sOHgBLK/0+qwpfWk3lcrM7FsDI2pYgmCcHO +VzI0zmbBnxbjrfJrNnXfHMX4WPhPj0vwQv/CwCWS6y9MZIowe6TTUGxEiex74dYV +DMoVo8phvk1IstffzQR7u46XM6Mb8RbTQGqFNRo3WQKBgQD1kHh7MAYa1Sz3+MNA +hBD6O30XKQPhIK+dQKd3xA16B6KTZOUtSO5SuULC6h3SsBzzI2c3nzxD9fQWeob/ +huLl2r2cpmJ7VIjQAp+5A7KYsWdPbPtB/wCYRfNwrQs74/TnH3pjGMvaFZ8y7xOc +6RPfExNFsTMagrLe4nb9jxXpbwKBgQDP+TU60vzio+wH+o9oG/aaQBmkOe/At8eC +3Ya8/Yqq8+AwyuXXvt9ozXmoK8Y4YLF2CHETwr4VZKdKAOcadGe3LW2+HMnA92N9 +sdicMamDWSokA0yNxy82swVlp4o3Tyaz4XClhH5kHiLJ0u4FBAnba2ON9EamesX8 +FyRB+Mp6GQKBgA9w8zKD1Y5wYzCAkv6Gj268uOPw706DuKdBvoYYbOSEgGOWT5bm +ZB4NijYpdJCgBGIBkub7e1WmrJ+ROtXsjG35sDyedcjdivDRiWuf4OYbGazz5GTE +/SuWEnW+W9t7PRYfc5mxoHfpLiaMxAX03gwD7g1O1DDRkR/Uy7ir+6u/AoGAORUp +qw+mIX7klfwLyIOEWCon34+XYsoOlLjA9raQjQOLEVlfZKwbHeTn82Swb2D61G7U +upvFGJIb6I/+3p/8p6ZsbLXyGmjMgf+CeLyYzlh23JObO37kUpsobBJkOXIcKVtD +U+rd1hT/b23Zrr7BBdyf4qKdkaw5E0w2w3TJ/mECgYAPNzz7Y42rn1N024MxRBd4 +AjaWRn4G1IslC3Xkcb2t5KALYQCEkb1q51wsSyHbQqXAvo+y7OyNdXByax51cDUm +xnRep4AnNHlkFYYLQlrFaY4VeF6zOwqjp7JeyqkACb6lRg596/AQepc9av+uuIuN +hWKE6tBe59J89Tn5oT9j2g== +-----END PRIVATE KEY-----`; +const CERT = `-----BEGIN CERTIFICATE----- +MIIDJzCCAg+gAwIBAgIUbcOuRZvszwe8X7aeLcUz4paZwpswDQYJKoZIhvcNAQEL +BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MCAXDTI2MDcyNTA3NTMwMVoYDzIxMjYw +NzAxMDc1MzAxWjAUMRIwEAYDVQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEB +AQUAA4IBDwAwggEKAoIBAQDHft4AIbaI7wwQCyoe71a2+x9gRawzwRefPC8SepL6 +5/sU9Ch+j5E22YRYixLCLAFvWCxyM8gjYtt9gtbGdDk2ce/TbRXvctC+QUpMxPDR +lV+yaq5PPxYSmTPf+pRbttpwXSB//miNHs05B2xY9umWcdiqWz0epyveVUWihIoq +bWQ7g35JXalYTZIoVf1dd7L9nfoXYE8PnrAbbW6p7OvUfJOzwo+SQW9MA6jJPtK1 +plnNWMOkzs9LiCL/x3myr68yZPVub9JRYbny0Gldnxa+exxqc53IJ8qDd/jXzgZh +ZKnMVPM2R0GWDGA/wTQzs4tE6FqTB+IXU93fd5Aj+LHXAgMBAAGjbzBtMB0GA1Ud +DgQWBBQrsDkPyngvK2f8Po69lZvZR2tc+TAfBgNVHSMEGDAWgBQrsDkPyngvK2f8 +Po69lZvZR2tc+TAPBgNVHRMBAf8EBTADAQH/MBoGA1UdEQQTMBGHBH8AAAGCCWxv +Y2FsaG9zdDANBgkqhkiG9w0BAQsFAAOCAQEAKyl5Xu7qjsERZBNfeQxpfYoU7KPR +fpTlO9EGUR5nWVCZ85pypec5Tzia8ht3PCt4vnd5/34U4YgTlDx+Go9SjXj9qpTE +ePBxEGzilk0eh9m9dUS0wCrY5AfB49975grrauu7SDt5jt+PU54RD5w7kgUcaKBV +9c7+sEK4It+McB7aSENBb9T7EJt7K9iSmFN1UZ+G3OKhSjkmUsZIYFI/L2vlJEO6 +iLWmv6CVrkpClaaXbmDTcRh546NN9yIi4RuCkwwOMDMzLIx0a0+Unxivx5w/Z70u +9LL82ZNYQY//ypFbx/UBlYbovgR/4+0VuqdLyDe3SrBrsz7AtNvDLUtQKA== +-----END CERTIFICATE-----`; + +// Minimal implicit-FTPS server that REQUIRES the data connection to resume the +// control connection's TLS session — the behaviour of FileZilla Server and of +// ProFTPD without NoSessionReuseRequired. The control and data listeners share +// TLS ticket keys so a correctly-presented session resumes across them; if the +// data connection did NOT resume (server-side isSessionReused() === false) the +// STOR is rejected with 425, exactly as a real server reports it. +// +// This lets a plain `node --test` run reproduce the CVE-2026-48934 regression: +// on an affected runtime (Node 22.23.0+, 24.17.0+, 26.3.1+) an unpinned data +// connection falls back to a "localhost" identity that doesn't match the +// control's "127.0.0.1", so Node refuses to resume, the server answers 425 and +// the upload rejects. connectImplicitTLS() pinning the host fixes it. +class ImplicitReuseServer { + constructor() { + const tlsOptions = { key: KEY, cert: CERT, ticketKeys: crypto.randomBytes(48) }; + this.lastDataReused = null; + this._pending = null; + this.controlServer = tls.createServer(tlsOptions, sock => this._handleControl(sock)); + this.dataServer = tls.createServer(tlsOptions, sock => this._handleData(sock)); + } + + listen() { + return Promise.all([ + new Promise(r => this.controlServer.listen(0, "127.0.0.1", r)), + new Promise(r => this.dataServer.listen(0, "127.0.0.1", r)), + ]); + } + + get port() { return this.controlServer.address().port; } + get dataPort() { return this.dataServer.address().port; } + + close() { + this.controlServer.close(); + this.dataServer.close(); + } + + _handleData(sock) { + const state = this._pending; + const reused = sock.isSessionReused(); + this.lastDataReused = reused; + if (state) { + state.dataSocket = sock; + state.dataReused = reused; + } + sock.on("data", () => {}); + sock.on("end", () => { if (state) { state.dataEnded = true; if (state.onEnd) state.onEnd(); } }); + sock.on("error", () => {}); + } + + _handleControl(control) { + const state = { dataSocket: null, dataReused: null, dataEnded: false, onEnd: null }; + this._pending = state; + control.on("error", () => {}); + control.write("220 Welcome\r\n"); + let buffer = ""; + control.on("data", chunk => { + buffer += chunk.toString("latin1"); + let idx; + while ((idx = buffer.indexOf("\r\n")) >= 0) { + const line = buffer.slice(0, idx); + buffer = buffer.slice(idx + 2); + this._onCommand(control, line, state); + } + }); + } + + _onCommand(control, line, state) { + const cmd = line.split(" ")[0].toUpperCase(); + switch (cmd) { + case "USER": control.write("331 Need password\r\n"); break; + case "PASS": control.write("230 Logged in\r\n"); break; + case "FEAT": control.write("211 End\r\n"); break; + case "TYPE": case "STRU": case "PBSZ": case "PROT": case "OPTS": + control.write("200 OK\r\n"); break; + case "EPSV": + control.write(`229 Entering Extended Passive Mode (|||${this.dataPort}|)\r\n`); break; + case "STOR": this._handleStor(control, state); break; + case "QUIT": control.write("221 Bye\r\n"); break; + default: control.write("500 Unknown command\r\n"); + } + } + + _handleStor(control, state) { + const proceed = () => { + if (!state.dataReused) { + // The core of the test: a non-resumed data connection is rejected. + control.write("425 Unable to build data connection: TLS session of data connection not resumed.\r\n"); + if (state.dataSocket) state.dataSocket.destroy(); + return; + } + control.write("150 Opening data connection\r\n"); + const finish = () => control.write("226 Transfer complete\r\n"); + if (state.dataEnded) finish(); + else state.onEnd = finish; + }; + // The data connection is opened during prepareTransfer, before STOR, but + // guard against ordering races on slower machines. + if (state.dataSocket) proceed(); + else { + const timer = setInterval(() => { + if (state.dataSocket) { clearInterval(timer); proceed(); } + }, 5); + } + } +} + +describe("Implicit TLS data-connection session reuse", () => { + + let server, client; + + beforeEach(async () => { + server = new ImplicitReuseServer(); + await server.listen(); + client = new Client(4000); + }); + + afterEach(() => { + client.close(); + server.close(); + }); + + // Regression guard for CVE-2026-48934. On affected Node runtimes this upload + // rejects with 425 unless connectImplicitTLS() pins the control host so the + // data connection resumes the session. On unaffected runtimes (e.g. the EOL + // 25.x line) resumption happens anyway and this simply passes. + it("resumes the control session on the data connection (upload succeeds)", async () => { + await client.access({ + host: "127.0.0.1", + port: server.port, + user: "test", + password: "test", + secure: "implicit", + secureOptions: { ca: CERT, rejectUnauthorized: true } + }); + + await assert.doesNotReject( + () => client.uploadFrom(Readable.from(["hello reuse"]), "reuse-test.txt"), + "upload must not be rejected — the data connection has to resume the TLS session" + ); + + assert.strictEqual( + server.lastDataReused, + true, + "server must observe the data connection as a resumed TLS session" + ); + }); +}); diff --git a/test/implicitTlsSpec.js b/test/implicitTlsSpec.js new file mode 100644 index 0000000..d25c1d0 --- /dev/null +++ b/test/implicitTlsSpec.js @@ -0,0 +1,102 @@ +const { describe, it, beforeEach, afterEach } = require("node:test"); +const assert = require("assert"); +const tls = require("tls"); +const { Client } = require("../dist"); + +// Self-signed cert for 127.0.0.1 / localhost, used only by these tests. +const KEY = `-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDHft4AIbaI7wwQ +Cyoe71a2+x9gRawzwRefPC8SepL65/sU9Ch+j5E22YRYixLCLAFvWCxyM8gjYtt9 +gtbGdDk2ce/TbRXvctC+QUpMxPDRlV+yaq5PPxYSmTPf+pRbttpwXSB//miNHs05 +B2xY9umWcdiqWz0epyveVUWihIoqbWQ7g35JXalYTZIoVf1dd7L9nfoXYE8PnrAb +bW6p7OvUfJOzwo+SQW9MA6jJPtK1plnNWMOkzs9LiCL/x3myr68yZPVub9JRYbny +0Gldnxa+exxqc53IJ8qDd/jXzgZhZKnMVPM2R0GWDGA/wTQzs4tE6FqTB+IXU93f +d5Aj+LHXAgMBAAECggEAUN8x0NzZ2f4KJlDYVO0SeqAnsofcDKj40gD8ViHhhpxX +MUHfTpsVs6YPHDPYuWVMeZ5FelBakFnJf8J0HcRM0zDyF4QP0d96Fr5yB9gIhfXn +cvUDT7XT1VtM5731uY5RVB60h56TNK4pZLaJKjGCNQ5W1oyhJNWBF6L61SyktBin +nxD6nOax6QyvPpVom1khzPVx6oNp3sOHgBLK/0+qwpfWk3lcrM7FsDI2pYgmCcHO +VzI0zmbBnxbjrfJrNnXfHMX4WPhPj0vwQv/CwCWS6y9MZIowe6TTUGxEiex74dYV +DMoVo8phvk1IstffzQR7u46XM6Mb8RbTQGqFNRo3WQKBgQD1kHh7MAYa1Sz3+MNA +hBD6O30XKQPhIK+dQKd3xA16B6KTZOUtSO5SuULC6h3SsBzzI2c3nzxD9fQWeob/ +huLl2r2cpmJ7VIjQAp+5A7KYsWdPbPtB/wCYRfNwrQs74/TnH3pjGMvaFZ8y7xOc +6RPfExNFsTMagrLe4nb9jxXpbwKBgQDP+TU60vzio+wH+o9oG/aaQBmkOe/At8eC +3Ya8/Yqq8+AwyuXXvt9ozXmoK8Y4YLF2CHETwr4VZKdKAOcadGe3LW2+HMnA92N9 +sdicMamDWSokA0yNxy82swVlp4o3Tyaz4XClhH5kHiLJ0u4FBAnba2ON9EamesX8 +FyRB+Mp6GQKBgA9w8zKD1Y5wYzCAkv6Gj268uOPw706DuKdBvoYYbOSEgGOWT5bm +ZB4NijYpdJCgBGIBkub7e1WmrJ+ROtXsjG35sDyedcjdivDRiWuf4OYbGazz5GTE +/SuWEnW+W9t7PRYfc5mxoHfpLiaMxAX03gwD7g1O1DDRkR/Uy7ir+6u/AoGAORUp +qw+mIX7klfwLyIOEWCon34+XYsoOlLjA9raQjQOLEVlfZKwbHeTn82Swb2D61G7U +upvFGJIb6I/+3p/8p6ZsbLXyGmjMgf+CeLyYzlh23JObO37kUpsobBJkOXIcKVtD +U+rd1hT/b23Zrr7BBdyf4qKdkaw5E0w2w3TJ/mECgYAPNzz7Y42rn1N024MxRBd4 +AjaWRn4G1IslC3Xkcb2t5KALYQCEkb1q51wsSyHbQqXAvo+y7OyNdXByax51cDUm +xnRep4AnNHlkFYYLQlrFaY4VeF6zOwqjp7JeyqkACb6lRg596/AQepc9av+uuIuN +hWKE6tBe59J89Tn5oT9j2g== +-----END PRIVATE KEY-----`; +const CERT = `-----BEGIN CERTIFICATE----- +MIIDJzCCAg+gAwIBAgIUbcOuRZvszwe8X7aeLcUz4paZwpswDQYJKoZIhvcNAQEL +BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MCAXDTI2MDcyNTA3NTMwMVoYDzIxMjYw +NzAxMDc1MzAxWjAUMRIwEAYDVQQDDAlsb2NhbGhvc3QwggEiMA0GCSqGSIb3DQEB +AQUAA4IBDwAwggEKAoIBAQDHft4AIbaI7wwQCyoe71a2+x9gRawzwRefPC8SepL6 +5/sU9Ch+j5E22YRYixLCLAFvWCxyM8gjYtt9gtbGdDk2ce/TbRXvctC+QUpMxPDR +lV+yaq5PPxYSmTPf+pRbttpwXSB//miNHs05B2xY9umWcdiqWz0epyveVUWihIoq +bWQ7g35JXalYTZIoVf1dd7L9nfoXYE8PnrAbbW6p7OvUfJOzwo+SQW9MA6jJPtK1 +plnNWMOkzs9LiCL/x3myr68yZPVub9JRYbny0Gldnxa+exxqc53IJ8qDd/jXzgZh +ZKnMVPM2R0GWDGA/wTQzs4tE6FqTB+IXU93fd5Aj+LHXAgMBAAGjbzBtMB0GA1Ud +DgQWBBQrsDkPyngvK2f8Po69lZvZR2tc+TAfBgNVHSMEGDAWgBQrsDkPyngvK2f8 +Po69lZvZR2tc+TAPBgNVHRMBAf8EBTADAQH/MBoGA1UdEQQTMBGHBH8AAAGCCWxv +Y2FsaG9zdDANBgkqhkiG9w0BAQsFAAOCAQEAKyl5Xu7qjsERZBNfeQxpfYoU7KPR +fpTlO9EGUR5nWVCZ85pypec5Tzia8ht3PCt4vnd5/34U4YgTlDx+Go9SjXj9qpTE +ePBxEGzilk0eh9m9dUS0wCrY5AfB49975grrauu7SDt5jt+PU54RD5w7kgUcaKBV +9c7+sEK4It+McB7aSENBb9T7EJt7K9iSmFN1UZ+G3OKhSjkmUsZIYFI/L2vlJEO6 +iLWmv6CVrkpClaaXbmDTcRh546NN9yIi4RuCkwwOMDMzLIx0a0+Unxivx5w/Z70u +9LL82ZNYQY//ypFbx/UBlYbovgR/4+0VuqdLyDe3SrBrsz7AtNvDLUtQKA== +-----END CERTIFICATE-----`; + +// Minimal implicit-FTPS server: completes the TLS handshake and greets with 220. +class ImplicitTlsServer { + constructor() { + this.server = tls.createServer({ key: KEY, cert: CERT }, socket => { + socket.write("220 Welcome\r\n"); + }); + } + listen() { + return new Promise(resolve => this.server.listen(0, "127.0.0.1", resolve)); + } + get address() { + return this.server.address(); + } + close() { + this.server.close(); + } +} + +describe("Implicit TLS", () => { + + let server, client; + + beforeEach(async () => { + server = new ImplicitTlsServer(); + await server.listen(); + client = new Client(1000); + }); + + afterEach(() => { + client.close(); + server.close(); + }); + + // Regression for the Node.js v24.17.0 (CVE-2026-48934) change that binds a + // reusable TLS session to the host it was authenticated for. Data connections + // reuse the control connection's session, so the control host identity must be + // remembered on `tlsOptions`; otherwise data connections resume under a + // different identity and servers report the session as not resumed. + it("remembers the control host on tlsOptions so data connections can resume the session", async () => { + await client.connectImplicitTLS("127.0.0.1", server.address.port, { ca: CERT, rejectUnauthorized: true }); + assert.strictEqual(client.ftp.tlsOptions.host, "127.0.0.1"); + }); + + it("does not overwrite a host explicitly provided in secureOptions", async () => { + await client.connectImplicitTLS("127.0.0.1", server.address.port, { ca: CERT, rejectUnauthorized: true, host: "localhost" }); + assert.strictEqual(client.ftp.tlsOptions.host, "localhost"); + }); +});