diff --git a/.eslintrc.yaml b/.eslintrc.yaml index 51e926e..0ab995c 100644 --- a/.eslintrc.yaml +++ b/.eslintrc.yaml @@ -2,9 +2,14 @@ extends: - airbnb-base - prettier - plugin:node/recommended + - plugin:jest/recommended plugins: - prettier + - jest + +env: + jest/globals: true rules: prettier/prettier: error @@ -14,4 +19,10 @@ rules: no-process-exit: off object-shorthand: off class-methods-use-this: off - semi: [2, never] \ No newline at end of file + semi: [2, never] + +overrides: + - files: + - "**/*.test.js" + env: + jest: true \ No newline at end of file diff --git a/app/config.js b/app/config.js index ccc34ab..89698d1 100644 --- a/app/config.js +++ b/app/config.js @@ -1,8 +1,12 @@ +// server +// app/config.js + const path = require("path") const fs = require("fs") const readConfig = require("read-config-ng") const Ajv = require("ajv") -const { deepMerge, generateSecureSecret } = require("./utils") +const { deepMerge, validateConfig } = require("./utils") +const { generateSecureSecret } = require("./crypto-utils") const { createNamespacedDebug } = require("./logger") const { ConfigError, handleError } = require("./errors") const { DEFAULTS } = require("./constants") @@ -67,127 +71,11 @@ const defaultConfig = { } } -/** - * Schema for validating the config - */ -const configSchema = { - type: "object", - properties: { - listen: { - type: "object", - properties: { - ip: { type: "string", format: "ipv4" }, - port: { type: "integer", minimum: 1, maximum: 65535 } - }, - required: ["ip", "port"] - }, - http: { - type: "object", - properties: { - origins: { - type: "array", - items: { type: "string" } - } - }, - required: ["origins"] - }, - user: { - type: "object", - properties: { - name: { type: ["string", "null"] }, - password: { type: ["string", "null"] } - }, - required: ["name", "password"] - }, - ssh: { - type: "object", - properties: { - host: { type: ["string", "null"] }, - port: { type: "integer", minimum: 1, maximum: 65535 }, - term: { type: "string" }, - readyTimeout: { type: "integer" }, - keepaliveInterval: { type: "integer" }, - keepaliveCountMax: { type: "integer" } - }, - required: [ - "host", - "port", - "term", - "readyTimeout", - "keepaliveInterval", - "keepaliveCountMax" - ] - }, - header: { - type: "object", - properties: { - text: { type: ["string", "null"] }, - background: { type: "string" } - }, - required: ["text", "background"] - }, - options: { - type: "object", - properties: { - challengeButton: { type: "boolean" }, - autoLog: { type: "boolean" }, - allowReauth: { type: "boolean" }, - allowReconnect: { type: "boolean" }, - allowReplay: { type: "boolean" } - }, - required: ["challengeButton", "allowReauth", "allowReplay"] - }, - algorithms: { - type: "object", - properties: { - kex: { - type: "array", - items: { type: "string" } - }, - cipher: { - type: "array", - items: { type: "string" } - }, - hmac: { - type: "array", - items: { type: "string" } - }, - compress: { - type: "array", - items: { type: "string" } - } - }, - required: ["kex", "cipher", "hmac", "compress"] - }, - session: { - type: "object", - properties: { - secret: { type: "string" }, - name: { type: "string" } - }, - required: ["secret", "name"] - } - }, - required: ["listen", "http", "user", "ssh", "header", "options", "algorithms"] -} - function getConfigPath() { const nodeRoot = path.dirname(require.main.filename) return path.join(nodeRoot, "config.json") } -function validateConfig(config) { - const ajv = new Ajv() - const validate = ajv.compile(configSchema) - const valid = validate(config) - if (!valid) { - throw new Error( - `Config validation error: ${ajv.errorsText(validate.errors)}` - ) - } - return config -} - function loadConfig() { const configPath = getConfigPath() diff --git a/app/configSchema.js b/app/configSchema.js new file mode 100644 index 0000000..78e9b89 --- /dev/null +++ b/app/configSchema.js @@ -0,0 +1,104 @@ +/** + * Schema for validating the config + */ +const configSchema = { + type: "object", + properties: { + listen: { + type: "object", + properties: { + ip: { type: "string", format: "ipv4" }, + port: { type: "integer", minimum: 1, maximum: 65535 } + }, + required: ["ip", "port"] + }, + http: { + type: "object", + properties: { + origins: { + type: "array", + items: { type: "string" } + } + }, + required: ["origins"] + }, + user: { + type: "object", + properties: { + name: { type: ["string", "null"] }, + password: { type: ["string", "null"] } + }, + required: ["name", "password"] + }, + ssh: { + type: "object", + properties: { + host: { type: ["string", "null"] }, + port: { type: "integer", minimum: 1, maximum: 65535 }, + term: { type: "string" }, + readyTimeout: { type: "integer" }, + keepaliveInterval: { type: "integer" }, + keepaliveCountMax: { type: "integer" } + }, + required: [ + "host", + "port", + "term", + "readyTimeout", + "keepaliveInterval", + "keepaliveCountMax" + ] + }, + header: { + type: "object", + properties: { + text: { type: ["string", "null"] }, + background: { type: "string" } + }, + required: ["text", "background"] + }, + options: { + type: "object", + properties: { + challengeButton: { type: "boolean" }, + autoLog: { type: "boolean" }, + allowReauth: { type: "boolean" }, + allowReconnect: { type: "boolean" }, + allowReplay: { type: "boolean" } + }, + required: ["challengeButton", "allowReauth", "allowReplay"] + }, + algorithms: { + type: "object", + properties: { + kex: { + type: "array", + items: { type: "string" } + }, + cipher: { + type: "array", + items: { type: "string" } + }, + hmac: { + type: "array", + items: { type: "string" } + }, + compress: { + type: "array", + items: { type: "string" } + } + }, + required: ["kex", "cipher", "hmac", "compress"] + }, + session: { + type: "object", + properties: { + secret: { type: "string" }, + name: { type: "string" } + }, + required: ["secret", "name"] + } + }, + required: ["listen", "http", "user", "ssh", "header", "options", "algorithms"] +} +module.exports = configSchema diff --git a/app/crypto-utils.js b/app/crypto-utils.js new file mode 100644 index 0000000..845fde6 --- /dev/null +++ b/app/crypto-utils.js @@ -0,0 +1,16 @@ +// server +// app/crypto-utils.js + +const crypto = require("crypto") + +/** + * Generates a secure random session secret + * @returns {string} A random 32-byte hex string + */ +function generateSecureSecret() { + return crypto.randomBytes(32).toString("hex") +} + +module.exports = { + generateSecureSecret +} diff --git a/app/utils.js b/app/utils.js index 4c559a2..4f74742 100644 --- a/app/utils.js +++ b/app/utils.js @@ -1,12 +1,11 @@ // server // /app/utils.js const validator = require("validator") -const crypto = require("crypto") const Ajv = require("ajv") const maskObject = require("jsmasker") const { createNamespacedDebug } = require("./logger") const { DEFAULTS, MESSAGES } = require("./constants") -const { configSchema } = require("./config") +const configSchema = require("./configSchema") const debug = createNamespacedDebug("utils") @@ -34,14 +33,6 @@ function deepMerge(target, source) { return output } -/** - * Generates a secure random session secret - * @returns {string} A random 32-byte hex string - */ -function generateSecureSecret() { - return crypto.randomBytes(32).toString("hex") -} - /** * Determines if a given host is an IP address or a hostname. * If it's a hostname, it escapes it for safety. @@ -99,7 +90,7 @@ function getValidatedPort(portInput) { * @returns {boolean} - Returns true if the credentials are valid, otherwise false. */ function isValidCredentials(creds) { - return ( + return !!( creds && typeof creds.username === "string" && typeof creds.password === "string" && @@ -189,7 +180,6 @@ function maskSensitiveData(obj, options) { module.exports = { deepMerge, - generateSecureSecret, getValidatedHost, getValidatedPort, isValidCredentials, diff --git a/package.json b/package.json index 31b74df..a437254 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,12 @@ "scripts": { "start": "node index.js", "lint": "eslint src test", - "watch": "node_modules/.bin/nodemon index.js" + "watch": "node_modules/.bin/nodemon index.js", + "test": "jest" + }, + "jest": { + "testEnvironment": "node", + "testMatch": ["**/tests/**/*.test.js"] }, "standard": { "ignore": [ @@ -62,8 +67,10 @@ "eslint-config-airbnb-base": "^13.2.0", "eslint-config-prettier": "^4.3.0", "eslint-plugin-import": "^2.18.2", + "eslint-plugin-jest": "^22.0.0", "eslint-plugin-node": "^8.0.0", "eslint-plugin-prettier": "^2.7.0", + "jest": "^23.6.0", "nodemon": "^1.12.1", "prettier": "^1.19.1", "prettier-eslint": "^8.8.2", diff --git a/tests/crypto-utils.test.js b/tests/crypto-utils.test.js new file mode 100644 index 0000000..2a5b4f5 --- /dev/null +++ b/tests/crypto-utils.test.js @@ -0,0 +1,11 @@ +// server +// tests/crypto-utils.test.js + +const { generateSecureSecret } = require("../app/crypto-utils") + +describe("generateSecureSecret", () => { + it("should generate a 64-character hex string", () => { + const secret = generateSecureSecret() + expect(secret).toMatch(/^[0-9a-f]{64}$/) + }) +}) diff --git a/tests/utils.test.js b/tests/utils.test.js new file mode 100644 index 0000000..f3fa23d --- /dev/null +++ b/tests/utils.test.js @@ -0,0 +1,173 @@ +// server +// tests/utils.test.js + +const { + deepMerge, + getValidatedHost, + getValidatedPort, + isValidCredentials, + maskSensitiveData, + modifyHtml, + validateConfig, + validateSshTerm +} = require("../app/utils") + +describe("utils", () => { + describe("deepMerge", () => { + it("should merge two objects deeply", () => { + const obj1 = { a: { b: 1 }, c: 2 } + const obj2 = { a: { d: 3 }, e: 4 } + const result = deepMerge(obj1, obj2) + expect(result).toEqual({ a: { b: 1, d: 3 }, c: 2, e: 4 }) + }) + }) + + describe("getValidatedHost", () => { + it("should return IP address unchanged", () => { + expect(getValidatedHost("192.168.1.1")).toBe("192.168.1.1") + }) + + it("should escape hostname", () => { + expect(getValidatedHost("example.com")).toBe("example.com") + expect(getValidatedHost("")).toBe( + "<script>alert('xss')</script>" + ) + }) + }) + + describe("getValidatedPort", () => { + it("should return valid port number", () => { + expect(getValidatedPort("22")).toBe(22) + expect(getValidatedPort("8080")).toBe(8080) + }) + + it("should return default port for invalid input", () => { + expect(getValidatedPort("invalid")).toBe(22) + expect(getValidatedPort("0")).toBe(22) + expect(getValidatedPort("65536")).toBe(22) + }) + }) + + describe("isValidCredentials", () => { + it("should return true for valid credentials", () => { + const validCreds = { + username: "user", + password: "pass", + host: "example.com", + port: 22 + } + expect(isValidCredentials(validCreds)).toBe(true) + }) + + it("should return false for invalid credentials", () => { + expect(isValidCredentials(null)).toBe(false) + expect(isValidCredentials({})).toBe(false) + expect(isValidCredentials({ username: "user" })).toBe(false) + }) + }) + + describe("maskSensitiveData", () => { + it("should mask sensitive data", () => { + const data = { + username: "user", + password: "secret", + token: "12345" + } + const masked = maskSensitiveData(data) + expect(masked.username).toBe("user") + expect(masked.password).not.toBe("secret") + expect(masked.token).not.toBe("12345") + }) + }) + + describe("modifyHtml", () => { + it("should modify HTML content", () => { + const html = "window.webssh2Config = null;" + const config = { key: "value" } + const content = `window.webssh2Config = ${JSON.stringify(config)};` + const modified = modifyHtml(html, config) + expect(modified).toContain('window.webssh2Config = {"key":"value"};') + }) + }) + + describe("validateConfig", () => { + it("should validate correct config", () => { + const validConfig = { + listen: { + ip: "0.0.0.0", + port: 2222 + }, + http: { + origins: ["http://localhost:8080"] + }, + user: { + name: null, + password: null, + privatekey: null + }, + ssh: { + host: null, + port: 22, + localAddress: null, + localPort: null, + term: "xterm-color", + readyTimeout: 20000, + keepaliveInterval: 120000, + keepaliveCountMax: 10, + allowedSubnets: [] + }, + header: { + text: null, + background: "green" + }, + options: { + challengeButton: true, + autoLog: false, + allowReauth: true, + allowReconnect: true, + allowReplay: 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"] + } + } + + expect(() => validateConfig(validConfig)).not.toThrow() + }) + + it("should throw error for invalid config", () => { + const invalidConfig = {} + expect(() => validateConfig(invalidConfig)).toThrow() + }) + }) + + describe("validateSshTerm", () => { + it("should return valid SSH term", () => { + expect(validateSshTerm("xterm")).toBe("xterm") + expect(validateSshTerm("xterm-256color")).toBe("xterm-256color") + }) + + it("should return null for invalid SSH term", () => { + expect(validateSshTerm("")).toBe(null) + expect(validateSshTerm("")).toBe(null) + }) + }) +})