From 37017697a0f2b93a27f5b3c4037542fedcf641c9 Mon Sep 17 00:00:00 2001 From: anupamme Date: Sat, 29 Aug 2026 15:41:40 +0000 Subject: [PATCH 1/8] fix: V-001 security vulnerability Automated security fix generated by OrbisAI Security --- housepanel-push/housepanel-push.js | 32 ++++++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/housepanel-push/housepanel-push.js b/housepanel-push/housepanel-push.js index bf1ff54..583d726 100644 --- a/housepanel-push/housepanel-push.js +++ b/housepanel-push/housepanel-push.js @@ -171,9 +171,29 @@ function updateElements() { } } +// require a shared secret (configured as config.pushToken in hmoptions.cfg) +// on every request so remote attackers cannot send or read push traffic +// without credentials. Fails closed if no token has been configured. +function checkPushAuth(req, res) { + var token = config && config.pushToken; + if ( !token ) { + console.log((new Date()) + " housepanel-push: pushToken not configured in hmoptions.cfg; rejecting unauthenticated request."); + res.status(503).json('housepanel-push is not configured with a pushToken; request rejected'); + return false; + } + var provided = req.headers['x-push-token'] || req.query.token || (req.body && req.body.pushToken); + if ( provided !== token ) { + console.log((new Date()) + " housepanel-push: rejected unauthorized request from " + req.ip); + res.status(401).json('unauthorized'); + return false; + } + return true; +} + // a callback function to give status info if they point a browser here if ( app ) { app.get("/", function (req, res) { + if ( !checkPushAuth(req, res) ) { return; } var str = "

