mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-29 18:11:40 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
813173ccad | ||
|
|
3170ab3e09 | ||
|
|
98cadb80fc | ||
|
|
e673ae4d07 | ||
|
|
ac0d595c96 |
@@ -0,0 +1,467 @@
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* Integration coverage for #3310: SFTP-tab uploads on OTP (keyboard-
|
||||
* interactive) hosts.
|
||||
*
|
||||
* The SFTP page opens pooled transfer connections through the unified SSH
|
||||
* transport registry. When a host authenticates interactively (one-time
|
||||
* codes), a second connection must never require another OTP round trip: the
|
||||
* pooled transfer open must ride the transport that the interactive login
|
||||
* already authenticated. When credentials genuinely diverge (e.g. an OTP was
|
||||
* saved into the host record after login), a fresh dial must surface a new
|
||||
* keyboard-interactive prompt instead of hanging silently.
|
||||
*
|
||||
* This test drives the real bridges against an in-process ssh2 server that
|
||||
* only accepts keyboard-interactive auth, mirroring the reporter's server.
|
||||
*/
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const net = require("node:net");
|
||||
const path = require("node:path");
|
||||
|
||||
const tempDirBridge = require("./tempDirBridge.cjs");
|
||||
|
||||
const { Server } = require("ssh2");
|
||||
const keygen = require("ssh2/lib/keygen.js");
|
||||
|
||||
const pool = require("./sshConnectionPool.cjs");
|
||||
const kiHandler = require("./keyboardInteractiveHandler.cjs");
|
||||
const sftpBridge = require("./sftpBridge.cjs");
|
||||
const transferBridge = require("./transferBridge.cjs");
|
||||
|
||||
const OTP_CODE = "135790";
|
||||
|
||||
// SFTP status codes (RFC draft-ietf-secsh-filexfer-02, section 7).
|
||||
const STATUS_OK = 0;
|
||||
const STATUS_EOF = 1;
|
||||
const STATUS_NO_SUCH_FILE = 2;
|
||||
|
||||
const FILE_MODE = 0o100644;
|
||||
const DIR_MODE = 0o040755;
|
||||
|
||||
/**
|
||||
* Minimal in-memory fs for the server side: enough of SFTP for Netcatty's
|
||||
* upload pipeline (stat / open / write / setstat / rename / backup cleanup).
|
||||
*/
|
||||
function makeSftpHandlers(files, dirs) {
|
||||
const handles = new Map();
|
||||
let nextHandle = 1;
|
||||
|
||||
const attrsFor = (p) => {
|
||||
if (files.has(p)) {
|
||||
return { mode: FILE_MODE, size: files.get(p).length, uid: 0, gid: 0, atime: 0, mtime: 0 };
|
||||
}
|
||||
if (dirs.has(p)) {
|
||||
return { mode: DIR_MODE, size: 4096, uid: 0, gid: 0, atime: 0, mtime: 0 };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const parentDir = (p) => {
|
||||
const idx = p.lastIndexOf("/");
|
||||
return idx <= 0 ? "/" : p.slice(0, idx);
|
||||
};
|
||||
|
||||
const ensureParents = (p) => {
|
||||
let cur = parentDir(p);
|
||||
const missing = [];
|
||||
while (cur !== "/" && !dirs.has(cur)) {
|
||||
missing.unshift(cur);
|
||||
cur = parentDir(cur);
|
||||
}
|
||||
for (const dir of missing) dirs.add(dir);
|
||||
};
|
||||
|
||||
return {
|
||||
attach(sftp) {
|
||||
sftp.on("REALPATH", (reqid, reqPath) => {
|
||||
sftp.name(reqid, [{ filename: reqPath || "/", longname: "drwxr-xr-x", attrs: { mode: DIR_MODE } }]);
|
||||
});
|
||||
sftp.on("STAT", (reqid, reqPath) => {
|
||||
const attrs = attrsFor(reqPath);
|
||||
if (attrs) sftp.attrs(reqid, attrs);
|
||||
else sftp.status(reqid, STATUS_NO_SUCH_FILE);
|
||||
});
|
||||
sftp.on("LSTAT", (reqid, reqPath) => {
|
||||
const attrs = attrsFor(reqPath);
|
||||
if (attrs) sftp.attrs(reqid, attrs);
|
||||
else sftp.status(reqid, STATUS_NO_SUCH_FILE);
|
||||
});
|
||||
sftp.on("FSTAT", (reqid, handle) => {
|
||||
const entry = handles.get(String(handle));
|
||||
const attrs = entry ? attrsFor(entry.path) : null;
|
||||
if (attrs) sftp.attrs(reqid, attrs);
|
||||
else sftp.status(reqid, STATUS_NO_SUCH_FILE);
|
||||
});
|
||||
sftp.on("OPENDIR", (reqid, reqPath) => {
|
||||
if (!dirs.has(reqPath)) {
|
||||
sftp.status(reqid, STATUS_NO_SUCH_FILE);
|
||||
return;
|
||||
}
|
||||
const handle = Buffer.from(`d${nextHandle++}`);
|
||||
handles.set(String(handle), { path: reqPath, dir: true, readPos: 0 });
|
||||
sftp.handle(reqid, handle);
|
||||
});
|
||||
sftp.on("READDIR", (reqid, handle) => {
|
||||
const entry = handles.get(String(handle));
|
||||
if (!entry) {
|
||||
sftp.status(reqid, STATUS_NO_SUCH_FILE);
|
||||
return;
|
||||
}
|
||||
if (entry.readPos !== 0) {
|
||||
sftp.status(reqid, STATUS_EOF);
|
||||
return;
|
||||
}
|
||||
entry.readPos = 1;
|
||||
const names = [...files.keys(), ...dirs.keys()]
|
||||
.filter((p) => parentDir(p) === entry.path && p !== entry.path)
|
||||
.map((p) => ({
|
||||
filename: p.slice(p.lastIndexOf("/") + 1),
|
||||
longname: files.has(p) ? "-rw-r--r--" : "drwxr-xr-x",
|
||||
attrs: attrsFor(p),
|
||||
}));
|
||||
sftp.name(reqid, names);
|
||||
});
|
||||
sftp.on("OPEN", (reqid, reqPath, flags) => {
|
||||
ensureParents(reqPath);
|
||||
if (!files.has(reqPath) && !(flags & 0x1)) {
|
||||
files.set(reqPath, Buffer.alloc(0));
|
||||
}
|
||||
const handle = Buffer.from(`f${nextHandle++}`);
|
||||
handles.set(String(handle), { path: reqPath, readPos: 0 });
|
||||
sftp.handle(reqid, handle);
|
||||
});
|
||||
sftp.on("READ", (reqid, handle, offset, len) => {
|
||||
const entry = handles.get(String(handle));
|
||||
const data = entry ? files.get(entry.path) : undefined;
|
||||
if (!entry || data === undefined) {
|
||||
sftp.status(reqid, STATUS_NO_SUCH_FILE);
|
||||
return;
|
||||
}
|
||||
if (offset >= data.length) {
|
||||
sftp.status(reqid, STATUS_EOF);
|
||||
return;
|
||||
}
|
||||
sftp.data(reqid, data.subarray(offset, offset + len));
|
||||
});
|
||||
sftp.on("WRITE", (reqid, handle, offset, data) => {
|
||||
const entry = handles.get(String(handle));
|
||||
if (!entry) {
|
||||
sftp.status(reqid, STATUS_NO_SUCH_FILE);
|
||||
return;
|
||||
}
|
||||
const prev = files.get(entry.path) || Buffer.alloc(0);
|
||||
const next = Buffer.alloc(Math.max(prev.length, offset + data.length));
|
||||
prev.copy(next);
|
||||
data.copy(next, offset);
|
||||
files.set(entry.path, next);
|
||||
sftp.status(reqid, STATUS_OK);
|
||||
});
|
||||
sftp.on("CLOSE", (reqid, handle) => {
|
||||
handles.delete(String(handle));
|
||||
sftp.status(reqid, STATUS_OK);
|
||||
});
|
||||
sftp.on("SETSTAT", (reqid) => sftp.status(reqid, STATUS_OK));
|
||||
sftp.on("FSETSTAT", (reqid) => sftp.status(reqid, STATUS_OK));
|
||||
sftp.on("REMOVE", (reqid, reqPath) => {
|
||||
if (files.delete(reqPath)) sftp.status(reqid, STATUS_OK);
|
||||
else sftp.status(reqid, STATUS_NO_SUCH_FILE);
|
||||
});
|
||||
sftp.on("RMDIR", (reqid, reqPath) => {
|
||||
if (dirs.delete(reqPath)) sftp.status(reqid, STATUS_OK);
|
||||
else sftp.status(reqid, STATUS_NO_SUCH_FILE);
|
||||
});
|
||||
sftp.on("MKDIR", (reqid, reqPath) => {
|
||||
ensureParents(reqPath);
|
||||
dirs.add(reqPath);
|
||||
sftp.status(reqid, STATUS_OK);
|
||||
});
|
||||
sftp.on("RENAME", (reqid, from, to) => {
|
||||
if (files.has(from)) {
|
||||
files.set(to, files.get(from));
|
||||
files.delete(from);
|
||||
sftp.status(reqid, STATUS_OK);
|
||||
return;
|
||||
}
|
||||
if (dirs.has(from)) {
|
||||
dirs.delete(from);
|
||||
dirs.add(to);
|
||||
sftp.status(reqid, STATUS_OK);
|
||||
return;
|
||||
}
|
||||
sftp.status(reqid, STATUS_NO_SUCH_FILE);
|
||||
});
|
||||
sftp.on("EXTENDED", (reqid) => sftp.status(reqid, STATUS_OK));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function startOtpOnlyServer() {
|
||||
return new Promise((resolve, reject) => {
|
||||
keygen.generateKeyPair("ed25519", (err, keys) => {
|
||||
if (err) return reject(err);
|
||||
const files = new Map();
|
||||
const dirs = new Set(["/", "/home", "/home/otpuser"]);
|
||||
const handlers = makeSftpHandlers(files, dirs);
|
||||
let keyboardInteractiveRounds = 0;
|
||||
|
||||
const sshServer = new Server({ hostKeys: [keys.private] }, (client) => {
|
||||
client.on("authentication", (ctx) => {
|
||||
if (ctx.method === "keyboard-interactive") {
|
||||
keyboardInteractiveRounds += 1;
|
||||
ctx.prompt(
|
||||
[{ prompt: "Verification code (OTP):", echo: false }],
|
||||
"OTP required",
|
||||
"",
|
||||
(answers) => {
|
||||
if (answers && answers[0] === OTP_CODE) ctx.accept();
|
||||
else ctx.reject(["keyboard-interactive"]);
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
// The server refuses everything else: OTP is the only way in.
|
||||
ctx.reject(["keyboard-interactive"]);
|
||||
});
|
||||
client.on("ready", () => {
|
||||
client.on("session", (accept) => {
|
||||
const session = accept();
|
||||
session.on("sftp", (acceptChannel) => {
|
||||
const sftp = acceptChannel();
|
||||
handlers.attach(sftp);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const sockets = new Set();
|
||||
const server = net.createServer((socket) => {
|
||||
sockets.add(socket);
|
||||
socket.on("close", () => sockets.delete(socket));
|
||||
sshServer.injectSocket(socket);
|
||||
});
|
||||
server.on("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => resolve({
|
||||
port: server.address().port,
|
||||
files,
|
||||
dirs,
|
||||
getKeyboardInteractiveRounds: () => keyboardInteractiveRounds,
|
||||
close: () => new Promise((done) => {
|
||||
// Include clients still waiting for authentication, before registration.
|
||||
for (const socket of sockets) socket.destroy();
|
||||
sshServer.close?.();
|
||||
server.close(() => done());
|
||||
}),
|
||||
}));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function createSender() {
|
||||
const sender = {
|
||||
sent: [],
|
||||
isDestroyed: () => false,
|
||||
send(channel, payload) {
|
||||
sender.sent.push({ channel, payload });
|
||||
},
|
||||
};
|
||||
return sender;
|
||||
}
|
||||
|
||||
/** Answer every pending keyboard-interactive request with the OTP code. */
|
||||
function answerOtpPrompts(sender) {
|
||||
const surfaced = sender.sent.some((s) => s.channel === "netcatty:keyboard-interactive");
|
||||
for (const { channel, payload } of sender.sent) {
|
||||
if (channel === "netcatty:keyboard-interactive" && kiHandler.getRequests().has(payload.requestId)) {
|
||||
kiHandler.handleResponse({ sender }, { requestId: payload.requestId, responses: [OTP_CODE] });
|
||||
}
|
||||
}
|
||||
return surfaced;
|
||||
}
|
||||
|
||||
async function waitFor(condition, { timeoutMs = 8000, stepMs = 25, message } = {}) {
|
||||
for (let elapsed = 0; elapsed < timeoutMs; elapsed += stepMs) {
|
||||
if (condition()) return true;
|
||||
await new Promise((resolve) => setTimeout(resolve, stepMs));
|
||||
}
|
||||
assert.ok(condition(), message || "condition not met before timeout");
|
||||
return true;
|
||||
}
|
||||
|
||||
const BASE_OPTIONS = (port) => ({
|
||||
hostname: "127.0.0.1",
|
||||
hostId: "host-otp-3310",
|
||||
port,
|
||||
username: "otpuser",
|
||||
authMethod: "password",
|
||||
useSshAgent: false,
|
||||
verifyHostKeys: false,
|
||||
fileProtocol: "sftp",
|
||||
identityFilePaths: [],
|
||||
});
|
||||
|
||||
test("SFTP-page transfers reuse the OTP-authenticated transport (#3310)", async (t) => {
|
||||
const server = await startOtpOnlyServer();
|
||||
pool.resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
|
||||
t.after(() => {
|
||||
pool.resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
|
||||
});
|
||||
|
||||
const pendingOpens = [];
|
||||
const sftpClients = new Map();
|
||||
sftpBridge.init({ sftpClients, sessions: new Map(), electronModule: {} });
|
||||
transferBridge.init({ sftpClients });
|
||||
|
||||
// Tear down SSH clients and shared transports BEFORE awaiting server
|
||||
// closure: node runs `t.after` hooks in registration order, and
|
||||
// `net.Server.close()` would hang if an assertion failed while the SSH
|
||||
// TCP clients were still connected.
|
||||
t.after(async () => {
|
||||
for (const sftpId of [...sftpClients.keys()]) {
|
||||
try {
|
||||
await sftpBridge.closeSftp(null, { sftpId });
|
||||
} catch {
|
||||
// Best-effort cleanup.
|
||||
}
|
||||
}
|
||||
for (const pending of [...kiHandler.getRequests().values()]) {
|
||||
kiHandler.cancelRequestsForSession(pending.sessionId, "test-end");
|
||||
}
|
||||
pool.discardAllTransports("test-end");
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
});
|
||||
t.after(async () => {
|
||||
await server.close();
|
||||
await Promise.allSettled(pendingOpens);
|
||||
});
|
||||
|
||||
const options = BASE_OPTIONS(server.port);
|
||||
const loginEndpointKey = pool.buildEndpointKey(
|
||||
pool.buildConnectionReuseEndpoint({ ...options, sessionId: "sftp-browse-otp" }),
|
||||
);
|
||||
const pooledEndpointKey = pool.buildEndpointKey(pool.buildConnectionReuseEndpoint(options));
|
||||
assert.equal(
|
||||
pooledEndpointKey,
|
||||
loginEndpointKey,
|
||||
"pooled transfer opens must resolve to the login endpoint",
|
||||
);
|
||||
|
||||
// -- 1. SFTP-page login: keyboard-interactive modal, then browse open. --
|
||||
const browseSender = createSender();
|
||||
const browseOpen = sftpBridge.openSftp(
|
||||
{ sender: browseSender },
|
||||
{ ...options, sessionId: "sftp-browse-otp" },
|
||||
);
|
||||
pendingOpens.push(browseOpen);
|
||||
browseOpen.catch(() => {}); // Teardown observes failures if the prompt wait fails first.
|
||||
const browseSurfaced = await waitFor(
|
||||
() => answerOtpPrompts(browseSender),
|
||||
{ message: "SFTP-tab login must surface the OTP prompt" },
|
||||
);
|
||||
assert.ok(browseSurfaced, "OTP prompt must be shown for the SFTP-tab login");
|
||||
const browse = await browseOpen;
|
||||
assert.equal(browse.fileProtocol, "sftp");
|
||||
assert.equal(server.getKeyboardInteractiveRounds(), 1, "exactly one OTP round for login");
|
||||
|
||||
// -- 2. Pooled transfer open: rides the authenticated transport. --
|
||||
const transferSender = createSender();
|
||||
const transferOpen = sftpBridge.openSftp(
|
||||
{ sender: transferSender },
|
||||
{ ...options },
|
||||
);
|
||||
pendingOpens.push(transferOpen);
|
||||
let transferOpenSettled = false;
|
||||
transferOpen.then(
|
||||
() => { transferOpenSettled = true; },
|
||||
() => { transferOpenSettled = true; },
|
||||
);
|
||||
const transferPrompted = () => transferSender.sent.some(
|
||||
(s) => s.channel === "netcatty:keyboard-interactive",
|
||||
);
|
||||
await waitFor(() => transferOpenSettled || transferPrompted(), {
|
||||
message: "pooled transfer open must complete promptly",
|
||||
});
|
||||
assert.equal(transferPrompted(), false, "pooled transfer open must not prompt again");
|
||||
const transfer = await transferOpen;
|
||||
const transferClient = sftpClients.get(transfer.sftpId);
|
||||
assert.ok(transferClient, "pooled transfer open must register a client");
|
||||
assert.equal(
|
||||
transferClient.__netcattyTransportManaged,
|
||||
true,
|
||||
"pooled transfer open must ride the shared transport",
|
||||
);
|
||||
assert.equal(
|
||||
server.getKeyboardInteractiveRounds(),
|
||||
1,
|
||||
"pooled transfer open must not re-authenticate (no second OTP)",
|
||||
);
|
||||
const transferChannel = await sftpBridge.requireSftpChannel(transferClient);
|
||||
assert.ok(transferChannel, "shared transport must host the transfer SFTP channel");
|
||||
|
||||
// -- 3. Stream upload over the reused session completes. --
|
||||
const tempRoot = fs.mkdtempSync(`${tempDirBridge.getTempFilePath("otp-reuse-upload")}-`);
|
||||
t.after(() => {
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
});
|
||||
const localFile = path.join(tempRoot, "upload.txt");
|
||||
const payload = Buffer.from("hello otp upload\n");
|
||||
fs.writeFileSync(localFile, payload);
|
||||
|
||||
const lifecycleEvents = [];
|
||||
const uploadSender = { send: (channel, event) => lifecycleEvents.push({ channel, event }) };
|
||||
const streamResult = await transferBridge.startTransfer(
|
||||
{ sender: uploadSender },
|
||||
{
|
||||
transferId: "otp-reuse-stream-1",
|
||||
sourcePath: localFile,
|
||||
targetPath: "/home/otpuser/stream-upload.txt",
|
||||
sourceType: "local",
|
||||
targetType: "sftp",
|
||||
targetSftpId: transfer.sftpId,
|
||||
targetEncoding: "utf-8",
|
||||
totalBytes: payload.length,
|
||||
},
|
||||
);
|
||||
assert.equal(
|
||||
streamResult?.error,
|
||||
undefined,
|
||||
`stream upload over the reused OTP transport must succeed: ${streamResult?.error}`,
|
||||
);
|
||||
assert.ok(
|
||||
lifecycleEvents.some((e) => e.channel === "netcatty:transfer:started"),
|
||||
"stream upload must emit transfer lifecycle events",
|
||||
);
|
||||
assert.equal(
|
||||
Buffer.from(server.files.get("/home/otpuser/stream-upload.txt") || Buffer.alloc(0)).toString("utf-8"),
|
||||
payload.toString("utf-8"),
|
||||
"remote file must contain the uploaded payload",
|
||||
);
|
||||
assert.equal(server.getKeyboardInteractiveRounds(), 1, "upload must not re-authenticate");
|
||||
|
||||
// -- 4. Credential drift: fresh dial must surface a new OTP prompt, --
|
||||
// not hang silently (e.g. an OTP saved into the host record after login).
|
||||
const driftSender = createSender();
|
||||
const driftOpen = sftpBridge.openSftp(
|
||||
{ sender: driftSender },
|
||||
{ ...options, password: "one-time-code-not-valid-anymore" },
|
||||
);
|
||||
pendingOpens.push(driftOpen);
|
||||
driftOpen.catch(() => {});
|
||||
await waitFor(
|
||||
() =>
|
||||
driftSender.sent.some((s) => s.channel === "netcatty:keyboard-interactive") &&
|
||||
server.getKeyboardInteractiveRounds() >= 2,
|
||||
{ message: "drifted transfer open must surface a fresh OTP prompt" },
|
||||
);
|
||||
const driftSurfaced = answerOtpPrompts(driftSender);
|
||||
assert.ok(driftSurfaced, "fresh OTP prompt must actually reach the renderer sender");
|
||||
const drifted = await driftOpen;
|
||||
assert.equal(
|
||||
sftpClients.get(drifted.sftpId)?.__netcattyTransportManaged,
|
||||
true,
|
||||
"drifted dial must still register a transport-managed client",
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user