diff --git a/app/package.json b/app/package.json index 032725ebc..51ca5aead 100644 --- a/app/package.json +++ b/app/package.json @@ -21,7 +21,7 @@ } ], "engines": { - "node": ">= 6" + "node": ">= 10" }, "bugs": { "url": "https://github.com/billchurch/WebSSH2/issues" diff --git a/app/scripts/webpack.common.js b/app/scripts/webpack.common.js index 14e2f39f0..368e838d4 100644 --- a/app/scripts/webpack.common.js +++ b/app/scripts/webpack.common.js @@ -1,5 +1,5 @@ const path = require('path') -const { CleanWebpackPlugin } = require('clean-webpack-plugin'); +const { CleanWebpackPlugin } = require('clean-webpack-plugin') /* const CleanWebpackPlugin = require('clean-webpack-plugin') */ const CopyWebpackPlugin = require('copy-webpack-plugin') const ExtractTextPlugin = require('extract-text-webpack-plugin') diff --git a/app/scripts/webpack.prod.js b/app/scripts/webpack.prod.js index 08e51a3ee..04567dda9 100644 --- a/app/scripts/webpack.prod.js +++ b/app/scripts/webpack.prod.js @@ -1,4 +1,4 @@ -const TerserPlugin = require('terser-webpack-plugin'); +const TerserPlugin = require('terser-webpack-plugin') const merge = require('webpack-merge') const common = require('./webpack.common.js') @@ -9,8 +9,8 @@ module.exports = merge(common, { terserOptions: { parallel: 4, ie8: false, - safari10: false + safari10: false } - })], + })] } -}) \ No newline at end of file +}) diff --git a/app/server/app.js b/app/server/app.js index 5f0b49061..64240d2e4 100644 --- a/app/server/app.js +++ b/app/server/app.js @@ -2,104 +2,28 @@ /* jshint esversion: 6, asi: true, node: true */ // app.js -var path = require('path') -var fs = require('fs') -var nodeRoot = path.dirname(require.main.filename) -var configPath = path.join(nodeRoot, 'config.json') -var publicPath = path.join(nodeRoot, 'client', 'public') -console.log('WebSSH2 service reading config from: ' + configPath) -var express = require('express') -var logger = require('morgan') - -// sane defaults if config.json or parts are missing -let config = { - listen: { - ip: '0.0.0.0', - port: 2222 - }, - user: { - name: null, - password: null, - privatekey: null - }, - ssh: { - host: null, - port: 22, - term: 'xterm-color', - readyTimeout: 20000, - keepaliveInterval: 120000, - keepaliveCountMax: 10, - allowedSubnets: [] - }, - terminal: { - cursorBlink: true, - scrollback: 10000, - tabStopWidth: 8, - bellStyle: 'sound' - }, - header: { - text: null, - background: 'green' - }, - session: { - name: 'WebSSH2', - secret: 'mysecret' - }, - options: { - challengeButton: true, - allowreauth: true - }, - algorithms: { - kex: [ - 'ecdh-sha2-nistp256', - 'ecdh-sha2-nistp384', - 'ecdh-sha2-nistp521', - 'diffie-hellman-group-exchange-sha256', - 'diffie-hellman-group14-sha1' - ], - cipher: [ - 'aes128-ctr', - 'aes192-ctr', - 'aes256-ctr', - 'aes128-gcm', - 'aes128-gcm@openssh.com', - 'aes256-gcm', - 'aes256-gcm@openssh.com', - 'aes256-cbc' - ], - hmac: [ - 'hmac-sha2-256', - 'hmac-sha2-512', - 'hmac-sha1' - ], - compress: [ - 'none', - 'zlib@openssh.com', - 'zlib' - ] - }, - serverlog: { - client: false, - server: false - }, - accesslog: false, - verify: false -} +const compression = require('compression') +const config = require('./app_config') +const express = require('express') +const http = require('http') +const logger = require('morgan') +const path = require('path') +const socketIo = require('socket.io') +const bodyParser = require('body-parser') -// test if config.json exists, if not provide error message but try to run -// anyway -try { - if (fs.existsSync(configPath)) { - console.log('ephemeral_auth service reading config from: ' + configPath) - config = require('read-config')(configPath) - } else { - console.error('\n\nERROR: Missing config.json for webssh. Current config: ' + JSON.stringify(config)) - console.error('\n See config.json.sample for details\n\n') - } -} catch (err) { - console.error('\n\nERROR: Missing config.json for webssh. Current config: ' + JSON.stringify(config)) - console.error('\n See config.json.sample for details\n\n') - console.error('ERROR:\n\n ' + err) +const expressOptions = require('./expressOptions') +const myutil = require('./util') +const socket = require('./socket') +const sshSessionConfig = require('./ssh_session_config') + +// Credential Token Store +const DEFAULT_CC_TTL = 60 * 1000 * 5 +const cachedCredentials = {} +function cachedCredentialSetTTL (key, ttl) { + setTimeout(() => { + console.log() + delete cachedCredentials[key] + }, ttl) } var session = require('express-session')({ @@ -109,72 +33,80 @@ var session = require('express-session')({ saveUninitialized: false, unset: 'destroy' }) -var app = express() -var compression = require('compression') -var server = require('http').Server(app) -var myutil = require('./util') -myutil.setDefaultCredentials(config.user.name, config.user.password, config.user.privatekey); -var validator = require('validator') -var io = require('socket.io')(server, { serveClient: false }) -var socket = require('./socket') -var expressOptions = require('./expressOptions') + +myutil.setDefaultCredentials(config.user.name, config.user.password, config.user.privatekey) // express +const app = express() +const server = http.Server(app) +const io = socketIo(server, { serveClient: false }) +app.use(bodyParser.json()) app.use(compression({ level: 9 })) app.use(session) -app.use(myutil.basicAuth) +// app.use(myutil.basicAuth) if (config.accesslog) app.use(logger('common')) app.disable('x-powered-by') // static files +const publicPath = path.join(path.dirname(require.main.filename), 'client', 'public') app.use(express.static(publicPath, expressOptions)) -app.get('/reauth', function (req, res, next) { - var r = req.headers.referer || '/' - res.status(401).send('') -}) +//app.get('/reauth', function (req, res, next) { +// var r = req.headers.referer || '/' +// res.status(401).send('') +//}) + +// eslint-disable-next-line complexity +//app.get('/ssh/host/:host?', function (req, res, next) { +// res.sendFile(path.join(path.join(publicPath, 'client.htm'))) +// +// // Setup the session data +// req.session.ssh = sshSessionConfig(config, Object.assign({}, req.params, req.query, req.headers)) +//}) // eslint-disable-next-line complexity -app.get('/ssh/host/:host?', function (req, res, next) { - res.sendFile(path.join(path.join(publicPath, 'client.htm'))) - // capture, assign, and validated variables - req.session.ssh = { - host: (validator.isIP(req.params.host + '') && req.params.host) || - (validator.isFQDN(req.params.host) && req.params.host) || - (/^(([a-z]|[A-Z]|[0-9]|[!^(){}\-_~])+)?\w$/.test(req.params.host) && - req.params.host) || config.ssh.host, - port: (validator.isInt(req.query.port + '', { min: 1, max: 65535 }) && - req.query.port) || config.ssh.port, - localAddress: config.ssh.localAddress, - localPort: config.ssh.localPort, - header: { - name: req.query.header || config.header.text, - background: req.query.headerBackground || config.header.background - }, - algorithms: config.algorithms, - keepaliveInterval: config.ssh.keepaliveInterval, - keepaliveCountMax: config.ssh.keepaliveCountMax, - allowedSubnets: config.ssh.allowedSubnets, - term: (/^(([a-z]|[A-Z]|[0-9]|[!^(){}\-_~])+)?\w$/.test(req.query.sshterm) && - req.query.sshterm) || config.ssh.term, - terminal: { - cursorBlink: (validator.isBoolean(req.query.cursorBlink + '') ? myutil.parseBool(req.query.cursorBlink) : config.terminal.cursorBlink), - scrollback: (validator.isInt(req.query.scrollback + '', { min: 1, max: 200000 }) && req.query.scrollback) ? req.query.scrollback : config.terminal.scrollback, - tabStopWidth: (validator.isInt(req.query.tabStopWidth + '', { min: 1, max: 100 }) && req.query.tabStopWidth) ? req.query.tabStopWidth : config.terminal.tabStopWidth, - bellStyle: ((req.query.bellStyle) && (['sound', 'none'].indexOf(req.query.bellStyle) > -1)) ? req.query.bellStyle : config.terminal.bellStyle - }, - allowreplay: config.options.challengeButton || (validator.isBoolean(req.headers.allowreplay + '') ? myutil.parseBool(req.headers.allowreplay) : false), - allowreauth: config.options.allowreauth || false, - mrhsession: ((validator.isAlphanumeric(req.headers.mrhsession + '') && req.headers.mrhsession) ? req.headers.mrhsession : 'none'), - serverlog: { - client: config.serverlog.client || false, - server: config.serverlog.server || false - }, - readyTimeout: (validator.isInt(req.query.readyTimeout + '', { min: 1, max: 300000 }) && - req.query.readyTimeout) || config.ssh.readyTimeout +app.get('/ssh/token_session/:token', function (req, res, next) { + const token = req.params.token + const creds = cachedCredentials[token] + + if (creds) { + delete cachedCredentials[token] + res.sendFile(path.join(path.join(publicPath, 'client.htm'))) + + const { username, userpassword, host, port, label } = creds + + // Setup the session data + req.session.username = username + req.session.userpassword = userpassword + req.session.label = label + req.session.ssh = sshSessionConfig(config, Object.assign({}, req.query, req.headers, { host: host, port: port })) + console.log(`[GET] /ssh/token_session/${token} - Claimed session`) + } else { + console.log(`[GET] /ssh/token_session/${token} - 404`) + res.status(404).send('