This is housepanel-push used to forward state from hubs to HousePanel dashboards. " + "To use this you must install housepanel-push as a service on some server.
" + @@ -192,6 +212,7 @@ if ( app ) { // handler for messages posted from the hub if ( app ) { app.post("/", function (req, res) { + if ( !checkPushAuth(req, res) ) { return; } // handle two types of messages posted from hub // the first initialize type tells Node.js to update elements @@ -208,17 +229,20 @@ if ( app ) { for (var num= 0; num< elements.length; num++) { var entry = elements[num]; + var changeAttr = req.body['change_attribute']; if ( entry.id == req.body['change_device'].toString() && - req.body['change_attribute']!='trackData' && + changeAttr!='trackData' && + typeof changeAttr === 'string' && + Object.prototype.hasOwnProperty.call(entry.value || {}, changeAttr) && entry.value && typeof entry.value === 'object' && - entry['value'][req.body['change_attribute']] != req.body['change_value'] ) + Reflect.get(entry.value, changeAttr) != req.body['change_value'] ) { cnt = cnt + 1; // console.log(entry['value']); - entry['value'][req.body['change_attribute']] = req.body['change_value']; + Reflect.set(entry.value, changeAttr, req.body['change_value']); if ( entry['value']['trackData'] ) { delete entry['value']['trackData']; } console.log((new Date()) + 'updating tile #',entry['id'],' from trigger:', - req.body['change_attribute'],' to ', clients.length,' hosts. value= ', JSON.stringify(entry['value']) ); + changeAttr,' to ', clients.length,' hosts. value= ', JSON.stringify(entry['value']) ); // send the updated element to all clients // this is processed by the webSockets client in housepanel.js From 5a9b466648f3592319d658a2749768651ea05def Mon Sep 17 00:00:00 2001 From: Anupam Mediratta Date: Thu, 3 Sep 2026 07:06:06 +0530 Subject: [PATCH 2/8] fix(housepanel-push): wire a real pushToken through config/hub auth commit 3701769 gated housepanel-push on config.pushToken, but that field was never wired into hmoptions.cfg or the SmartThings/Hubitat SmartApp, so every install 503'd and real hub pushes had no way to authenticate. This wires up a working credential end to end: - housepanel.php generates and persists a random pushToken into hmoptions.cfg the first time the Options page loads, and displays it (read-only, click-to-copy) for the admin to paste into the hub. - HousePanel.groovy gains a Push Token setting and sends it as Authorization: Bearer from postHub(). - housepanel-push.js's checkPushAuth() now accepts only Authorization: Bearer , compared in constant time, dropping the X-Push-Token/?token=/body.pushToken alternatives (query-string secrets leak into access logs). GET / is public again (status page) but no longer leaks connected clients' remote IPs; auth is only required on the state-changing POST /. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Af1cZtsa1MZFx66H34YFLT --- HousePanel.groovy | 17 ++++++++++----- housepanel-push/housepanel-push.js | 33 +++++++++++++++++------------- housepanel.php | 24 +++++++++++++++++----- 3 files changed, 50 insertions(+), 24 deletions(-) diff --git a/HousePanel.groovy b/HousePanel.groovy index c980fb5..d3f543d 100644 --- a/HousePanel.groovy +++ b/HousePanel.groovy @@ -107,6 +107,7 @@ preferences { paragraph "Specify these parameters to enable direct and instant hub pushes when things change in your home." input "webSocketHost", "text", title: "Host IP", defaultValue: "192.168.11.20", required: false input "webSocketPort", "text", title: "Port", defaultValue: "19234", required: false + input "pushToken", "text", title: "Push Token (copy from HousePanel Options page)", required: false } section("Lights and Switches") { input "myswitches", "capability.switch", multiple: true, required: false, title: "Switches" @@ -197,6 +198,7 @@ def initialize() { state.usepistons = settings?.usepistons ?: false state.directIP = settings?.webSocketHost ?: "" state.directPort = settings?.webSocketPort ?: "19234" + state.pushToken = settings?.pushToken ?: "" state.tz = settings?.timezone ?: "America/Detroit" state.prefix = settings?.hubprefix ?: getPrefix() state.dateFormat = settings?.dateformat ?: "M/dd h:mm" @@ -2383,14 +2385,19 @@ def postHub(msgtype, name, id, attr, value) { // Send Using the Direct Mechanism logger("Sending ${msgtype} to Websocket at ${state.directIP}:${state.directPort}", "info") - // set a hub action - include the access token so we know which hub this is + // set a hub action - include the push token so housepanel-push can + // authenticate this request as coming from an authorized hub + def pushHeaders = [ + HOST: "${state.directIP}:${state.directPort}", + 'Content-Type': 'application/json' + ] + if ( state?.pushToken ) { + pushHeaders['Authorization'] = "Bearer ${state.pushToken}" + } def params = [ method: "POST", path: "/", - headers: [ - HOST: "${state.directIP}:${state.directPort}", - 'Content-Type': 'application/json' - ], + headers: pushHeaders, body: [ msgtype: msgtype, change_name: name, diff --git a/housepanel-push/housepanel-push.js b/housepanel-push/housepanel-push.js index 583d726..fa60e40 100644 --- a/housepanel-push/housepanel-push.js +++ b/housepanel-push/housepanel-push.js @@ -5,6 +5,7 @@ process.title = 'housepanel-push'; var webSocketServer = require('websocket').server; var http = require('http'); var fs = require('fs'); +var crypto = require('crypto'); // list of currently connected clients (users) var clients = [ ]; @@ -171,9 +172,12 @@ function updateElements() { } } -// require a shared secret (configured as config.pushToken in hmoptions.cfg) -// on every request so remote attackers cannot send or read push traffic -// without credentials. Fails closed if no token has been configured. +// require a shared secret (configured as config.pushToken in hmoptions.cfg, +// generated and displayed by the HousePanel Options page) on state-changing +// requests so remote attackers cannot inject fake hub push traffic. Fails +// closed if no token has been configured yet. Only Authorization: Bearer +// is accepted -- no header/query/body alternatives, since those can +// leak into access logs. function checkPushAuth(req, res) { var token = config && config.pushToken; if ( !token ) { @@ -181,8 +185,15 @@ function checkPushAuth(req, res) { res.status(503).json('housepanel-push is not configured with a pushToken; request rejected'); return false; } - var provided = req.headers['x-push-token'] || req.query.token || (req.body && req.body.pushToken); - if ( provided !== token ) { + var auth = req.get('Authorization') || ''; + var match = auth.match(/^Bearer\s+(.+)$/i); + var provided = match ? match[1] : null; + var providedBuf = Buffer.from(provided || ''); + var tokenBuf = Buffer.from(token); + var authorized = !!provided && + providedBuf.length === tokenBuf.length && + crypto.timingSafeEqual(providedBuf, tokenBuf); + if ( !authorized ) { console.log((new Date()) + " housepanel-push: rejected unauthorized request from " + req.ip); res.status(401).json('unauthorized'); return false; @@ -190,20 +201,14 @@ function checkPushAuth(req, res) { return true; } -// a callback function to give status info if they point a browser here +// a callback function to give status info if they point a browser here. +// this is a public status page (no credentials required) so it only +// reports a client count, never per-client host/IP details. if ( app ) { app.get("/", function (req, res) { - if ( !checkPushAuth(req, res) ) { return; } - var str = "

This is housepanel-push used to forward state from hubs to HousePanel dashboards. " + "To use this you must install housepanel-push as a service on some server.
" + "Currently connected to " + clients.length + " clients.

"; - str = str + "


