diff --git a/app/connectionHandler.js b/app/connectionHandler.js index 98ad252..7bb8a0d 100644 --- a/app/connectionHandler.js +++ b/app/connectionHandler.js @@ -1,13 +1,55 @@ // server // app/connectionHandler.js + const createDebug = require("debug") -const path = require("path") const fs = require("fs") +const path = require("path") const debug = createDebug("webssh2:connectionHandler") +/** + * Modify the HTML content by replacing certain placeholders with dynamic values. + * @param {string} html - The original HTML content. + * @param {Object} config - The configuration object to inject into the HTML. + * @returns {string} - The modified HTML content. + */ +function modifyHtml(html, config) { + const modifiedHtml = html.replace( + /(src|href)="(?!http|\/\/)/g, + '$1="/ssh/assets/' + ) + + return modifiedHtml.replace( + "window.webssh2Config = null;", + `window.webssh2Config = ${JSON.stringify(config)};` + ) +} + +/** + * Handle reading the file and processing the response. + * @param {string} filePath - The path to the HTML file. + * @param {Object} config - The configuration object to inject into the HTML. + * @param {Object} res - The Express response object. + */ +function handleFileRead(filePath, config, res) { + // eslint-disable-next-line consistent-return + fs.readFile(filePath, "utf8", function(err, data) { + if (err) { + return res.status(500).send("Error loading client file") + } + + const modifiedHtml = modifyHtml(data, config) + res.send(modifiedHtml) + }) +} + +/** + * Handle the connection request and send the modified client HTML. + * @param {Object} req - The Express request object. + * @param {Object} res - The Express response object. + */ function handleConnection(req, res) { - debug("Handling connection") + debug("Handling connection req.path:", req.path) const clientPath = path.resolve( __dirname, @@ -23,33 +65,11 @@ function handleConnection(req, res) { url: `${req.protocol}://${req.get("host")}`, path: "/ssh/socket.io" }, - autoConnect: false // Default to false + autoConnect: req.path.startsWith("/host/") // Automatically connect if path starts with /host/ } - // Check if the current route is /host/:host - debug("handleConnection req.path:", req.path) - if (req.path.startsWith("/host/")) { - tempConfig.autoConnect = true - } - - fs.readFile(path.join(clientPath, "client.htm"), "utf8", function(err, data) { - if (err) { - return res.status(500).send("Error loading client file") - } - - let modifiedHtml = data.replace( - /(src|href)="(?!http|\/\/)/g, - '$1="/ssh/assets/' - ) - - modifiedHtml = modifiedHtml.replace( - "window.webssh2Config = null;", - `window.webssh2Config = ${JSON.stringify(tempConfig)};` - ) - - res.send(modifiedHtml) - // Explicitly return to satisfy the linter - }) + const filePath = path.join(clientPath, "client.htm") + handleFileRead(filePath, tempConfig, res) } module.exports = handleConnection diff --git a/app/routes.js b/app/routes.js index 8d74115..8189ba1 100644 --- a/app/routes.js +++ b/app/routes.js @@ -9,9 +9,14 @@ const router = express.Router() const basicAuth = require("basic-auth") const maskObject = require("jsmasker") const validator = require("validator") -const { validateSshTerm } = require("./utils") +const { + getValidatedHost, + getValidatedPort, + validateSshTerm +} = require("./utils") const handleConnection = require("./connectionHandler") +// eslint-disable-next-line consistent-return function auth(req, res, next) { debug("auth: Basic Auth") const credentials = basicAuth(req) @@ -30,38 +35,26 @@ function auth(req, res, next) { // Scenario 1: No auth required, uses websocket authentication instead router.get("/", function(req, res) { - debug("Accessed / route") + debug("router.get./: Accessed / route") handleConnection(req, res) }) +// Scenario 2: Auth required, uses HTTP Basic Auth // Scenario 2: Auth required, uses HTTP Basic Auth router.get("/host/:host", auth, function(req, res) { - debug(`Accessed /ssh/host/${req.params.host} route`) + debug(`router.get.host: /ssh/host/${req.params.host} route`) - // Validate and sanitize host parameter - const host = validator.isIP(req.params.host) - ? req.params.host - : validator.escape(req.params.host) + const host = getValidatedHost(req.params.host) + const port = getValidatedPort(req.query.port) - // Validate and sanitize port parameter if it exists - const port = req.query.port - ? validator.isPort(req.query.port) - ? parseInt(req.query.port, 10) - : 22 - : 22 // Default to 22 if port is not provided - - // Validate and sanitize sshTerm parameter if it exists - const sshTerm = req.query.sshTerm - ? validateSshTerm(req.query.sshTerm) - ? req.query.sshTerm - : null - : null // Default to 'xterm-color' if sshTerm is not provided + // Validate and sanitize sshterm parameter if it exists + const sshterm = validateSshTerm(req.query.sshterm) req.session.sshCredentials = req.session.sshCredentials || {} req.session.sshCredentials.host = host req.session.sshCredentials.port = port - if (req.query.sshTerm) { - req.session.sshCredentials.term = sshTerm + if (req.query.sshterm) { + req.session.sshCredentials.term = sshterm } req.session.usedBasicAuth = true diff --git a/app/socket.js b/app/socket.js index e5a5808..0c2927f 100644 --- a/app/socket.js +++ b/app/socket.js @@ -7,7 +7,7 @@ const debug = createDebug("webssh2:socket") const maskObject = require("jsmasker") const validator = require("validator") const SSHConnection = require("./ssh") -const { validateSshTerm } = require("./utils") +const { validateSshTerm, isValidCredentials } = require("./utils") module.exports = function(io, config) { io.on("connection", function(socket) { @@ -79,15 +79,15 @@ module.exports = function(io, config) { `initializeConnection: ${socket.id} conn.on ready: ${creds.username}@${creds.host}:${creds.port} successfully connected` ) - const auth_result = { action: "auth_result", success: true } + const authResult = { action: "auth_result", success: true } debug( `initializeConnection: ${ socket.id } conn.on ready: emitting authentication: ${JSON.stringify( - auth_result + authResult )}` ) - socket.emit("authentication", auth_result) + socket.emit("authentication", authResult) // Emit consolidated permissions const permissions = { @@ -189,7 +189,7 @@ module.exports = function(io, config) { }) stream.on("close", function(code, signal) { - debug(`handleStreamClose: ${socket.id}`) + debug(`handleStreamClose: ${socket.id}: ${code}, ${signal}`) handleConnectionClose() }) @@ -339,15 +339,20 @@ module.exports = function(io, config) { debug( `clearSessionCredentials: Clearing session credentials for ${socket.id}` ) - if (socket.handshake.session.sshCredentials) { - socket.handshake.session.sshCredentials.username = null - socket.handshake.session.sshCredentials.password = null + + const { session } = socket.handshake + + if (session.sshCredentials) { + session.sshCredentials.username = null + session.sshCredentials.password = null } - socket.handshake.session.usedBasicAuth = false + + session.usedBasicAuth = false sessionState.authenticated = false sessionState.username = null sessionState.password = null - socket.handshake.session.save(function(err) { + + session.save(function(err) { if (err) { console.error(`Failed to save session for ${socket.id}:`, err) } @@ -376,23 +381,3 @@ module.exports = function(io, config) { socket.on("disconnect", handleConnectionClose) }) } - -/** - * Checks if the provided credentials object is valid. - * - * @param {Object} creds - The credentials object. - * @param {string} creds.username - The username. - * @param {string} creds.password - The password. - * @param {string} creds.host - The host. - * @param {number} creds.port - The port. - * @returns {boolean} - Returns true if the credentials are valid, otherwise false. - */ -function isValidCredentials(creds) { - return ( - creds && - typeof creds.username === "string" && - typeof creds.password === "string" && - typeof creds.host === "string" && - typeof creds.port === "number" - ) -} diff --git a/app/utils.js b/app/utils.js index 0423d21..66b3a63 100644 --- a/app/utils.js +++ b/app/utils.js @@ -6,25 +6,6 @@ const crypto = require("crypto") const debug = createDebug("webssh2:utils") -/** - * Validates the SSH terminal name using validator functions. - * Allows alphanumeric characters, hyphens, and periods. - * @param {string} term - The terminal name to validate - * @returns {boolean} True if the terminal name is valid, false otherwise - */ -function validateSshTerm(term) { - debug(`validateSshTerm: %O`, term) - - if (term === undefined || term === null) { - return false - } - return ( - validator.isLength(term, { min: 1, max: 30 }) && - validator.matches(term, /^[a-zA-Z0-9.-]+$/) - ) -} - -exports.validateSshTerm = validateSshTerm /** * Deep merges two objects * @param {Object} target - The target object to merge into @@ -48,7 +29,7 @@ function deepMerge(target, source) { }) return output } -exports.deepMerge = deepMerge + /** * Generates a secure random session secret * @returns {string} A random 32-byte hex string @@ -56,4 +37,100 @@ exports.deepMerge = deepMerge function generateSecureSecret() { return crypto.randomBytes(32).toString("hex") } -exports.generateSecureSecret = generateSecureSecret + +/** + * Determines if a given host is an IP address or a hostname. + * If it's a hostname, it escapes it for safety. + * + * @param {string} host - The host string to validate and escape. + * @returns {string} - The original IP or escaped hostname. + */ +function getValidatedHost(host) { + let validatedHost + + if (validator.isIP(host)) { + validatedHost = host + } else { + validatedHost = validator.escape(host) + } + + return validatedHost +} + +/** + * Validates and sanitizes a port value. + * If no port is provided, defaults to port 22. + * If a port is provided, checks if it is a valid port number (1-65535). + * If the port is invalid, defaults to port 22. + * + * @param {string} [portInput] - The port string to validate and parse. + * @returns {number} - The validated port number. + */ +function getValidatedPort(portInput) { + const defaultPort = 22 + const port = defaultPort + debug("getValidatedPort: input: %O", portInput) + + if (portInput) { + if (validator.isInt(portInput, { min: 1, max: 65535 })) { + return parseInt(portInput, 10) + } + } + debug( + "getValidatedPort: port not specified or is invalid, setting port to: %O", + port + ) + + return port +} + +/** + * Checks if the provided credentials object is valid. + * + * @param {Object} creds - The credentials object. + * @param {string} creds.username - The username. + * @param {string} creds.password - The password. + * @param {string} creds.host - The host. + * @param {number} creds.port - The port. + * @returns {boolean} - Returns true if the credentials are valid, otherwise false. + */ +function isValidCredentials(creds) { + return ( + creds && + typeof creds.username === "string" && + typeof creds.password === "string" && + typeof creds.host === "string" && + typeof creds.port === "number" + ) +} + +/** + * Validates and sanitizes the SSH terminal name using validator functions. + * Allows alphanumeric characters, hyphens, and periods. + * Returns null if the terminal name is invalid or not provided. + * + * @param {string} [term] - The terminal name to validate. + * @returns {string|null} - The sanitized terminal name if valid, null otherwise. + */ +function validateSshTerm(term) { + debug(`validateSshTerm: %O`, term) + + if (!term) { + return null + } + + const validatedSshTerm = + validator.isLength(term, { min: 1, max: 30 }) && + validator.matches(term, /^[a-zA-Z0-9.-]+$/) + + return validatedSshTerm ? term : null +} + +module.exports = { + deepMerge, + generateSecureSecret, + getValidatedHost, + getValidatedPort, + isValidCredentials, + validateSshTerm +}