Session Not Found

404

') + } +}) + +app.post('/ssh/set_session_credentials', (req, res) => { + const data = req.body + + if (!data.username || !data.userpassword || !data.host) { + res.status(400).send('{"Error": "Missing required field"}') + } else { + const token = myutil.uuidv4() + const sshConfig = { + username: data.username, + userpassword: data.userpassword, + label: data.label, + host: data.host, + port: data.port || config.ssh.port + } + + console.log(`[POST] /ssh/set_session_credentials - ${data.host} | ${data.username} 200`) + cachedCredentials[token] = sshConfig + cachedCredentialSetTTL(token, DEFAULT_CC_TTL) + res.status(200).send(JSON.stringify({ token: token, ttl: DEFAULT_CC_TTL })) } - if (req.session.ssh.header.name) validator.escape(req.session.ssh.header.name) - if (req.session.ssh.header.background) validator.escape(req.session.ssh.header.background) }) // express error handling diff --git a/app/server/app_config.js b/app/server/app_config.js new file mode 100644 index 000000000..e73a3f528 --- /dev/null +++ b/app/server/app_config.js @@ -0,0 +1,99 @@ +const path = require('path') +const fs = require('fs') + +const configPath = path.join(path.dirname(require.main.filename), 'config.json') + +console.log('WebSSH2 service reading config from: ' + configPath) + +// sane defaults if config.json or parts are missing +let config = { + listen: { + ip: '0.0.0.0', + port: 2222 + }, + user: { + name: null, + password: null, + privatekey: null + }, + ssh: { + host: null, + port: 22, + term: 'xterm-color', + readyTimeout: 20000, + keepaliveInterval: 120000, + keepaliveCountMax: 10, + allowedSubnets: [] + }, + terminal: { + cursorBlink: true, + scrollback: 10000, + tabStopWidth: 8, + bellStyle: 'sound' + }, + header: { + text: null, + background: 'green' + }, + session: { + name: 'WebSSH2', + secret: 'mysecret' + }, + options: { + challengeButton: true, + allowreauth: true + }, + algorithms: { + kex: [ + 'ecdh-sha2-nistp256', + 'ecdh-sha2-nistp384', + 'ecdh-sha2-nistp521', + 'diffie-hellman-group-exchange-sha256', + 'diffie-hellman-group14-sha1' + ], + cipher: [ + 'aes128-ctr', + 'aes192-ctr', + 'aes256-ctr', + 'aes128-gcm', + 'aes128-gcm@openssh.com', + 'aes256-gcm', + 'aes256-gcm@openssh.com', + 'aes256-cbc' + ], + hmac: [ + 'hmac-sha2-256', + 'hmac-sha2-512', + 'hmac-sha1' + ], + compress: [ + 'none', + 'zlib@openssh.com', + 'zlib' + ] + }, + serverlog: { + client: false, + server: false + }, + accesslog: false, + verify: false +} + +// test if config.json exists, if not provide error message but try to run +// anyway +try { + if (fs.existsSync(configPath)) { + console.log('ephemeral_auth service reading config from: ' + configPath) + config = require('read-config')(configPath) + } else { + console.error('\n\nERROR: Missing config.json for webssh. Current config: ' + JSON.stringify(config)) + console.error('\n See config.json.sample for details\n\n') + } +} catch (err) { + console.error('\n\nERROR: Missing config.json for webssh. Current config: ' + JSON.stringify(config)) + console.error('\n See config.json.sample for details\n\n') + console.error('ERROR:\n\n ' + err) +} + +module.exports = config diff --git a/app/server/socket.js b/app/server/socket.js index da880f880..26c549d07 100644 --- a/app/server/socket.js +++ b/app/server/socket.js @@ -7,7 +7,7 @@ var debug = require('debug') var debugWebSSH2 = require('debug')('WebSSH2') var SSH = require('ssh2').Client -var CIDRMatcher = require('cidr-matcher'); +var CIDRMatcher = require('cidr-matcher') // var fs = require('fs') // var hostkeys = JSON.parse(fs.readFileSync('./hostkeyhashes.json', 'utf8')) var termCols, termRows @@ -25,8 +25,8 @@ module.exports = function socket (socket) { } // If configured, check that requsted host is in a permitted subnet - if ( (((socket.request.session || {}).ssh || {}).allowedSubnets || {}).length && ( socket.request.session.ssh.allowedSubnets.length > 0 ) ) { - var matcher = new CIDRMatcher(socket.request.session.ssh.allowedSubnets); + if ((((socket.request.session || {}).ssh || {}).allowedSubnets || {}).length && (socket.request.session.ssh.allowedSubnets.length > 0)) { + var matcher = new CIDRMatcher(socket.request.session.ssh.allowedSubnets) if (!matcher.contains(socket.request.session.ssh.host)) { console.log('WebSSH2 ' + 'error: Requested host outside configured subnets / REJECTED'.red.bold + ' user=' + socket.request.session.username.yellow.bold.underline + @@ -53,13 +53,13 @@ module.exports = function socket (socket) { socket.emit('menu', menuData) socket.emit('allowreauth', socket.request.session.ssh.allowreauth) socket.emit('setTerminalOpts', socket.request.session.ssh.terminal) - socket.emit('title', 'ssh://' + socket.request.session.ssh.host) + socket.emit('title', socket.request.session.label ? socket.request.session.label : 'ssh://' + socket.request.session.ssh.host) if (socket.request.session.ssh.header.background) socket.emit('headerBackground', socket.request.session.ssh.header.background) if (socket.request.session.ssh.header.name) socket.emit('header', socket.request.session.ssh.header.name) - socket.emit('footer', 'ssh://' + socket.request.session.username + '@' + socket.request.session.ssh.host + ':' + socket.request.session.ssh.port) + socket.emit('footer', socket.request.session.label ? socket.request.session.label : `ssh://${socket.request.session.username}@${socket.request.session.ssh.host}:${socket.request.session.ssh.port}`) socket.emit('status', 'SSH CONNECTION ESTABLISHED') socket.emit('statusBackground', 'green') - socket.emit('allowreplay', socket.request.session.ssh.allowreplay) + socket.emit('allowreplay', false);//socket.request.session.ssh.allowreplay) conn.shell({ term: socket.request.session.ssh.term, cols: termCols, @@ -86,10 +86,10 @@ module.exports = function socket (socket) { }) socket.on('control', function socketOnControl (controlData) { switch (controlData) { - case 'replayCredentials': - if (socket.request.session.ssh.allowreplay) { - stream.write(socket.request.session.userpassword + '\n') - } + //case 'replayCredentials': + // if (socket.request.session.ssh.allowreplay) { + // stream.write(socket.request.session.userpassword + '\n') + // } /* falls through */ default: console.log('controlData: ' + controlData) @@ -124,7 +124,7 @@ module.exports = function socket (socket) { }) conn.on('end', function connOnEnd (err) { SSHerror('CONN END BY HOST', err) }) - conn.on('close', function connOnClose (err) { SSHerror('CONN CLOSE', err) }) + conn.on('close', function connOnClose (err) { SSHerror('CONN CLOSE', err) }) conn.on('error', function connOnError (err) { SSHerror('CONN ERROR', err) }) conn.on('keyboard-interactive', function connOnKeyboardInteractive (name, instructions, instructionsLang, prompts, finish) { debugWebSSH2('conn.on(\'keyboard-interactive\')') @@ -132,7 +132,7 @@ module.exports = function socket (socket) { }) if (socket.request.session.username && (socket.request.session.userpassword || socket.request.session.privatekey) && socket.request.session.ssh) { // console.log('hostkeys: ' + hostkeys[0].[0]) - conn.connect({ + const sshConfig = { host: socket.request.session.ssh.host, port: socket.request.session.ssh.port, localAddress: socket.request.session.ssh.localAddress, @@ -146,7 +146,9 @@ module.exports = function socket (socket) { keepaliveInterval: socket.request.session.ssh.keepaliveInterval, keepaliveCountMax: socket.request.session.ssh.keepaliveCountMax, debug: debug('ssh2') - }) + } + + conn.connect(sshConfig) } else { debugWebSSH2('Attempt to connect without session.username/password or session varialbles defined, potentially previously abandoned client session. disconnecting websocket client.\r\nHandshake information: \r\n ' + JSON.stringify(socket.handshake)) socket.emit('ssherror', 'WEBSOCKET ERROR - Refresh the browser and try again') diff --git a/app/server/ssh_session_config.js b/app/server/ssh_session_config.js new file mode 100644 index 000000000..84aca234f --- /dev/null +++ b/app/server/ssh_session_config.js @@ -0,0 +1,44 @@ +var validator = require('validator') +const myutil = require('./util') + +module.exports = (appConfig, opts) => { + const sshSessionConfig = { + host: (validator.isIP(opts.host + '') && opts.host) || + (validator.isFQDN(opts.host) && opts.host) || + (/^(([a-z]|[A-Z]|[0-9]|[!^(){}\-_~])+)?\w$/.test(opts.host) && + opts.host) || appConfig.ssh.host, + port: (validator.isInt(opts.port + '', { min: 1, max: 65535 }) && + opts.port) || appConfig.ssh.port, + localAddress: appConfig.ssh.localAddress, + localPort: appConfig.ssh.localPort, + header: { + name: opts.header || appConfig.header.text, + background: opts.headerBackground || appConfig.header.background + }, + algorithms: appConfig.algorithms, + keepaliveInterval: appConfig.ssh.keepaliveInterval, + keepaliveCountMax: appConfig.ssh.keepaliveCountMax, + allowedSubnets: appConfig.ssh.allowedSubnets, + term: (/^(([a-z]|[A-Z]|[0-9]|[!^(){}\-_~])+)?\w$/.test(opts.sshterm) && + opts.sshterm) || appConfig.ssh.term, + terminal: { + cursorBlink: (validator.isBoolean(opts.cursorBlink + '') ? myutil.parseBool(opts.cursorBlink) : appConfig.terminal.cursorBlink), + scrollback: (validator.isInt(opts.scrollback + '', { min: 1, max: 200000 }) && opts.scrollback) ? opts.scrollback : appConfig.terminal.scrollback, + tabStopWidth: (validator.isInt(opts.tabStopWidth + '', { min: 1, max: 100 }) && opts.tabStopWidth) ? opts.tabStopWidth : appConfig.terminal.tabStopWidth, + bellStyle: ((opts.bellStyle) && (['sound', 'none'].indexOf(opts.bellStyle) > -1)) ? opts.bellStyle : appConfig.terminal.bellStyle + }, + allowreplay: appConfig.options.challengeButton || (validator.isBoolean(opts.allowreplay + '') ? myutil.parseBool(opts.allowreplay) : false), + allowreauth: appConfig.options.allowreauth || false, + mrhsession: ((validator.isAlphanumeric(opts.mrhsession + '') && opts.mrhsession) ? opts.mrhsession : 'none'), + serverlog: { + client: appConfig.serverlog.client || false, + server: appConfig.serverlog.server || false + }, + readyTimeout: (validator.isInt(opts.readyTimeout + '', { min: 1, max: 300000 }) && + opts.readyTimeout) || appConfig.ssh.readyTimeout + } + if (sshSessionConfig.header.name) validator.escape(sshSessionConfig.header.name) + if (sshSessionConfig.header.background) validator.escape(sshSessionConfig.header.background) + + return sshSessionConfig +} diff --git a/app/server/util.js b/app/server/util.js index 0ca3dca01..f2fd2387d 100644 --- a/app/server/util.js +++ b/app/server/util.js @@ -1,18 +1,16 @@ -'use strict' /* jshint esversion: 6, asi: true, node: true */ // util.js -// private require('colors') // allow for color property extensions in log messages -var debug = require('debug')('WebSSH2') -var Auth = require('basic-auth') +const debug = require('debug')('WebSSH2') +const Auth = require('basic-auth') -let defaultCredentials = {username: null, password: null, privatekey: null}; +const defaultCredentials = { username: null, password: null, privatekey: null } exports.setDefaultCredentials = function (username, password, privatekey) { - defaultCredentials.username = username - defaultCredentials.password = password - defaultCredentials.privatekey = privatekey + defaultCredentials.username = username + defaultCredentials.password = password + defaultCredentials.privatekey = privatekey } exports.basicAuth = function basicAuth (req, res, next) { @@ -24,11 +22,11 @@ exports.basicAuth = function basicAuth (req, res, next) { ' and password ' + ((myAuth.pass) ? 'exists'.yellow.bold.underline : 'is blank'.underline.red.bold)) } else { - req.session.username = defaultCredentials.username; - req.session.userpassword = defaultCredentials.password; - req.session.privatekey = defaultCredentials.privatekey; + req.session.username = defaultCredentials.username + req.session.userpassword = defaultCredentials.password + req.session.privatekey = defaultCredentials.privatekey } - if ( (!req.session.userpassword) && (!req.session.privatekey) ) { + if ((!req.session.userpassword) && (!req.session.privatekey)) { res.statusCode = 401 debug('basicAuth credential request (401)') res.setHeader('WWW-Authenticate', 'Basic realm="WebSSH"') @@ -42,3 +40,10 @@ exports.basicAuth = function basicAuth (req, res, next) { exports.parseBool = function parseBool (str) { return (str.toLowerCase() === 'true') } + +exports.uuidv4 = function uuidv4 () { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { + var r = Math.random() * 16 | 0; var v = c === 'x' ? r : (r & 0x3 | 0x8) + return v.toString(16) + }) +}