"; - - for (var i=0; i < clients.length; i++) { - str = str + "Client #" + i + " host= " + clients[i].socket.remoteAddress.substring(7) + "
"; - // str = str + "Client #" + i + " host= " + clients[i].origin + "
"; - } res.send(str); console.log((new Date()) + "GET request. Currently connected to " + clients.length + " clients. " ); }); diff --git a/housepanel.php b/housepanel.php index 10a775a..ad67a34 100644 --- a/housepanel.php +++ b/housepanel.php @@ -712,7 +712,7 @@ function getEndpoint($access_token, $stweb, $clientId, $hubType) { // this used to create input blocks for auth page // it was modified for use now on the options page -function tsk($timezone, $skin, $uname, $port, $webSocketServerPort, $fast_timer, $slow_timer) { +function tsk($timezone, $skin, $uname, $port, $webSocketServerPort, $fast_timer, $slow_timer, $pushToken = "") { $tc= ""; $tc.= "
"; @@ -725,7 +725,11 @@ function tsk($timezone, $skin, $uname, $port, $webSocketServerPort, $fast_timer, $tc.= "
"; $tc.= "
"; - $tc.= "
"; + $tc.= ""; + + $tc.= "
"; + $tc.= "
"; + $tc.= "
copy this into the Push Token setting of your SmartThings/Hubitat SmartApp so hub pushes authenticate
"; $tc.= "
"; $tc.= "
"; @@ -3428,6 +3432,16 @@ function getOptionsPage($options, $retpage, $allthings) { if ( !array_key_exists("port", $configoptions) ) { $configoptions = setDefaultOptions($configoptions); } + + // housepanel-push authenticates hub POSTs with this shared secret. + // generate one on first visit to this page so every install gets a + // token automatically instead of requiring manual hmoptions.cfg edits. + if ( !array_key_exists("pushToken", $configoptions) || !$configoptions["pushToken"] ) { + $configoptions["pushToken"] = bin2hex(random_bytes(32)); + $options["config"] = $configoptions; + writeOptions($options); + } + $pushToken = $configoptions["pushToken"]; $port = $configoptions["port"]; $webSocketServerPort = $configoptions["webSocketServerPort"]; $fast_timer = $configoptions["fast_timer"]; @@ -3451,11 +3465,11 @@ function getOptionsPage($options, $retpage, $allthings) { // $tc.= "
Skin directory name: "; $tc.= "
"; - $tc.= tsk($timezone, $skin, $uname, $port, $webSocketServerPort, $fast_timer, $slow_timer); + $tc.= tsk($timezone, $skin, $uname, $port, $webSocketServerPort, $fast_timer, $slow_timer, $pushToken); $tc.= "
"; - + $tc.= "
"; - $tc.= ""; + $tc.= ""; $kstr = ($kioskoptions===true || $kioskoptions==="true" || $kioskoptions==="1" || $kioskoptions==="yes") ? "checked" : ""; $tc.= ""; From 5c0453fdcc62c0d413a3f38d4cd8171bda652f89 Mon Sep 17 00:00:00 2001 From: Anupam Mediratta Date: Thu, 3 Sep 2026 07:06:11 +0530 Subject: [PATCH 3/8] test(housepanel-push): add regression tests for push token auth Covers the checkPushAuth() contract that the previous commit implemented: no token configured (503), missing/malformed Authorization header (401), wrong bearer token (401), and a correct bearer token (authorized) -- replacing the PR's stated but never-implemented Bearer-token test claims with tests that actually match the code. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Af1cZtsa1MZFx66H34YFLT --- housepanel-push/housepanel-push.smoke.js | 49 ++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/housepanel-push/housepanel-push.smoke.js b/housepanel-push/housepanel-push.smoke.js index 9ea29a9..90470b3 100644 --- a/housepanel-push/housepanel-push.smoke.js +++ b/housepanel-push/housepanel-push.smoke.js @@ -8,6 +8,7 @@ // 4. healthy path still parses, indexes hubs correctly, and pushes items const assert = require("assert"); +const crypto = require("crypto"); // Mirrors the callback body now in housepanel-push.js updateElements() function makeCallback(hubs, elements, logs) { @@ -212,4 +213,52 @@ const hubs = [ assert.strictEqual(threw, false, "update handler must not throw on non-object value"); } +// Mirrors checkPushAuth() in housepanel-push.js: only Authorization: Bearer +// is accepted, compared in constant time. Returns the HTTP status +// that would be sent (200 = authorized, 401 = unauthorized, 503 = no token +// configured), same statuses the real handler returns. +function mockCheckPushAuth(configuredToken, authHeader) { + var token = configuredToken; + if ( !token ) { return 503; } + var auth = authHeader || ''; + var match = auth.match(/^Bearer\s+(.+)$/i); + var provided = match ? match[1] : null; + var providedBuf = Buffer.from(provided || ''); + var tokenBuf = Buffer.from(token); + var authorized = !!provided && + providedBuf.length === tokenBuf.length && + crypto.timingSafeEqual(providedBuf, tokenBuf); + return authorized ? 200 : 401; +} + +const REAL_TOKEN = "s3cr3t-push-token"; + +// 7. no pushToken configured at all -> 503 regardless of what's sent +{ + assert.strictEqual(mockCheckPushAuth(null, "Bearer anything"), 503, "unconfigured token must 503"); + assert.strictEqual(mockCheckPushAuth(null, undefined), 503, "unconfigured token must 503 with no header"); +} + +// 7b. configured token, no Authorization header -> 401 +{ + assert.strictEqual(mockCheckPushAuth(REAL_TOKEN, undefined), 401, "missing Authorization must 401"); +} + +// 7c. configured token, malformed Authorization scheme -> 401 +{ + assert.strictEqual(mockCheckPushAuth(REAL_TOKEN, "Token " + REAL_TOKEN), 401, "non-Bearer scheme must 401"); + assert.strictEqual(mockCheckPushAuth(REAL_TOKEN, REAL_TOKEN), 401, "raw token with no scheme must 401"); +} + +// 7d. configured token, wrong bearer token -> 401 +{ + assert.strictEqual(mockCheckPushAuth(REAL_TOKEN, "Bearer wrong-token"), 401, "wrong bearer token must 401"); +} + +// 7e. configured token, correct bearer token -> 200 (authorized) +{ + assert.strictEqual(mockCheckPushAuth(REAL_TOKEN, "Bearer " + REAL_TOKEN), 200, "correct bearer token must authorize"); + assert.strictEqual(mockCheckPushAuth(REAL_TOKEN, "bearer " + REAL_TOKEN), 200, "scheme match must be case-insensitive"); +} + console.log("ALL BEHAVIORAL ASSERTIONS PASSED"); From d9ba3aa5a5e2a8624cbcb42c960bbdb67a4cae96 Mon Sep 17 00:00:00 2001 From: Anupam Mediratta Date: Thu, 3 Sep 2026 07:06:16 +0530 Subject: [PATCH 4/8] docs: document housepanel-push token authentication Explains the upgrade step for existing installs: open Options once to generate a Push Token, copy it into the SmartApp's new Push Token setting, and what to expect (401/503) until that's done. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Af1cZtsa1MZFx66H34YFLT --- docs/index.html | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/index.html b/docs/index.html index a4eb8bf..da7205c 100644 --- a/docs/index.html +++ b/docs/index.html @@ -588,6 +588,19 @@

