diff --git a/js/preload.js b/js/preload.js index 2150ca6..aae4d57 100644 --- a/js/preload.js +++ b/js/preload.js @@ -1,2 +1,7 @@ -window.addEventListener('DOMContentLoaded', () => { +const { contextBridge, ipcRenderer } = require('electron/renderer'); + +contextBridge.exposeInMainWorld('signage', { + watchdog: { + reset: (payload) => ipcRenderer.invoke('signage:watchdog:reset', payload) + } }); \ No newline at end of file diff --git a/js/watchdog.js b/js/watchdog.js new file mode 100644 index 0000000..cab19c7 --- /dev/null +++ b/js/watchdog.js @@ -0,0 +1,87 @@ +const http = require('node:http'); + +const WATCHDOG_CHANNEL = 'signage:watchdog:reset'; +const WATCHDOG_PATH = '/tooloop/api/v1.0/system/watchdog/reset'; +const REQUEST_TIMEOUT_MS = 5000; + +function isValidPayload(payload) { + return payload !== null && + typeof payload === 'object' && + !Array.isArray(payload) && + Object.keys(payload).length === 1 && + Object.hasOwn(payload, 'healthy') && + typeof payload.healthy === 'boolean'; +} + +function isAuthorizedWatchdogEvent(event, mainWindow, isUrlAllowed) { + if (!mainWindow || mainWindow.isDestroyed() || event.sender !== mainWindow.webContents) return false; + if (!event.senderFrame || event.senderFrame !== event.sender.mainFrame) return false; + return isUrlAllowed(event.senderFrame.url); +} + +function sendWatchdogReset(payload, httpClient = http, timeoutMs = REQUEST_TIMEOUT_MS) { + if (!isValidPayload(payload)) { + return Promise.reject(new TypeError('Watchdog reset payload must contain only a boolean healthy field')); + } + + const body = JSON.stringify({ healthy: payload.healthy }); + + return new Promise((resolve, reject) => { + let settled = false; + let timeout; + + const finish = (callback, value) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + callback(value); + }; + + const request = httpClient.request({ + hostname: 'localhost', + path: WATCHDOG_PATH, + method: 'POST', + headers: { 'Content-Type': 'application/json' } + }, (response) => { + response.resume(); + const statusCode = response.statusCode; + if (!Number.isInteger(statusCode) || statusCode < 200 || statusCode >= 300) { + finish(reject, new Error(`Watchdog service returned HTTP ${statusCode ?? 'unknown'}`)); + return; + } + finish(resolve); + }); + + request.on('error', (error) => finish(reject, error)); + timeout = setTimeout(() => { + request.destroy(new Error(`Watchdog request timed out after ${timeoutMs} ms`)); + }, timeoutMs); + request.end(body); + }); +} + +function registerWatchdogHandler(ipcMain, { isAuthorized, onError = () => {}, httpClient = http } = {}) { + ipcMain.handle(WATCHDOG_CHANNEL, async (event, payload) => { + try { + if (!isAuthorized || !isAuthorized(event)) { + throw new Error('Watchdog reset is not authorized for this renderer'); + } + if (!isValidPayload(payload)) { + throw new TypeError('Watchdog reset payload must contain only a boolean healthy field'); + } + await sendWatchdogReset(payload, httpClient); + } catch (error) { + onError(error); + throw error; + } + }); +} + +module.exports = { + WATCHDOG_CHANNEL, + WATCHDOG_PATH, + isAuthorizedWatchdogEvent, + isValidPayload, + registerWatchdogHandler, + sendWatchdogReset +}; \ No newline at end of file diff --git a/main.js b/main.js index 0ce1e39..c4cb728 100644 --- a/main.js +++ b/main.js @@ -1,4 +1,4 @@ -const { app, BrowserWindow, nativeTheme, Menu, MenuItem } = require('electron/main'); +const { app, BrowserWindow, nativeTheme, Menu, MenuItem, ipcMain } = require('electron/main'); const path = require('node:path'); const { fileURLToPath } = require('node:url'); const fs = require('fs'); @@ -7,6 +7,7 @@ const { default: isReachable } = require('is-reachable'); const { setTimeout: delay } = require('node:timers/promises'); const config = require('./js/config.js'); const { Config } = require('./js/config.js'); +const { isAuthorizedWatchdogEvent: isWatchdogEventAuthorized, registerWatchdogHandler } = require('./js/watchdog.js'); //------------------------------------------------------------------------------ @@ -163,30 +164,53 @@ function createMenu() { * @returns `true` if the url is allowed, `false` otherwise */ function validateDomain(event) { + if (isUrlAllowed(event.url)) return true; + event.preventDefault(); + log.info("Navigation to " + event.url + " prevented"); + return false; +} + +function isUrlAllowed(urlString) { if (config == undefined || config.data == undefined) return true; - let url = new URL(event.url); + let url; + try { + url = new URL(urlString); + } catch { + return false; + } - // allow local urls if (['file:', 'file'].includes(url.protocol)) return true; if ('whitelist' in config.data && Array.isArray(config.data.whitelist) && config.data.whitelist.length > 0 && !config.data.whitelist.includes(url.hostname)) { - event.preventDefault(); - log.info("Navigation to " + event.url + " prevented"); return false; } return true; } +function isAuthorizedWatchdogEvent(event) { + return isWatchdogEventAuthorized(event, win, isUrlAllowed); +} + +function logWatchdogError(error) { + log.warn('Watchdog reset failed: ' + (error?.message || error)); +} + +function registerWatchdogBridge() { + registerWatchdogHandler(ipcMain, { + isAuthorized: isAuthorizedWatchdogEvent, + onError: logWatchdogError + }); +} /** * Tests if the host of the url is reachable. * Access is tried in a 1 second interval and the url is loaded if successfull. - * @param {string} testurl + * @param {string} testurl */ async function loadUrlAsync(testurl) { const request = ++navigationRequest; @@ -299,6 +323,7 @@ app.whenReady().then(() => { log.info('Starting Tooloop Kiosk Browser...'); log.info('Logging to ' + log.transports.file.resolvePathFn()); + registerWatchdogBridge(); createMenu(); createMainWindow(); }); diff --git a/package.json b/package.json index c83afea..5a9d421 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "version": "1.0.0", "main": "main.js", "scripts": { + "test": "node --test", "start": "electron-forge start", "clean": "rm -fr out", "package": "electron-forge package", diff --git a/test/watchdog.test.js b/test/watchdog.test.js new file mode 100644 index 0000000..f72591b --- /dev/null +++ b/test/watchdog.test.js @@ -0,0 +1,121 @@ +const assert = require('node:assert/strict'); +const { EventEmitter } = require('node:events'); +const test = require('node:test'); +const { + WATCHDOG_CHANNEL, + WATCHDOG_PATH, + isAuthorizedWatchdogEvent, + registerWatchdogHandler, + sendWatchdogReset +} = require('../js/watchdog.js'); + +function createHttpClient({ statusCode = 204, hang = false } = {}) { + const calls = []; + const httpClient = { + request(options, onResponse) { + const request = new EventEmitter(); + request.destroy = (error) => request.emit('error', error); + request.end = (body) => { + calls.push({ options, body }); + if (!hang) { + queueMicrotask(() => onResponse({ statusCode, resume() {} })); + } + }; + return request; + } + }; + return { httpClient, calls }; +} + +test('sends healthy true as a fixed local POST', async () => { + const { httpClient, calls } = createHttpClient(); + + await sendWatchdogReset({ healthy: true }, httpClient); + + assert.deepEqual(calls, [{ + options: { + hostname: 'localhost', + path: WATCHDOG_PATH, + method: 'POST', + headers: { 'Content-Type': 'application/json' } + }, + body: '{"healthy":true}' + }]); +}); + +test('sends healthy false in the request body', async () => { + const { httpClient, calls } = createHttpClient(); + + await sendWatchdogReset({ healthy: false }, httpClient); + + assert.equal(calls[0].body, '{"healthy":false}'); +}); + +test('rejects malformed payloads without making a request', async () => { + const { httpClient, calls } = createHttpClient(); + + for (const payload of [null, [], {}, { healthy: 1 }, { healthy: true, url: 'http://example.test' }]) { + await assert.rejects(sendWatchdogReset(payload, httpClient), /boolean healthy field/); + } + + assert.equal(calls.length, 0); +}); + +test('rejects non-success HTTP responses without parsing their body', async () => { + const { httpClient } = createHttpClient({ statusCode: 503 }); + + await assert.rejects(sendWatchdogReset({ healthy: true }, httpClient), /HTTP 503/); +}); + +test('rejects responses without a numeric HTTP status', async () => { + const { httpClient } = createHttpClient({ statusCode: null }); + + await assert.rejects(sendWatchdogReset({ healthy: true }, httpClient), /HTTP unknown/); +}); + +test('rejects requests that exceed the timeout', async () => { + const { httpClient } = createHttpClient({ hang: true }); + + await assert.rejects(sendWatchdogReset({ healthy: true }, httpClient, 5), /timed out after 5 ms/); +}); + +test('rejects unauthorized renderers without making a request', async () => { + let registeredChannel; + let handler; + const { httpClient, calls } = createHttpClient(); + const mainFrame = { url: 'https://allowed.example/' }; + const mainWindow = { isDestroyed: () => false, webContents: { mainFrame } }; + const ipcMain = { + handle(channel, registeredHandler) { + registeredChannel = channel; + handler = registeredHandler; + } + }; + + registerWatchdogHandler(ipcMain, { + isAuthorized: (event) => isAuthorizedWatchdogEvent(event, mainWindow, (url) => url === mainFrame.url), + httpClient + }); + + assert.equal(registeredChannel, WATCHDOG_CHANNEL); + const otherRenderer = { sender: { mainFrame }, senderFrame: mainFrame }; + await assert.rejects(handler(otherRenderer, { healthy: true }), /not authorized/); + assert.equal(calls.length, 0); +}); + +test('rejects subframes and renderers on disallowed URLs', () => { + const mainFrame = { url: 'https://allowed.example/' }; + const mainWindow = { isDestroyed: () => false, webContents: { mainFrame } }; + const isUrlAllowed = (url) => url === mainFrame.url; + const blockedFrame = { url: 'https://blocked.example/' }; + const blockedWindow = { isDestroyed: () => false, webContents: { mainFrame: blockedFrame } }; + + assert.equal(isAuthorizedWatchdogEvent({ + sender: mainWindow.webContents, + senderFrame: { url: mainFrame.url } + }, mainWindow, isUrlAllowed), false); + assert.equal(isAuthorizedWatchdogEvent({ + sender: blockedWindow.webContents, + senderFrame: blockedFrame + }, blockedWindow, isUrlAllowed), false); +}); \ No newline at end of file