Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
686034b
chore: move to esm
robertsLando Oct 7, 2025
d6ba802
fix: breaking changes
robertsLando Oct 7, 2025
4eb9420
fix: correct file references in utils and nodemon configurations
robertsLando Oct 7, 2025
7c4bb6b
chore: remove esbuild-register, use experimenta flag
robertsLando Oct 7, 2025
0cc76a9
fix: testss
robertsLando Oct 7, 2025
98ed4ba
fix: ui tests
robertsLando Oct 7, 2025
2eeb552
fix: update import for native-url and add postinstall script for patc…
robertsLando Oct 7, 2025
5851cbd
fix: update Node.js version in .nvmrc to v22.20.0
robertsLando Oct 7, 2025
19f1c0c
fix: refactor content reading and replacement for clarity
robertsLando Oct 7, 2025
66e4bf4
fix: use node 22 to test applicetion
robertsLando Oct 7, 2025
09717f9
fix: ensure logs are always outputted and backend status is checked
robertsLando Oct 7, 2025
6bc3f10
fix: fake stick
robertsLando Oct 7, 2025
2baf010
fix: use production code in test application
robertsLando Oct 7, 2025
a303fec
Merge branch 'master' of https://github.com/zwave-js/zwave-js-ui into…
robertsLando Oct 7, 2025
5a7b39b
fix: update backend process check to use 'npm start'
robertsLando Oct 7, 2025
69d5076
fix: circular reference
robertsLando Oct 7, 2025
7d16b86
Merge branch 'master' into move-to-esm
robertsLando Oct 8, 2025
fdd8d5d
refactor: update imports to use 'node:' prefix for built-in modules
robertsLando Oct 8, 2025
b4ca151
fix: restore esbuild meta url shim
robertsLando Oct 8, 2025
d7f93fd
fix: pkg build
robertsLando Oct 8, 2025
265c958
Merge branch 'master' into move-to-esm
robertsLando Oct 8, 2025
2cb4dbf
feat: use backend code in frontend
robertsLando Oct 8, 2025
cd619fa
feat: update zwave-js to version 15.15.1 and fix generateDocs script …
robertsLando Oct 10, 2025
0ecd12c
fix: change _getFile method visibility from public to private
robertsLando Oct 10, 2025
fe3ee83
refactor: replace fs-extra with native fs/promises methods and update…
robertsLando Oct 10, 2025
90398fb
fix path resolution on Windows
AlCalzone Oct 10, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions .github/workflows/test-application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v5
with:
node-version: '20'
node-version: '22'
cache: 'npm'

- name: Install dependencies
Expand Down Expand Up @@ -81,7 +81,8 @@ jobs:

- name: Start backend
run: |
nohup npm run server > server.log 2>&1 &
npm run build:server
nohup npm start > server.log 2>&1 &
sleep 25

- name: Test MQTT write and read
Expand All @@ -91,17 +92,20 @@ jobs:
docker exec broker mosquitto_sub -h localhost -p 1883 -t "zwave/nodeID_2/106/0/currentValue/3" -C 1

- name: Output backend logs
if: always()
run: |
sleep 5
cat server.log

- name: Output broker logs
if: always()
run: |
docker logs broker

