From 99b929a62638b5388b62d565f20b9847bc855a2b Mon Sep 17 00:00:00 2001 From: Daniel Huber Date: Wed, 5 Jan 2022 16:20:10 +0100 Subject: [PATCH] Replace message based dashboard communication with a http endpoint --- nodecg-io-core/dashboard/authentication.ts | 12 +- nodecg-io-core/dashboard/bundles.ts | 9 +- nodecg-io-core/dashboard/core.ts | 25 +++ nodecg-io-core/dashboard/crypto.ts | 17 +- nodecg-io-core/dashboard/serviceInstance.ts | 25 ++- nodecg-io-core/extension/dashboardApi.ts | 225 ++++++++++++++++++++ nodecg-io-core/extension/index.ts | 11 +- nodecg-io-core/extension/messageManager.ts | 128 ----------- 8 files changed, 293 insertions(+), 159 deletions(-) create mode 100644 nodecg-io-core/dashboard/core.ts create mode 100644 nodecg-io-core/extension/dashboardApi.ts delete mode 100644 nodecg-io-core/extension/messageManager.ts diff --git a/nodecg-io-core/dashboard/authentication.ts b/nodecg-io-core/dashboard/authentication.ts index 0a9e8c939..2c98d0dc5 100644 --- a/nodecg-io-core/dashboard/authentication.ts +++ b/nodecg-io-core/dashboard/authentication.ts @@ -2,6 +2,7 @@ import { updateMonacoLayout } from "./serviceInstance"; import { setPassword, isPasswordSet } from "./crypto"; +import { callCoreApi } from "./core"; // HTML elements const spanLoaded = document.getElementById("spanLoaded") as HTMLSpanElement; @@ -41,7 +42,13 @@ document.addEventListener("DOMContentLoaded", () => { export async function isLoaded(): Promise { return new Promise((resolve, _reject) => { - nodecg.sendMessage("isLoaded", (_err, res) => resolve(res)); + callCoreApi({ type: "isLoaded" }).then((result) => { + if (result.failed) { + resolve(false); + } else { + resolve(result.result); + } + }); setTimeout(() => resolve(false), 5000); // Fallback in case connection gets lost. }); } @@ -79,7 +86,8 @@ export async function loadFramework(): Promise { } async function updateFirstStartupLabel(): Promise { - const isFirstStartup: boolean = await nodecg.sendMessage("isFirstStartup"); + const isFirstStartupRes = await callCoreApi({ type: "isFirstStartup" }); + const isFirstStartup = isFirstStartupRes.failed === false && isFirstStartupRes.result; if (isFirstStartup) { pFirstStartup?.classList.remove("hidden"); } else { diff --git a/nodecg-io-core/dashboard/bundles.ts b/nodecg-io-core/dashboard/bundles.ts index e2530b646..880863910 100644 --- a/nodecg-io-core/dashboard/bundles.ts +++ b/nodecg-io-core/dashboard/bundles.ts @@ -1,6 +1,6 @@ import { updateOptionsArr, updateOptionsMap } from "./utils/selectUtils"; -import { SetServiceDependencyMessage } from "nodecg-io-core/extension/messageManager"; -import { config, sendAuthenticatedMessage } from "./crypto"; +import { SetServiceDependencyRequest } from "nodecg-io-core/extension/dashboardApi"; +import { config, callCoreApiAuthenticated } from "./crypto"; document.addEventListener("DOMContentLoaded", () => { config.onChange(() => { @@ -114,14 +114,15 @@ export function unsetAllBundleDependencies(): void { } async function setServiceDependency(bundle: string, instance: string | undefined, serviceType: string): Promise { - const msg: Partial = { + const msg: Partial = { + type: "setServiceDependency", bundleName: bundle, instanceName: instance, serviceType, }; try { - await sendAuthenticatedMessage("setServiceDependency", msg); + await callCoreApiAuthenticated(msg); } catch (err) { nodecg.log.error(err); } diff --git a/nodecg-io-core/dashboard/core.ts b/nodecg-io-core/dashboard/core.ts new file mode 100644 index 000000000..00e33f8c7 --- /dev/null +++ b/nodecg-io-core/dashboard/core.ts @@ -0,0 +1,25 @@ +import { Result } from "nodecg-io-core"; +import { DashboardApiRequest } from "../extension/dashboardApi"; + +/** + * Calls a function on the dashboard api of nodecg-io-core using http. + * Throws if the request is calling a non existing function or a invalid password is provided. + * If the api responds with an error, it will be returned as a error result. + * + * @param msg the message to send to the dashboard api + */ +export async function callCoreApi(msg: DashboardApiRequest): Promise> { + const response = await fetch("/nodecg-io-core/", { + method: "POST", + body: JSON.stringify(msg), + headers: { + "Content-Type": "application/json", + }, + }); + + if (response.status === 200) { + return response.json(); + } else { + throw new Error(`Unexpected response from API: ${response.status}`); + } +} diff --git a/nodecg-io-core/dashboard/crypto.ts b/nodecg-io-core/dashboard/crypto.ts index a967c260d..d17c47605 100644 --- a/nodecg-io-core/dashboard/crypto.ts +++ b/nodecg-io-core/dashboard/crypto.ts @@ -2,7 +2,9 @@ import { PersistentData, EncryptedData, decryptData } from "nodecg-io-core/exten import { EventEmitter } from "events"; import { ObjectMap, ServiceInstance, ServiceDependency, Service } from "nodecg-io-core/extension/service"; import { isLoaded } from "./authentication"; -import { PasswordMessage } from "nodecg-io-core/extension/messageManager"; +import { callCoreApi } from "./core"; +import { Result } from "nodecg-io-core"; +import { AuthenticatedRequest } from "../extension/dashboardApi"; const encryptedData = nodecg.Replicant("encryptedConfig"); let services: Service[] | undefined; @@ -77,11 +79,11 @@ export async function setPassword(pw: string): Promise { return true; } -export async function sendAuthenticatedMessage(messageName: string, message: Partial): Promise { +export async function callCoreApiAuthenticated(message: Partial): Promise> { if (password === undefined) throw "No password available"; - const msgWithAuth = Object.assign({}, message); + const msgWithAuth = Object.assign({}, message) as AuthenticatedRequest; msgWithAuth.password = password; - return await nodecg.sendMessage(messageName, msgWithAuth); + return await callCoreApi(msgWithAuth); } /** @@ -128,14 +130,17 @@ function persistentData2ConfigData(data: PersistentData | undefined): ConfigData } async function fetchServices() { - services = await nodecg.sendMessage("getServices"); + const result = await callCoreApi>>({ type: "getServices" }); + if (!result.failed) { + services = result.result; + } } async function loadFramework(): Promise { if (await isLoaded()) return true; try { - await nodecg.sendMessage("load", { password }); + await callCoreApi({ type: "load", password }); return true; } catch { return false; diff --git a/nodecg-io-core/dashboard/serviceInstance.ts b/nodecg-io-core/dashboard/serviceInstance.ts index cdde19933..8c2dde708 100644 --- a/nodecg-io-core/dashboard/serviceInstance.ts +++ b/nodecg-io-core/dashboard/serviceInstance.ts @@ -1,12 +1,12 @@ import * as monaco from "monaco-editor"; import { - CreateServiceInstanceMessage, - DeleteServiceInstanceMessage, - UpdateInstanceConfigMessage, -} from "nodecg-io-core/extension/messageManager"; + CreateServiceInstanceRequest, + DeleteServiceInstanceRequest, + UpdateInstanceConfigRequest, +} from "nodecg-io-core/extension/dashboardApi"; import { updateOptionsArr, updateOptionsMap } from "./utils/selectUtils"; import { objectDeepCopy } from "./utils/deepCopy"; -import { config, sendAuthenticatedMessage } from "./crypto"; +import { config, callCoreApiAuthenticated } from "./crypto"; import { ObjectMap } from "nodecg-io-core/extension/service"; const editorDefaultText = "<---- Select a service instance to start editing it in here"; @@ -139,12 +139,13 @@ export async function saveInstanceConfig(): Promise { try { const instName = selectInstance.options[selectInstance.selectedIndex]?.value; const config = JSON.parse(editor.getValue()); - const msg: Partial = { + const msg: Partial = { + type: "updateInstanceConfig", config: config, instanceName: instName, }; showNotice("Saving..."); - await sendAuthenticatedMessage("updateInstanceConfig", msg); + await callCoreApiAuthenticated(msg); showNotice("Successfully saved."); } catch (err) { nodecg.log.error(`Couldn't save instance config: ${err}`); @@ -154,11 +155,12 @@ export async function saveInstanceConfig(): Promise { // Delete button export async function deleteInstance(): Promise { - const msg: Partial = { + const msg: Partial = { + type: "deleteServiceInstance", instanceName: selectInstance.options[selectInstance.selectedIndex]?.value, }; - const deleted = await sendAuthenticatedMessage("deleteServiceInstance", msg); + const deleted = await callCoreApiAuthenticated(msg); if (deleted) { selectServiceInstance("select"); } else { @@ -174,13 +176,14 @@ export async function createInstance(): Promise { const service = selectService.options[selectService.options.selectedIndex]?.value; const name = inputInstanceName.value; - const msg: Partial = { + const msg: Partial = { + type: "createServiceInstance", serviceType: service, instanceName: name, }; try { - await sendAuthenticatedMessage("createServiceInstance", msg); + await callCoreApiAuthenticated(msg); } catch (e) { showNotice(String(e)); return; diff --git a/nodecg-io-core/extension/dashboardApi.ts b/nodecg-io-core/extension/dashboardApi.ts new file mode 100644 index 000000000..51316cff9 --- /dev/null +++ b/nodecg-io-core/extension/dashboardApi.ts @@ -0,0 +1,225 @@ +import { NodeCG } from "nodecg-types/types/server"; +import type { Request, Response } from "express"; +import * as crypto from "crypto"; +import * as http from "http"; +import { BundleManager } from "./bundleManager"; +import { InstanceManager } from "./instanceManager"; +import { ServiceManager } from "./serviceManager"; +import { PersistenceManager } from "./persistenceManager"; +import { success, Result, error, emptySuccess } from "./utils/result"; +import { ObjectMap } from "./service"; + +export type DashboardApiRequest = + | { type: string } + | CreateServiceInstanceRequest + | UpdateInstanceConfigRequest + | DeleteServiceInstanceRequest + | SetServiceDependencyRequest; + +export interface AuthenticatedRequest { + type: string; + password: string; +} + +export interface CreateServiceInstanceRequest extends AuthenticatedRequest { + serviceType: string; + instanceName: string; +} + +export interface UpdateInstanceConfigRequest extends AuthenticatedRequest { + instanceName: string; + config: unknown; +} + +export interface DeleteServiceInstanceRequest extends AuthenticatedRequest { + instanceName: string; +} + +export interface SetServiceDependencyRequest extends AuthenticatedRequest { + bundleName: string; + instanceName: string | undefined; + serviceType: string; +} + +export const dashboardApiPath = "/nodecg-io-core/"; + +export class DashboardApi { + private readonly routes: ObjectMap<(r: unknown) => Promise>> = { + createServiceInstance: this.createServiceInstance.bind(this), + updateInstanceConfig: this.updateInstanceConfig.bind(this), + deleteServiceInstance: this.deleteServiceInstance.bind(this), + setServiceDependency: this.setServiceDependency.bind(this), + isLoaded: this.isLoaded.bind(this), + load: this.load.bind(this), + getServices: this.getServices.bind(this), + isFirstStartup: this.isFirstStartup.bind(this), + getSessionValue: this.getSessionValue.bind(this), + }; + + // For all these routes the password will be checked before the request is handled. + // If the password is invalid or the framework hasn't been loaded yet, the request will be rejected. + private readonly authenticatedRoutes = [ + "createServiceInstance", + "updateInstanceConfig", + "deleteServiceInstance", + "setServiceDependency", + ]; + + private sessionValue = crypto.randomBytes(16).toString("hex"); + + constructor( + private nodecg: NodeCG, + private services: ServiceManager, + private instances: InstanceManager, + private bundles: BundleManager, + private persist: PersistenceManager, + ) {} + + private async createServiceInstance(msg: CreateServiceInstanceRequest) { + return this.instances.createServiceInstance(msg.serviceType, msg.instanceName); + } + + private async updateInstanceConfig(msg: UpdateInstanceConfigRequest) { + const inst = this.instances.getServiceInstance(msg.instanceName); + if (inst === undefined) { + return error("Service instance doesn't exist."); + } else { + return await this.instances.updateInstanceConfig(msg.instanceName, msg.config); + } + } + + private async deleteServiceInstance(msg: DeleteServiceInstanceRequest) { + return success(this.instances.deleteServiceInstance(msg.instanceName)); + } + + private async setServiceDependency(msg: SetServiceDependencyRequest) { + if (msg.instanceName === undefined) { + const success = this.bundles.unsetServiceDependency(msg.bundleName, msg.serviceType); + if (success) { + return emptySuccess(); + } else { + return error("Service dependency couldn't be found."); + } + } else { + const instance = this.instances.getServiceInstance(msg.instanceName); + if (instance === undefined) { + return error("Service instance couldn't be found."); + } else { + return this.bundles.setServiceDependency(msg.bundleName, msg.instanceName, instance); + } + } + } + + private async isLoaded() { + return success(this.persist.isLoaded()); + } + + private async load(req: AuthenticatedRequest) { + return this.persist.load(req.password); + } + + private async getServices() { + return success(this.services.getServices()); + } + + private async isFirstStartup() { + return success(this.persist.isFirstStartup()); + } + + private async getSessionValue() { + return success(this.sessionValue); + } + + private async handleRequest(req: Request, res: Response) { + const message = req.body as { type: string }; + + const handler = this.routes[message.type]; + if (handler === undefined) { + res.status(404).json(error(`No route with type "${message.type}" found.`)); + return; + } + + if (this.authenticatedRoutes.includes(message.type)) { + const msg = req.body as AuthenticatedRequest; + if (this.persist.checkPassword(msg.password) === false) { + res.status(400).json(error("The password is invalid.")); + } + } + + const result = await handler(message); + res.json(result); + } + + mountApi() { + const app = this.nodecg.Router(); + this.nodecg.mount(app); + + app.post(dashboardApiPath, (req, res) => { + this.handleRequest(req, res); + }); + + this.nodecg.Replicant("bundles", "nodecg").on("change", () => this.verifySuccessfulMount()); + + this.nodecg.log.info("Succesfully mounted nodecg-io dashboard API."); + } + + /** + * A malicious bundle could try and mount a fake dashboard Api before nodecg-io-core and + * get access to e.g. the nodecg-io configuration password. + * + * To circumvent this at least a bit, we generate a random session value and serve it using a route. + * Once nodecg has loaded all bundles it will start its express server with all routes. + * + * To check if another bundle already registered a route on the same path, we call + * the getSessionValue route. If the response is the same as the stored session value only we know + * everything is fine. + * If not, we know that another bundle has already mounted the dashboard api and we'll stop + * nodecg to prevent any password leakage. + * + * Any bundle can still mess with nodecg-io by simply overwriting its source file, + * this is just a little layer of protection so that not any bundle can get + * the password with like three lines of code. + */ + private async verifySuccessfulMount() { + await new Promise((res) => setImmediate(res)); + + const payload = JSON.stringify({ + type: "getSessionValue", + }); + + const httpOptions = { + method: "POST", + path: dashboardApiPath, + hostname: "127.0.0.1", + port: this.nodecg.config.port, + headers: { + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(payload), + }, + }; + + const request = http.request(httpOptions, (resp) => { + let responseBody = ""; + resp.on("data", (data) => (responseBody += data)); + + resp.on("end", () => { + const result: Result = JSON.parse(responseBody); + + if (result.failed || result.result !== this.sessionValue) { + this.nodecg.log.error("Failed to verify dashboard API."); + process.exit(1); + } else { + this.nodecg.log.debug("Dashboard API verified."); + } + }); + + resp.on("error", (err) => { + this.nodecg.log.error(`Failed to verify dashboard API: ${err}`); + process.exit(1); + }); + }); + + request.write(payload); + request.end(); + } +} diff --git a/nodecg-io-core/extension/index.ts b/nodecg-io-core/extension/index.ts index 596df2cc3..dd4ac1b88 100644 --- a/nodecg-io-core/extension/index.ts +++ b/nodecg-io-core/extension/index.ts @@ -1,12 +1,13 @@ import { NodeCG } from "nodecg-types/types/server"; import { ServiceManager } from "./serviceManager"; import { BundleManager } from "./bundleManager"; -import { MessageManager } from "./messageManager"; import { InstanceManager } from "./instanceManager"; import { Service } from "./service"; import { PersistenceManager } from "./persistenceManager"; import { ServiceProvider } from "./serviceProvider"; import { Logger } from "./utils/logger"; +import { DashboardApi } from "./dashboardApi"; + /** * Main type of NodeCG extension that the core bundle exposes. * Contains references to all internal modules. @@ -24,13 +25,7 @@ module.exports = (nodecg: NodeCG): NodeCGIOCore => { const instanceManager = new InstanceManager(nodecg, serviceManager, bundleManager); const persistenceManager = new PersistenceManager(nodecg, serviceManager, instanceManager, bundleManager); - new MessageManager( - nodecg, - serviceManager, - instanceManager, - bundleManager, - persistenceManager, - ).registerMessageHandlers(); + new DashboardApi(nodecg, serviceManager, instanceManager, bundleManager, persistenceManager).mountApi(); registerExitHandlers(nodecg, bundleManager, instanceManager, serviceManager, persistenceManager); diff --git a/nodecg-io-core/extension/messageManager.ts b/nodecg-io-core/extension/messageManager.ts deleted file mode 100644 index 76a63bed3..000000000 --- a/nodecg-io-core/extension/messageManager.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { NodeCG } from "nodecg-types/types/server"; -import { emptySuccess, error, Result, success } from "./utils/result"; -import { InstanceManager } from "./instanceManager"; -import { BundleManager } from "./bundleManager"; -import { PersistenceManager } from "./persistenceManager"; -import { ServiceManager } from "./serviceManager"; - -export interface PasswordMessage { - password: string; -} - -export interface UpdateInstanceConfigMessage extends PasswordMessage { - instanceName: string; - config: unknown; -} - -export interface CreateServiceInstanceMessage extends PasswordMessage { - serviceType: string; - instanceName: string; -} - -export interface DeleteServiceInstanceMessage extends PasswordMessage { - instanceName: string; -} - -export interface SetServiceDependencyMessage extends PasswordMessage { - bundleName: string; - instanceName: string | undefined; - serviceType: string; -} - -/** - * MessageManager manages communication with the GUI and handles NodeCG messages to control the framework. - * Also adds a small wrapper around the actual functions them to make some things easier. - */ -export class MessageManager { - constructor( - private nodecg: NodeCG, - private services: ServiceManager, - private instances: InstanceManager, - private bundles: BundleManager, - private persist: PersistenceManager, - ) {} - - registerMessageHandlers(): void { - this.listenWithAuth("updateInstanceConfig", async (msg: UpdateInstanceConfigMessage) => { - const inst = this.instances.getServiceInstance(msg.instanceName); - if (inst === undefined) { - return error("Service instance doesn't exist."); - } else { - return await this.instances.updateInstanceConfig(msg.instanceName, msg.config); - } - }); - - this.listenWithAuth("createServiceInstance", async (msg: CreateServiceInstanceMessage) => { - return this.instances.createServiceInstance(msg.serviceType, msg.instanceName); - }); - - this.listenWithAuth("deleteServiceInstance", async (msg: DeleteServiceInstanceMessage) => { - return success(this.instances.deleteServiceInstance(msg.instanceName)); - }); - - this.listenWithAuth("setServiceDependency", async (msg: SetServiceDependencyMessage) => { - if (msg.instanceName === undefined) { - const success = this.bundles.unsetServiceDependency(msg.bundleName, msg.serviceType); - if (success) { - return emptySuccess(); - } else { - return error("Service dependency couldn't be found."); - } - } else { - const instance = this.instances.getServiceInstance(msg.instanceName); - if (instance === undefined) { - return error("Service instance couldn't be found."); - } else { - return this.bundles.setServiceDependency(msg.bundleName, msg.instanceName, instance); - } - } - }); - - this.listen("isLoaded", async () => { - return success(this.persist.isLoaded()); - }); - - this.listen("load", async (msg: PasswordMessage) => { - return this.persist.load(msg.password); - }); - - this.listen("getServices", async () => { - // We create a shallow copy of the service before we return them because if we return a reference - // another bundle could call this, get a reference and overwrite the createClient function on it - // and therefore get a copy of all credentials that are used for services. - // If we shallow copy the functions get excluded and other bundles can't overwrite it. - const result = this.services.getServices().map((svc) => Object.assign({}, svc)); - return success(result); - }); - - this.listen("isFirstStartup", async () => { - return success(this.persist.isFirstStartup()); - }); - } - - private listen(messageName: string, cb: (msg: M) => Promise>): void { - this.nodecg.listenFor(messageName, async (msg: M, ack) => { - const result = await cb(msg); - if (!ack?.handled) { - if (result.failed) { - ack?.(result.errorMessage, undefined); - } else { - ack?.(undefined, result.result); - } - } - }); - } - - private listenWithAuth( - messageName: string, - cb: (msg: M) => Promise>, - ): void { - this.listen(messageName, async (msg: M) => { - if (this.persist.checkPassword(msg.password)) { - return cb(msg); - } else { - return error("The password is invalid"); - } - }); - } -}