UNPKG

homebridge-philips-hue-sync-box

Version:
248 lines (229 loc) 7.91 kB
import http from 'http'; import crypto from 'node:crypto'; import { HueSyncBoxPlatform } from './platform.js'; import { HTTP_STATUS_OK, HTTP_STATUS_UNAUTHORIZED, HTTP_STATUS_BAD_REQUEST, HTTP_STATUS_PAYLOAD_TOO_LARGE, HTTP_STATUS_METHOD_NOT_ALLOWED, HTTP_STATUS_INTERNAL_ERROR, MIN_API_TOKEN_LENGTH, MAX_API_BODY_BYTES, API_REQUEST_TIMEOUT_MS, } from './lib/constants.js'; import { isPlainObject, validateExecution, validateHue, } from './lib/validation.js'; export class ApiServer { private readonly platform: HueSyncBoxPlatform; private server?: http.Server; constructor(platform: HueSyncBoxPlatform) { this.platform = platform; } public start() { const { apiServerPort, apiServerToken } = this.platform.config; if (!apiServerPort) { this.platform.log.error( 'API server cannot start due to missing configuration.' ); return; } if ( typeof apiServerToken !== 'string' || apiServerToken.length < MIN_API_TOKEN_LENGTH ) { this.platform.log.error( `API server cannot start: apiServerToken must be a string at least ${MIN_API_TOKEN_LENGTH} characters long.` ); return; } try { this.server = http.createServer((request, response) => { request.on('error', e => this.platform.log.error('API - Error received.', e) ); response.on('error', () => this.platform.log.error('API - Error sending the response.') ); // Authorization is checked before any body bytes are read, so an // unauthenticated caller can never force the server to buffer data. if (!this.isAuthorized(request, apiServerToken)) { this.platform.log.debug('Authorization header missing or invalid.'); // No 'data' listener is attached, so the request stream never // resumes: any body the caller keeps sending is bounded by the // OS socket receive buffer (via normal TCP flow control), not by // application memory. That's what actually stops the DoS - // destroying the socket here would race the response write and // reset the connection before the client reads it. this.sendError(response, HTTP_STATUS_UNAUTHORIZED, 'Unauthorized'); return; } const payload: Buffer[] = []; let receivedBytes = 0; let rejected = false; request .on('data', (chunk: Buffer) => { if (rejected) { return; } receivedBytes += chunk.length; if (receivedBytes > MAX_API_BODY_BYTES) { rejected = true; // Remaining chunks are still drained (and discarded) by this // same 'data' handler rather than buffered, bounding memory; // the server's requestTimeout bounds how long that continues. this.sendError( response, HTTP_STATUS_PAYLOAD_TOO_LARGE, 'Payload too large.' ); return; } payload.push(chunk); }) .on('end', async () => { if (rejected) { return; } switch (request.method) { case 'GET': await this.handleGet(response); break; case 'POST': await this.handlePost(payload, response); break; default: this.platform.log.debug('No action matched.'); response.statusCode = HTTP_STATUS_METHOD_NOT_ALLOWED; response.end(); } }); }); // Without this listener, an async server error (e.g. EADDRINUSE from a // second Sync Box instance defaulting to the same port) is rethrown by // Node as an uncaught exception, which Homebridge treats as fatal to // the entire process rather than just this plugin. this.server.on('error', e => { this.platform.log.error('API server encountered an error.', e); }); // listen() returns before the socket is actually bound, so logging // success here (rather than from 'listening') would claim the server // is up even when it fails to bind, e.g. EADDRINUSE. this.server.on('listening', () => { this.platform.log.info('API server started.'); }); this.server.listen(apiServerPort, '0.0.0.0'); this.server.requestTimeout = API_REQUEST_TIMEOUT_MS; } catch (e) { this.platform.log.error('API could not be started:', e); } } private isAuthorized( request: http.IncomingMessage, apiServerToken: string ): boolean { const provided = request.headers['authorization']; if (typeof provided !== 'string') { return false; } const providedBuffer = Buffer.from(provided); const tokenBuffer = Buffer.from(apiServerToken); if (providedBuffer.length !== tokenBuffer.length) { return false; } return crypto.timingSafeEqual(providedBuffer, tokenBuffer); } private sendJson( response: http.ServerResponse, statusCode: number, body: unknown ) { response.statusCode = statusCode; response.end(JSON.stringify(body)); } private sendError( response: http.ServerResponse, statusCode: number, message: string ) { this.sendJson(response, statusCode, { error: message }); } private async handleGet(response: http.ServerResponse) { try { this.platform.log.debug('GET request received.'); const state = await this.platform.client.getState(); response.setHeader('Content-Type', 'application/json'); this.sendJson(response, HTTP_STATUS_OK, state); } catch (e) { this.platform.log.error('Error while getting the state.', e); this.sendError( response, HTTP_STATUS_INTERNAL_ERROR, 'An error occurred while processing your request.' ); } } private async handlePost(payload: Buffer[], response: http.ServerResponse) { if (!payload.length) { this.sendError(response, HTTP_STATUS_BAD_REQUEST, 'Body missing.'); return; } let body: unknown; try { body = JSON.parse(Buffer.concat(payload).toString()); } catch (e) { this.platform.log.error('Body malformed.', e); this.sendError(response, HTTP_STATUS_BAD_REQUEST, 'Body malformed.'); return; } if (!isPlainObject(body)) { this.sendError( response, HTTP_STATUS_BAD_REQUEST, 'Body must be a JSON object.' ); return; } let execution; if (Object.hasOwn(body, 'execution')) { const result = validateExecution(body.execution); if (!result.ok) { this.sendError(response, HTTP_STATUS_BAD_REQUEST, result.error); return; } execution = result.value; } let hue; if (Object.hasOwn(body, 'hue')) { // Validated against live device state so only a groupId that actually // exists can be forwarded. const currentState = await this.platform.client.getState(); const result = validateHue(body.hue, currentState); if (!result.ok) { this.sendError(response, HTTP_STATUS_BAD_REQUEST, result.error); return; } hue = result.value; } try { if (execution) { await this.platform.client.updateExecution(execution); } if (hue) { await this.platform.client.updateHue(hue); } const newState = await this.platform.client.getState(); this.sendJson(response, HTTP_STATUS_OK, newState); } catch (e) { this.platform.log.error('Error while updating the state.', e); this.sendError( response, HTTP_STATUS_INTERNAL_ERROR, 'An error occurred while processing your request.' ); } } }