- name: Ensure backend is running
if: always()
run: |
if ! pgrep -f "npm run server" > /dev/null; then
if ! pgrep -f "npm start" > /dev/null; then
echo "Backend crashed!" && exit 1
fi
echo "Backend is running successfully."
Expand Down
2 changes: 0 additions & 2 deletions .mocharc.yml
Original file line number Diff line number Diff line change
@@ -1,3 +1 @@
recursive: true
watch-files:
- 'test/**/*.ts'
2 changes: 1 addition & 1 deletion .nvmrc
Original file line number Diff line number Diff line change
@@ -1 +1 @@
v20.19.4
v22.20.0
2 changes: 1 addition & 1 deletion .prettierrc.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
module.exports = {
export default {
semi: false,
singleQuote: true,
useTabs: true,
Expand Down
96 changes: 54 additions & 42 deletions api/app.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,33 @@
import express, { Request, RequestHandler, Response, Router } from 'express'
import type { Request, RequestHandler, Response, Router } from 'express'
import express from 'express'
import history from 'connect-history-api-fallback'
import cors from 'cors'
import csrf from 'csurf'
import morgan from 'morgan'
import store, { Settings, User } from './config/store'
import Gateway, { GatewayConfig, GatewayType } from './lib/Gateway'
import jsonStore from './lib/jsonStore'
import * as loggers from './lib/logger'
import MqttClient from './lib/MqttClient'
import SocketManager from './lib/SocketManager'
import ZWaveClient, { CallAPIResult, SensorTypeScale } from './lib/ZwaveClient'
import type { Settings, User } from './config/store.ts'
import store from './config/store.ts'
import type { GatewayConfig } from './lib/Gateway.ts'
import Gateway, { GatewayType } from './lib/Gateway.ts'
import jsonStore from './lib/jsonStore.ts'
import * as loggers from './lib/logger.ts'
import MqttClient from './lib/MqttClient.ts'
import SocketManager from './lib/SocketManager.ts'
import type { CallAPIResult, SensorTypeScale } from './lib/ZwaveClient.ts'
import ZWaveClient from './lib/ZwaveClient.ts'
import multer, { diskStorage } from 'multer'
import extract from 'extract-zip'
import { serverVersion } from '@zwave-js/server'
import archiver from 'archiver'
import rateLimit from 'express-rate-limit'
import session from 'express-session'
import fs, { mkdirp, move, readdir, rm, stat } from 'fs-extra'
import { createServer as createHttpServer, Server as HttpServer } from 'http'
import { createServer as createHttpsServer } from 'https'
import type { Server as HttpServer } from 'node:http'
import { createServer as createHttpServer } from 'node:http'
import { createServer as createHttpsServer } from 'node:https'
import jwt from 'jsonwebtoken'
import path from 'path'
import path from 'node:path'
import sessionStore from 'session-file-store'
import { Socket } from 'socket.io'
import { promisify } from 'util'
import type { Socket } from 'socket.io'
import { promisify } from 'node:util'
import { Driver, libVersion } from 'zwave-js'
import {
defaultPsw,
Expand All @@ -32,18 +36,26 @@
snippetsDir,
storeDir,
tmpDir,
} from './config/app'
} from './config/app.ts'
import type { CustomPlugin, PluginConstructor } from './lib/CustomPlugin.ts'
import { createPlugin } from './lib/CustomPlugin.ts'
import { inboundEvents, socketEvents } from './lib/SocketEvents.ts'
import * as utils from './lib/utils.ts'
import backupManager from './lib/BackupManager.ts'
import {
createPlugin,
CustomPlugin,
PluginConstructor,
} from './lib/CustomPlugin'
import { inboundEvents, socketEvents } from './lib/SocketEvents'
import * as utils from './lib/utils'
import backupManager from './lib/BackupManager'
import { readFile, realpath } from 'fs/promises'
readFile,
realpath,
readdir,
stat,
rm,
rename,
writeFile,
lstat,
mkdir,
} from 'node:fs/promises'
import { generate } from 'selfsigned'
import ZnifferManager, { ZnifferConfig } from './lib/ZnifferManager'
import type { ZnifferConfig } from './lib/ZnifferManager.ts'
import ZnifferManager from './lib/ZnifferManager.ts'
import { getAllNamedScaleGroups, getAllSensors } from '@zwave-js/core'

const createCertificate = promisify(generate)
Expand Down Expand Up @@ -72,7 +84,7 @@

