diff --git a/HousePanel.groovy b/HousePanel.groovy index c980fb5..499921c 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" @@ -206,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) { @@ -2383,14 +2388,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/docs/index.html b/docs/index.html index a4eb8bf..e98991b 100644 --- a/docs/index.html +++ b/docs/index.html @@ -588,6 +588,28 @@

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. +

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

diff --git a/housepanel-push/housepanel-push.js b/housepanel-push/housepanel-push.js index bf1ff54..67f0012 100644 --- a/housepanel-push/housepanel-push.js +++ b/housepanel-push/housepanel-push.js @@ -2,9 +2,18 @@ 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'); // list of currently connected clients (users) var clients = [ ]; @@ -16,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; @@ -46,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; @@ -158,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 ) { @@ -167,23 +182,77 @@ 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; } -// a callback function to give status info if they point a browser here +// 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 = 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'); + return false; + } + 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; + } + return true; +} + +// 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) { - 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. " ); }); @@ -192,6 +261,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 +278,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 @@ -299,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 +}; diff --git a/housepanel-push/housepanel-push.smoke.js b/housepanel-push/housepanel-push.smoke.js index 9ea29a9..6029299 100644 --- a/housepanel-push/housepanel-push.smoke.js +++ b/housepanel-push/housepanel-push.smoke.js @@ -8,6 +8,9 @@ // 4. healthy path still parses, indexes hubs correctly, and pushes items const assert = require("assert"); +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) { @@ -212,4 +215,206 @@ const hubs = [ assert.strictEqual(threw, false, "update handler must not throw on non-object value"); } -console.log("ALL BEHAVIORAL ASSERTIONS PASSED"); +// --------------------------------------------------------------------------- +// 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 -> 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(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"); +} + +// 7e. configured token, wrong bearer token -> 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"); +} + +// 7f. correct bearer token, case-insensitive scheme -> authorized +{ + 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"); +} + +// 7g. a rotated token in the cfg is picked up, and the old one stops working +{ + 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"); +} + +// 7h. a corrupt cfg must fail closed rather than throw +{ + 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); + }); +} + +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); + }); 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.= "";