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)
+ })
+ })
+})