const Storage = diskStorage({
async destination(reqD, file, callback) {
await mkdirp(tmpDir)
await utils.ensureDir(tmpDir)
callback(null, tmpDir)
},
filename(reqF, file, callback) {
Expand Down Expand Up @@ -262,7 +274,7 @@

async function loadSnippets() {
const localSnippetsDir = utils.joinPath(false, 'snippets')
await mkdirp(snippetsDir)
await utils.ensureDir(snippetsDir)

const files = await readdir(localSnippetsDir)
for (const file of files) {
Expand Down Expand Up @@ -329,8 +341,8 @@
let cert: string

try {
cert = await fs.readFile(certFile, 'utf8')
key = await fs.readFile(keyFile, 'utf8')
cert = await readFile(certFile, 'utf8')
key = await readFile(keyFile, 'utf8')
} catch (error) {
// noop
}
Expand All @@ -349,8 +361,8 @@
key = result.private
cert = result.cert

await fs.writeFile(utils.joinPath(storeDir, 'key.pem'), key)
await fs.writeFile(utils.joinPath(storeDir, 'cert.pem'), cert)
await writeFile(utils.joinPath(storeDir, 'key.pem'), key)
await writeFile(utils.joinPath(storeDir, 'cert.pem'), cert)
logger.info('New cert and key created')
} catch (error) {
logger.error('Error creating cert and key for HTTPS', error)
Expand Down Expand Up @@ -447,14 +459,14 @@

async function parseDir(dir: string): Promise<StoreFileEntry[]> {
const toReturn = []
const files = await fs.readdir(dir)
const files = await readdir(dir)
for (const file of files) {
try {
const entry: StoreFileEntry = {
name: path.basename(file),
path: utils.joinPath(dir, file),
}
const stats = await fs.lstat(entry.path)
const stats = await lstat(entry.path)
if (stats.isDirectory()) {
if (entry.path === process.env.ZWAVEJS_EXTERNAL_CONFIG) {
// hide config-db
Expand Down Expand Up @@ -1367,18 +1379,18 @@
if (req.query.path) {
const reqPath = getSafePath(req)
// lgtm [js/path-injection]
let stat = await fs.lstat(reqPath)
let stat = await lstat(reqPath)

// check symlink is secure
if (stat.isSymbolicLink()) {
const realPath = await realpath(reqPath)
getSafePath(realPath)
stat = await fs.lstat(realPath)
stat = await lstat(realPath)
}

if (stat.isFile()) {
// lgtm [js/path-injection]
data = await fs.readFile(reqPath, 'utf8')
data = await readFile(reqPath, 'utf8')
} else {
// read directory
// lgtm [js/path-injection]
Expand Down Expand Up @@ -1411,7 +1423,7 @@

if (!isNew) {
// lgtm [js/path-injection]
const stat = await fs.lstat(reqPath)
const stat = await lstat(reqPath)

if (!stat.isFile()) {
throw Error('Path is not a file')
Expand All @@ -1420,10 +1432,10 @@

if (!isDirectory) {
// lgtm [js/path-injection]
await fs.writeFile(reqPath, req.body.content, 'utf8')
await writeFile(reqPath, req.body.content, 'utf8')

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High

This path depends on a
user-provided value
.

Copilot Autofix

AI about 1 month ago

The best way to fix this problem is to ensure that all user-supplied paths are resolved and checked that, after normalization and resolution (including symlinks), they remain strictly inside the intended root directory (storeDir). Specifically:

  • In getSafePath, after normalizing the user input and joining with storeDir, use path.resolve to create an absolute path.
  • Use fs.promises.realpath (aliased to existing imported realpath) to resolve symlinks and get the true absolute path.
  • After resolution, strictly check that the resolved path starts with storeDir (which should also be resolved to absolute) and is not equal to storeDir. Throw an error if the path escapes.
  • Return the resolved safe path.

To implement this:

  • Modify getSafePath to be async and operate as described above.
  • Update any code calling getSafePath to await its result.
  • Ensure imports include realpath from node:fs/promises (already present).
  • Only use context shown: all edits in api/app.ts.
Suggested changeset 1
api/app.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/api/app.ts b/api/app.ts
--- a/api/app.ts
+++ b/api/app.ts
@@ -313,20 +313,22 @@
 /**
  * Get the `path` param from a request. Throws if the path is not safe
  */
-function getSafePath(req: Request | string) {
+async function getSafePath(req: Request | string) {
 	let reqPath = typeof req === 'string' ? req : req.query.path
 
 	if (typeof reqPath !== 'string') {
 		throw Error('Invalid path')
 	}
 
-	reqPath = path.normalize(reqPath)
+	const rootDir = path.resolve(storeDir)
+	const absPath = path.resolve(rootDir, reqPath)
+	const realAbsPath = await realpath(absPath)
 
-	if (!reqPath.startsWith(storeDir) || reqPath === storeDir) {
+	if (!realAbsPath.startsWith(rootDir + path.sep) || realAbsPath === rootDir) {
 		throw Error('Path not allowed')
 	}
 
-	return reqPath
+	return realAbsPath
 }
 
 async function loadCertKey(): Promise<{
@@ -1416,7 +1408,7 @@
 
 app.put('/api/store', storeLimiter, isAuthenticated, async function (req, res) {
 	try {
-		const reqPath = getSafePath(req)
+		const reqPath = await getSafePath(req)
 
 		const isNew = req.query.isNew === 'true'
 		const isDirectory = req.query.isDirectory === 'true'
EOF
@@ -313,20 +313,22 @@
/**
* Get the `path` param from a request. Throws if the path is not safe
*/
function getSafePath(req: Request | string) {
async function getSafePath(req: Request | string) {
let reqPath = typeof req === 'string' ? req : req.query.path

if (typeof reqPath !== 'string') {
throw Error('Invalid path')
}

reqPath = path.normalize(reqPath)
const rootDir = path.resolve(storeDir)
const absPath = path.resolve(rootDir, reqPath)
const realAbsPath = await realpath(absPath)

if (!reqPath.startsWith(storeDir) || reqPath === storeDir) {
if (!realAbsPath.startsWith(rootDir + path.sep) || realAbsPath === rootDir) {
throw Error('Path not allowed')
}

return reqPath
return realAbsPath
}

async function loadCertKey(): Promise<{
@@ -1416,7 +1408,7 @@

app.put('/api/store', storeLimiter, isAuthenticated, async function (req, res) {
try {
const reqPath = getSafePath(req)
const reqPath = await getSafePath(req)

const isNew = req.query.isNew === 'true'
const isDirectory = req.query.isDirectory === 'true'
Copilot is powered by AI and may make mistakes. Always verify output.
} else {
// lgtm [js/path-injection]
await fs.mkdir(reqPath)
await mkdir(reqPath)
}

res.json({ success: true })
Expand All @@ -1442,7 +1454,7 @@
const reqPath = getSafePath(req)

// lgtm [js/path-injection]
await fs.remove(reqPath)
await rm(reqPath, { recursive: true, force: true })

res.json({ success: true })
} catch (error) {
Expand All @@ -1460,7 +1472,7 @@
try {
const files = req.body.files || []
for (const f of files) {
await fs.remove(f)
await rm(f, { recursive: true, force: true })
}
res.json({ success: true })
} catch (error) {
Expand Down Expand Up @@ -1498,7 +1510,7 @@
archive.pipe(res)

for (const f of files) {
const s = await fs.lstat(f)
const s = await lstat(f)
const name = f.replace(storeDir, '')
if (s.isFile()) {
archive.file(f, { name })
Expand Down Expand Up @@ -1559,7 +1571,7 @@
const destinationPath = getSafePath(
path.join(storeDir, folder, file.originalname),
)
await move(file.path, destinationPath)
await rename(file.path, destinationPath)
}

res.json({ success: true })
Expand Down
8 changes: 4 additions & 4 deletions api/bin/www.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@
/**
* Module dependencies.
*/
import jsonStore from '../lib/jsonStore'
import store from '../config/store'
import * as conf from '../config/app'
import app, { startServer } from '../app'
import jsonStore from '../lib/jsonStore.ts'
import store from '../config/store.ts'
import * as conf from '../config/app.ts'
import app, { startServer } from '../app.ts'

console.log(
` ______ __ __ _ _____ _ _ _____ \n |___ / \\ \\ / / | |/ ____| | | | |_ _|\n / /____\\ \\ /\\ / /_ ___ _____ | | (___ | | | | | | \n / /______\\ \\/ \\/ / _\' \\ \\ / / _ \\ _ | |\\___ \\ | | | | | | \n / /__ \\ /\\ / (_| |\\ V / __/ | |__| |____) | | |__| |_| |_ \n /_____| \\/ \\/ \\__,_| \\_/ \\___| \\____/|_____/ \\____/|_____|\n`,
Expand Down
2 changes: 1 addition & 1 deletion api/config/app.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { joinPath } from '../lib/utils'
import { joinPath } from '../lib/utils.ts'
import { config } from 'dotenv'

config({ path: './.env.app' })
Expand Down
9 changes: 5 additions & 4 deletions api/config/store.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
// config/store.js

import { GatewayConfig } from '../lib/Gateway'
import { MqttConfig } from '../lib/MqttClient'
import { ZnifferConfig } from '../lib/ZnifferManager'
import { ZwaveConfig, deviceConfigPriorityDir } from '../lib/ZwaveClient'
import type { GatewayConfig } from '../lib/Gateway.ts'
import type { MqttConfig } from '../lib/MqttClient.ts'
import type { ZnifferConfig } from '../lib/ZnifferManager.ts'
import type { ZwaveConfig } from '../lib/ZwaveClient.ts'
import { deviceConfigPriorityDir } from '../lib/Constants.ts'

export type StoreKeys = 'settings' | 'scenes' | 'nodes' | 'users'

Expand Down
2 changes: 1 addition & 1 deletion api/hass/configurations.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// List of Home-Assistant configuration for MQTT Discovery
// https://www.home-assistant.io/docs/mqtt/discovery/

import { HassDevice } from '../lib/ZwaveClient'
import type { HassDevice } from '../lib/ZwaveClient.ts'

type HassDeviceKey =
| 'binary_sensor'
Expand Down
2 changes: 1 addition & 1 deletion api/hass/devices.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Place here repeated patterns

import { HassDevice } from '../lib/ZwaveClient'
import type { HassDevice } from '../lib/ZwaveClient.ts'

const FAN_DIMMER: HassDevice = {
type: 'fan',
Expand Down
14 changes: 7 additions & 7 deletions api/lib/BackupManager.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import store from '../config/store'
import { module } from './logger'
import jsonStore, { STORE_BACKUP_PREFIX } from './jsonStore'
import store from '../config/store.ts'
import { module } from './logger.ts'
import jsonStore, { STORE_BACKUP_PREFIX } from './jsonStore.ts'
import Cron from 'croner'
import { readdir, unlink } from 'fs/promises'
import { nvmBackupsDir, storeBackupsDir } from '../config/app'
import { joinPath } from './utils'
import type ZwaveClient from './ZwaveClient'
import { readdir, unlink } from 'node:fs/promises'
import { nvmBackupsDir, storeBackupsDir } from '../config/app.ts'
import { joinPath } from './utils.ts'
import type ZwaveClient from './ZwaveClient.ts'

export const NVM_BACKUP_PREFIX = 'NVM_'

Expand Down
4 changes: 4 additions & 0 deletions api/lib/Constants.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { getMeter, getMeterScale } from '@zwave-js/core'
import { join } from 'node:path'
import { storeDir } from '../config/app.ts'

interface IGenericMap {
[key: number]: string
Expand Down Expand Up @@ -43,6 +45,8 @@ export interface IMeterCCSpecific {
meterType: number
}

export const deviceConfigPriorityDir = join(storeDir, 'config')

// https://github.com/OpenZWave/open-zwave/blob/0d94c9427bbd19e47457578bccc60b16c6679b49/config/Localization.xml#L606
const _productionMap: IGenericMap = {
0: 'instant',
Expand Down
8 changes: 4 additions & 4 deletions api/lib/CustomPlugin.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Router } from 'express'
import MqttClient from './MqttClient'
import { ModuleLogger } from './logger'
import ZwaveClient from './ZwaveClient'
import type { Router } from 'express'
import type MqttClient from './MqttClient.ts'
import type { ModuleLogger } from './logger.ts'
import type ZwaveClient from './ZwaveClient.ts'

export interface PluginContext {
zwave: ZwaveClient
Expand Down
4 changes: 2 additions & 2 deletions api/lib/EventEmitter.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import EventEmitter from 'events'
import { applyMixin } from './utils'
import EventEmitter from 'node:events'
import { applyMixin } from './utils.ts'

/**
* A type-safe EventEmitter interface to use in place of Node.js's EventEmitter.
Expand Down
Loading
Loading