Hub Push Node.js Installation

completely robust so it might fail, in which case the details above should help. Over time I will continue improving the install.sh script to make it more robust and foolproof. +

+ +

Push Authentication

+housepanel-push requires every state-changing hub POST to include a shared secret, +so a remote attacker cannot forge push traffic. Open the HousePanel Options page in +your browser once after upgrading; a Push Token field will be generated automatically +and shown there (click it to select and copy). Paste that value into the new +"Push Token" setting in your SmartThings/Hubitat HousePanel SmartApp and save it — +the SmartApp will then send it as an Authorization: Bearer <token> +header on every push. Until you do this, hub pushes will be rejected with 401 +Unauthorized (or 503 if the Options page has never been opened on this install, since +no token has been generated yet). The status page at GET / does not require +this token.

Set Server Permissions

From e90a7daa06632f631deee597516da373dc57fb54 Mon Sep 17 00:00:00 2001 From: Anupam Mediratta Date: Thu, 3 Sep 2026 09:48:15 +0530 Subject: [PATCH 5/8] fix(housepanel-push): reload push token from disk so running services self-heal checkPushAuth() read config.pushToken, but config is only populated by updateElements(), which runs at startup, on a websocket message, or on the "initialize" POST -- and that POST is itself behind checkPushAuth(). A service that started before the Options page generated a token could therefore never learn about it: the one hub message designed to refresh config was gated by the very check that needed refreshing, so every push returned 503 until Node was restarted. Add getPushToken(), which reads pushToken straight from hmoptions.cfg and re-reads only when the file's mtime changes. An already-running service now accepts authenticated pushes on the next request after the token is written, and picks up a rotated token the same way, with no restart. It deliberately does not touch config/hubs/elements or issue hub requests, so an unauthenticated caller cannot trigger any work. Also in support of testing the real handler rather than a copy: - extract the hmoptions.cfg search into locateOptionsFile(), shared with updateElements() instead of duplicating the four-path fallback - only call updateElements() under require.main === module, and export the auth functions, so requiring this file does not bind ports - tolerate a missing websocket module at load time, matching how the existing try block already degrades when express is unavailable - stop dereferencing a null config in the port-not-valid log lines, reachable when hmoptions.cfg exists but fails to parse Co-Authored-By: Claude Opus 5 --- housepanel-push/housepanel-push.js | 112 +++++++++++++++++++++-------- 1 file changed, 84 insertions(+), 28 deletions(-) diff --git a/housepanel-push/housepanel-push.js b/housepanel-push/housepanel-push.js index fa60e40..67f0012 100644 --- a/housepanel-push/housepanel-push.js +++ b/housepanel-push/housepanel-push.js @@ -2,7 +2,15 @@ process.title = 'housepanel-push'; // websocket and http servers -var webSocketServer = require('websocket').server; +// the websocket module is optional at load time so this file can be required +// (by the smoke tests) before npm install has been run; the try block below +// already degrades gracefully when the server cannot be created +var webSocketServer = null; +try { + webSocketServer = require('websocket').server; +} catch (e) { + webSocketServer = null; +} var http = require('http'); var fs = require('fs'); var crypto = require('crypto'); @@ -17,6 +25,12 @@ var elements = [ ]; var config; var hubs; +// push token cached from the main options file, with the file and mtime it +// was read from so we can pick up changes without restarting the service +var pushToken = null; +var pushTokenFname = null; +var pushTokenMtime = null; + // server variables var server; var app; @@ -47,34 +61,34 @@ try { app = null; } +// the places HousePanel may have installed hmoptions.cfg, in priority order +var optionsCandidates = [ + "hmoptions.cfg", + "../hmoptions.cfg", + "/var/www/html/housepanel/hmoptions.cfg", + "/var/www/html/smartthings/hmoptions.cfg" +]; + +// return the path to the options file, or null if none of them exist +function locateOptionsFile() { + for ( var i=0; i < optionsCandidates.length; i++ ) { + try { + fs.statSync(optionsCandidates[i]); + return optionsCandidates[i]; + } catch (err) { + // try the next candidate + } + } + return null; +} + function updateElements() { elements = [ ]; hubs = null; // read options file here since it could have changed - - fname = "hmoptions.cfg"; - try { - fs.statSync(fname); - } catch (err) { - try { - fname = "../hmoptions.cfg"; - fs.statSync(fname); - } catch (err2) { - try { - fname = "/var/www/html/housepanel/hmoptions.cfg"; - fs.statSync(fname); - } catch (err3) { - try { - fname = "/var/www/html/smartthings/hmoptions.cfg"; - fs.statSync(fname); - } catch (err4) { - fname = null; - } - } - } - } - + fname = locateOptionsFile(); + if ( fname === null ) { console.log('housepanel-push installed but hmoptions file not found. Will be activated when HousePanel is used and the first hub is authorized.'); return; @@ -159,7 +173,7 @@ function updateElements() { }); applistening = true; } else { - console.log((new Date()) + "Node.js application port not valid. port= ", config.port); + console.log((new Date()) + "Node.js application port not valid. port= ", config ? config.port : 'none'); } if ( !serverlistening && server && config && config.webSocketServerPort ) { @@ -168,8 +182,38 @@ function updateElements() { }); serverlistening = true; } else { - console.log("webSocket port not valid. webSocketServerPort= ", config.webSocketServerPort); + console.log("webSocket port not valid. webSocketServerPort= ", config ? config.webSocketServerPort : 'none'); + } +} + +// read the push token straight from hmoptions.cfg, re-reading only when the +// file has changed. updateElements() is not a usable source here: it only runs +// at startup, on a websocket message, or on the "initialize" POST -- and that +// POST is itself behind this auth check. Without an independent read, a service +// that started before the Options page generated a token would reject every +// push until it was restarted. Deliberately does not touch config/hubs/elements +// or make hub requests, so an unauthenticated caller cannot trigger any work. +function getPushToken() { + var tokenFile = locateOptionsFile(); + if ( tokenFile === null ) { + pushToken = null; + pushTokenMtime = null; + return null; } + + try { + var mtime = fs.statSync(tokenFile).mtimeMs; + if ( tokenFile !== pushTokenFname || mtime !== pushTokenMtime ) { + var options = JSON.parse(fs.readFileSync(tokenFile, 'utf8')); + pushToken = (options && options.config && options.config.pushToken) || null; + pushTokenFname = tokenFile; + pushTokenMtime = mtime; + } + } catch (e) { + pushToken = null; + pushTokenMtime = null; + } + return pushToken; } // require a shared secret (configured as config.pushToken in hmoptions.cfg, @@ -179,7 +223,7 @@ function updateElements() { // is accepted -- no header/query/body alternatives, since those can // leak into access logs. function checkPushAuth(req, res) { - var token = config && config.pushToken; + var token = getPushToken(); if ( !token ) { console.log((new Date()) + " housepanel-push: pushToken not configured in hmoptions.cfg; rejecting unauthenticated request."); res.status(503).json('housepanel-push is not configured with a pushToken; request rejected'); @@ -328,4 +372,16 @@ if ( wsServer ) { // start with an initial list of all elements // this is updated when any hub is reinstalled -updateElements(); +// only when run as a service; requiring this file (e.g. from the smoke tests) +// must not bind ports or start reading hubs +if ( require.main === module ) { + updateElements(); +} + +module.exports = { + checkPushAuth: checkPushAuth, + getPushToken: getPushToken, + locateOptionsFile: locateOptionsFile, + updateElements: updateElements, + app: app +}; From ac1ef98ec62f6e6bef06dbcd85058c334ceddcc4 Mon Sep 17 00:00:00 2001 From: Anupam Mediratta Date: Thu, 3 Sep 2026 09:49:00 +0530 Subject: [PATCH 6/8] test(housepanel-push): exercise production checkPushAuth The previous auth tests defined mockCheckPushAuth(), a hand-copied reimplementation of the real function, so they could pass while production auth was broken. Import and call the real checkPushAuth()/getPushToken() instead, driven by a fixture hmoptions.cfg in a temp cwd. Covers no token configured (503), missing/malformed/wrong bearer (401), valid and case-insensitive bearer (accepted), token rotation, and an unparseable cfg failing closed. Includes a regression test for the deadlock fixed in the previous commit: the token is written after the module is loaded, and the next call must authorize without a restart. Adds an HTTP-level block that drives the real Express routes over a loopback socket -- POST 401/valid, an authenticated hub initialize, and GET returning the status page without auth and without per-client hosts. It runs only when express is installed and prints an explicit SKIP otherwise, noting that the assertions above still ran against production code, so a clean checkout without npm install is not a silent gap. Verified by mutation: forcing checkPushAuth to authorize, and removing the POST gate entirely, each make this suite exit non-zero. Co-Authored-By: Claude Opus 5 --- housepanel-push/housepanel-push.smoke.js | 218 +++++++++++++++++++---- 1 file changed, 187 insertions(+), 31 deletions(-) diff --git a/housepanel-push/housepanel-push.smoke.js b/housepanel-push/housepanel-push.smoke.js index 90470b3..6029299 100644 --- a/housepanel-push/housepanel-push.smoke.js +++ b/housepanel-push/housepanel-push.smoke.js @@ -8,7 +8,9 @@ // 4. healthy path still parses, indexes hubs correctly, and pushes items const assert = require("assert"); -const crypto = require("crypto"); +const fs = require("fs"); +const os = require("os"); +const path = require("path"); // Mirrors the callback body now in housepanel-push.js updateElements() function makeCallback(hubs, elements, logs) { @@ -213,52 +215,206 @@ const hubs = [ assert.strictEqual(threw, false, "update handler must not throw on non-object value"); } -// Mirrors checkPushAuth() in housepanel-push.js: only Authorization: Bearer -// is accepted, compared in constant time. Returns the HTTP status -// that would be sent (200 = authorized, 401 = unauthorized, 503 = no token -// configured), same statuses the real handler returns. -function mockCheckPushAuth(configuredToken, authHeader) { - var token = configuredToken; - if ( !token ) { return 503; } - var auth = authHeader || ''; - var match = auth.match(/^Bearer\s+(.+)$/i); - var provided = match ? match[1] : null; - var providedBuf = Buffer.from(provided || ''); - var tokenBuf = Buffer.from(token); - var authorized = !!provided && - providedBuf.length === tokenBuf.length && - crypto.timingSafeEqual(providedBuf, tokenBuf); - return authorized ? 200 : 401; +// --------------------------------------------------------------------------- +// Push authentication tests. These call the REAL checkPushAuth/getPushToken +// exported by housepanel-push.js -- not a copy of the logic -- so that a +// regression in production auth fails this suite. +// --------------------------------------------------------------------------- + +// Point the options-file lookup at a fixture we control. locateOptionsFile() +// tries "hmoptions.cfg" relative to cwd first, so chdir into a temp dir that +// holds our fixture. Must happen before requiring the module. +const tmpdir = fs.mkdtempSync(path.join(os.tmpdir(), "hp-push-auth-")); +const cfgPath = path.join(tmpdir, "hmoptions.cfg"); +const origCwd = process.cwd(); + +// getPushToken() re-reads only when mtime changes, so step the mtime back on +// every write; otherwise same-millisecond rewrites could be missed. +let mtimeStep = 0; +function writeCfg(configObj) { + fs.writeFileSync(cfgPath, JSON.stringify({ config: configObj })); + mtimeStep += 2; + const stamp = new Date(Date.now() - (mtimeStep * 1000)); + fs.utimesSync(cfgPath, stamp, stamp); +} + +// start with a config that has NO pushToken, as every pre-upgrade install does +writeCfg({ port: "19234", webSocketServerPort: "1337" }); +process.chdir(tmpdir); + +const push = require(path.join(__dirname, "housepanel-push.js")); + +// invoke the real checkPushAuth with minimal req/res doubles and report the +// status it sent (or 200 when it authorized and sent nothing) +function callAuth(authHeader) { + let sentStatus = null; + const req = { + get: function (name) { + return (String(name).toLowerCase() === "authorization") ? authHeader : undefined; + }, + ip: "10.0.0.5" + }; + const res = { + status: function (code) { sentStatus = code; return res; }, + json: function () { return res; } + }; + const allowed = push.checkPushAuth(req, res); + return { allowed: allowed, status: allowed ? 200 : sentStatus }; } const REAL_TOKEN = "s3cr3t-push-token"; -// 7. no pushToken configured at all -> 503 regardless of what's sent +// 7. no pushToken configured -> 503 regardless of what is sent +{ + assert.strictEqual(callAuth("Bearer anything").status, 503, "unconfigured token must 503"); + assert.strictEqual(callAuth(undefined).status, 503, "unconfigured token must 503 with no header"); + assert.strictEqual(push.getPushToken(), null, "no token should be found in a cfg without one"); +} + +// 7b. REGRESSION: the token is written while this process is already running. +// The Options page writes hmoptions.cfg after housepanel-push has started, and +// the "initialize" POST that used to refresh config is itself behind auth, so +// this must start working with no restart. +{ + writeCfg({ port: "19234", pushToken: REAL_TOKEN }); + const res = callAuth("Bearer " + REAL_TOKEN); + assert.strictEqual(res.allowed, true, "token written after startup must be picked up without a restart"); + assert.strictEqual(res.status, 200, "valid token must not set an error status"); + assert.strictEqual(push.getPushToken(), REAL_TOKEN, "getPushToken must return the freshly written token"); +} + +// 7c. configured token, no Authorization header -> 401 +{ + const res = callAuth(undefined); + assert.strictEqual(res.allowed, false, "missing Authorization must be rejected"); + assert.strictEqual(res.status, 401, "missing Authorization must 401"); +} + +// 7d. configured token, malformed Authorization -> 401 { - assert.strictEqual(mockCheckPushAuth(null, "Bearer anything"), 503, "unconfigured token must 503"); - assert.strictEqual(mockCheckPushAuth(null, undefined), 503, "unconfigured token must 503 with no header"); + assert.strictEqual(callAuth("Token " + REAL_TOKEN).status, 401, "non-Bearer scheme must 401"); + assert.strictEqual(callAuth(REAL_TOKEN).status, 401, "raw token with no scheme must 401"); + assert.strictEqual(callAuth("Bearer").status, 401, "Bearer with no token must 401"); + assert.strictEqual(callAuth("Bearer ").status, 401, "Bearer with empty token must 401"); } -// 7b. configured token, no Authorization header -> 401 +// 7e. configured token, wrong bearer token -> 401 { - assert.strictEqual(mockCheckPushAuth(REAL_TOKEN, undefined), 401, "missing Authorization must 401"); + assert.strictEqual(callAuth("Bearer wrong-token").status, 401, "wrong bearer token must 401"); + assert.strictEqual(callAuth("Bearer " + REAL_TOKEN + "x").status, 401, "token with extra suffix must 401"); + assert.strictEqual(callAuth("Bearer " + REAL_TOKEN.slice(0, -1)).status, 401, "truncated token must 401"); } -// 7c. configured token, malformed Authorization scheme -> 401 +// 7f. correct bearer token, case-insensitive scheme -> authorized { - assert.strictEqual(mockCheckPushAuth(REAL_TOKEN, "Token " + REAL_TOKEN), 401, "non-Bearer scheme must 401"); - assert.strictEqual(mockCheckPushAuth(REAL_TOKEN, REAL_TOKEN), 401, "raw token with no scheme must 401"); + assert.strictEqual(callAuth("Bearer " + REAL_TOKEN).allowed, true, "correct bearer token must authorize"); + assert.strictEqual(callAuth("bearer " + REAL_TOKEN).allowed, true, "scheme match must be case-insensitive"); } -// 7d. configured token, wrong bearer token -> 401 +// 7g. a rotated token in the cfg is picked up, and the old one stops working { - assert.strictEqual(mockCheckPushAuth(REAL_TOKEN, "Bearer wrong-token"), 401, "wrong bearer token must 401"); + const NEW_TOKEN = "rotated-push-token-value"; + writeCfg({ port: "19234", pushToken: NEW_TOKEN }); + assert.strictEqual(callAuth("Bearer " + NEW_TOKEN).allowed, true, "rotated token must authorize"); + assert.strictEqual(callAuth("Bearer " + REAL_TOKEN).status, 401, "superseded token must 401"); } -// 7e. configured token, correct bearer token -> 200 (authorized) +// 7h. a corrupt cfg must fail closed rather than throw { - assert.strictEqual(mockCheckPushAuth(REAL_TOKEN, "Bearer " + REAL_TOKEN), 200, "correct bearer token must authorize"); - assert.strictEqual(mockCheckPushAuth(REAL_TOKEN, "bearer " + REAL_TOKEN), 200, "scheme match must be case-insensitive"); + fs.writeFileSync(cfgPath, "not json"); + mtimeStep += 2; + const stamp = new Date(Date.now() - (mtimeStep * 1000)); + fs.utimesSync(cfgPath, stamp, stamp); + assert.strictEqual(callAuth("Bearer " + REAL_TOKEN).status, 503, "unparseable cfg must fail closed with 503"); +} + +// --------------------------------------------------------------------------- +// 8. HTTP-level coverage of the real Express routes. Only runs when the +// dependencies are installed (npm install); the assertions above already +// exercise production checkPushAuth without them, so skipping here is not a +// silent gap in auth coverage. +// --------------------------------------------------------------------------- +function httpTests() { + if ( !push.app ) { + console.log("SKIP HTTP-level route tests: express/body-parser not installed (run npm install for this coverage). " + + "Unit-level assertions above already ran against production checkPushAuth."); + return Promise.resolve(); + } + + const httpmod = require("http"); + // no port/webSocketServerPort here on purpose: the "initialize" case below + // reaches updateElements(), which would otherwise bind the configured + // ports for real and leave this test process hanging on an open handle + writeCfg({ pushToken: REAL_TOKEN }); + + return new Promise(function (resolve, reject) { + const listener = push.app.listen(0, "127.0.0.1", function () { + const port = listener.address().port; + + function send(method, headers, body) { + return new Promise(function (done, fail) { + const req = httpmod.request({ + host: "127.0.0.1", port: port, path: "/", method: method, headers: headers || {} + }, function (res) { + let data = ""; + res.on("data", function (chunk) { data += chunk; }); + res.on("end", function () { done({ status: res.statusCode, body: data }); }); + }); + req.on("error", fail); + if ( body ) { req.write(body); } + req.end(); + }); + } + + const asJson = { "Content-Type": "application/json" }; + const payload = JSON.stringify({ msgtype: "update", change_device: "1", change_attribute: "switch", change_value: "on" }); + + Promise.resolve() + .then(function () { + return send("POST", asJson, payload).then(function (res) { + assert.strictEqual(res.status, 401, "POST with no Authorization must 401"); + }); + }) + .then(function () { + return send("POST", Object.assign({ Authorization: "Bearer wrong" }, asJson), payload).then(function (res) { + assert.strictEqual(res.status, 401, "POST with wrong bearer token must 401"); + }); + }) + .then(function () { + return send("POST", Object.assign({ Authorization: "Bearer " + REAL_TOKEN }, asJson), payload).then(function (res) { + assert.strictEqual(res.status, 200, "POST with valid bearer token must be accepted"); + }); + }) + .then(function () { + // a legitimate hub "initialize" post, the message that reauthorizes hubs + const init = JSON.stringify({ msgtype: "initialize" }); + return send("POST", Object.assign({ Authorization: "Bearer " + REAL_TOKEN }, asJson), init).then(function (res) { + assert.strictEqual(res.status, 200, "authenticated hub initialize must succeed"); + }); + }) + .then(function () { + return send("GET", {}).then(function (res) { + assert.strictEqual(res.status, 200, "GET status page must not require auth"); + assert.ok(res.body.indexOf("housepanel-push") >= 0, "GET must return the status page"); + assert.ok(res.body.indexOf("Client #") < 0, "GET must not disclose per-client host details"); + }); + }) + .then(function () { listener.close(function () { resolve(); }); }) + .catch(function (err) { listener.close(function () { reject(err); }); }); + }); + listener.on("error", reject); + }); } -console.log("ALL BEHAVIORAL ASSERTIONS PASSED"); +httpTests() + .then(function () { + process.chdir(origCwd); + fs.rmSync(tmpdir, { recursive: true, force: true }); + console.log("ALL BEHAVIORAL ASSERTIONS PASSED"); + }) + .catch(function (err) { + process.chdir(origCwd); + fs.rmSync(tmpdir, { recursive: true, force: true }); + console.error(err); + process.exit(1); + }); From 562da31e9058f44ceb3b106cc14a3dcade696274 Mon Sep 17 00:00:00 2001 From: Anupam Mediratta Date: Thu, 3 Sep 2026 09:49:00 +0530 Subject: [PATCH 7/8] docs: note that housepanel-push picks up the token without a restart The Push Authentication section told admins to generate a token and copy it into the SmartApp but omitted that a running service would keep rejecting pushes until reloaded. That reload is no longer required, so say so, and keep systemctl restart only as a troubleshooting fallback. Co-Authored-By: Claude Opus 5 --- docs/index.html | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/index.html b/docs/index.html index da7205c..e98991b 100644 --- a/docs/index.html +++ b/docs/index.html @@ -601,6 +601,15 @@

Push Authentication

Unauthorized (or 503 if the Options page has never been opened on this install, since no token has been generated yet). The status page at GET / does not require this token. +

+ +You do not need to restart housepanel-push after the token is generated. The +service re-reads the token from hmoptions.cfg whenever that file changes, so an +already-running service starts accepting authenticated pushes on the next one, and +picks up a changed token the same way. If pushes still fail after you have copied the +token into the SmartApp, confirm the token in the SmartApp matches the Options page +exactly, then as a fallback reload the service with +sudo systemctl restart housepanel-push.

Set Server Permissions

From c3f65385ae7e9459e7b4e385ca1e520c308bfe2f Mon Sep 17 00:00:00 2001 From: Anupam Mediratta Date: Thu, 3 Sep 2026 20:08:32 +0530 Subject: [PATCH 8/8] fix(groovy): stop logging pushToken in debug mode initialize() logged the entire settings map when debug logging was enabled (user-configurable, not default), which included settings.pushToken. Enabling debug mode would therefore leak the push authentication secret into SmartThings/Hubitat platform logs. Replace the wholesale ${settings} dump with an explicit safe subset: webSocketHost, webSocketPort, cloudcalls, and timezone. This gives enough context to debug hub setup issues without exposing secrets, and establishes the pattern of logging by allow-list rather than by exclude-list from a map dump. Co-Authored-By: Claude Opus 5 --- HousePanel.groovy | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/HousePanel.groovy b/HousePanel.groovy index d3f543d..499921c 100644 --- a/HousePanel.groovy +++ b/HousePanel.groovy @@ -208,7 +208,10 @@ def initialize() { webCoRE_init() } state.loggingLevelIDE = settings.configLogLevel?.toInteger() ?: 3 - logger("Installed ${hubtype} hub with settings: ${settings} ", "debug") + logger("Installed ${hubtype} hub. " + + "webSocket: ${settings?.webSocketHost}:${settings?.webSocketPort}, " + + "cloudCalls: ${settings?.cloudcalls}, " + + "timezone: ${settings?.timezone}", "debug") if (state.directIP) {