]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
fix: avoid to leak handlers in UI server
authorJérôme Benoit <jerome.benoit@piment-noir.org>
Thu, 14 Aug 2025 18:18:47 +0000 (20:18 +0200)
committerJérôme Benoit <jerome.benoit@piment-noir.org>
Thu, 14 Aug 2025 18:18:47 +0000 (20:18 +0200)
Signed-off-by: Jérôme Benoit <jerome.benoit@piment-noir.org>
src/charging-station/ocpp/OCPPServiceUtils.ts
src/charging-station/ui-server/UIHttpServer.ts
src/charging-station/ui-server/UIWebSocketServer.ts
src/utils/Utils.ts

index ab01c167e755454eed39ccdf73e445a5012ba239..69a3e5d6ca3761d933ade4eab4e9d6a32d328073 100644 (file)
@@ -286,14 +286,29 @@ export const ajvErrorsToErrorType = (errors: ErrorObject[] | null | undefined):
 
 // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters
 export const convertDateToISOString = <T extends JsonType>(object: T): void => {
-  // eslint-disable-next-line @typescript-eslint/no-for-in-array
-  for (const key in object) {
-    // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion, @typescript-eslint/no-non-null-assertion
-    if (isDate(object![key])) {
-      ;(object[key] as unknown as string) = (object[key] as Date).toISOString()
-      // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion, @typescript-eslint/no-non-null-assertion
-    } else if (typeof object![key] === 'object' && object[key] !== null) {
-      convertDateToISOString<T>(object[key] as T)
+  for (const [key, value] of Object.entries(object as Record<string, unknown>)) {
+    if (isDate(value)) {
+      try {
+        ;(object as Record<string, unknown>)[key] = value.toISOString()
+      } catch {
+        // Ignore date conversion error
+      }
+    } else if (Array.isArray(value)) {
+      for (let i = 0; i < value.length; i++) {
+        // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
+        const item = value[i]
+        if (isDate(item)) {
+          try {
+            value[i] = item.toISOString() as unknown as typeof item
+          } catch {
+            // Ignore date conversion error
+          }
+        } else if (typeof item === 'object' && item !== null) {
+          convertDateToISOString(item as T)
+        }
+      }
+    } else if (typeof value === 'object' && value !== null) {
+      convertDateToISOString<T>(value as T)
     }
   }
 }
@@ -335,7 +350,7 @@ export const buildMeterValue = (
             Number.parseInt(socSampledValueTemplate.value),
             socSampledValueTemplate.fluctuationPercent ?? Constants.DEFAULT_FLUCTUATION_PERCENT
           )
-          : randomInt(socMinimumValue, socMaximumValue)
+          : randomInt(socMinimumValue, socMaximumValue + 1)
         meterValue.sampledValue.push(
           buildSampledValue(socSampledValueTemplate, socSampledValueTemplateValue)
         )
@@ -979,8 +994,8 @@ export const buildMeterValue = (
               }: phase ${
                 // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
                 meterValue.sampledValue[sampledValuesPerPhaseIndex].phase
-              }, connector id ${connectorId.toString()}, transaction id $
-              connector?.transactionId?.toString()}, value: ${connectorMinimumAmperage.toString()}/${
+                // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
+              }, connector id ${connectorId.toString()}, transaction id ${connector?.transactionId?.toString()}, value: ${connectorMinimumAmperage.toString()}/${
                 meterValue.sampledValue[sampledValuesPerPhaseIndex].value
               }/${connectorMaximumAmperage.toString()}`
             )
@@ -1143,7 +1158,7 @@ const getLimitFromSampledValueTemplateCustomValue = (
     },
     ...options,
   }
-  const parsedValue = Number.parseInt(value ?? '')
+  const parsedValue = Number.parseFloat(value ?? '')
   if (options.limitationEnabled) {
     return max(
       min(
index b1357822a2045790ebba995a6bc9d1a7d6631f31..2004752cc922457ac786989777f15dc9ba9f88d5 100644 (file)
@@ -16,7 +16,6 @@ import {
   type UIServerConfiguration,
 } from '../../types/index.js'
 import {
-  Constants,
   generateUUID,
   isNotEmptyString,
   JSONStringify,
@@ -98,29 +97,52 @@ export class UIHttpServer extends AbstractUIServer {
           .end(`${StatusCodes.UNAUTHORIZED.toString()} Unauthorized`)
         res.destroy()
         req.destroy()
+        return
       }
-    })
-    // Expected request URL pathname: /ui/:version/:procedureName
-    const [protocol, version, procedureName] = req.url?.split('/').slice(1) as [
-      Protocol,
-      ProtocolVersion,
-      ProcedureName
-    ]
-    const uuid = generateUUID()
-    this.responseHandlers.set(uuid, res)
-    try {
-      const fullProtocol = `${protocol}${version}`
-      if (!isProtocolAndVersionSupported(fullProtocol)) {
-        throw new BaseError(`Unsupported UI protocol version: '${fullProtocol}'`)
-      }
-      this.registerProtocolVersionUIService(version)
-      req.on('error', error => {
-        logger.error(
-          `${this.logPrefix(moduleName, 'requestListener.req.onerror')} Error on HTTP request:`,
-          error
-        )
+
+      const uuid = generateUUID()
+      this.responseHandlers.set(uuid, res)
+      res.on('close', () => {
+        this.responseHandlers.delete(uuid)
       })
-      if (req.method === HttpMethods.POST) {
+      try {
+        // Expected request URL pathname: /ui/:version/:procedureName
+        const rawUrl = req.url ?? ''
+        const { pathname } = new URL(rawUrl, 'http://localhost')
+        const parts = pathname.split('/').filter(Boolean)
+        if (parts.length < 3) {
+          throw new BaseError(
+            `Malformed URL path: '${pathname}' (expected /ui/:version/:procedureName)`
+          )
+        }
+        const [protocol, version, procedureName] = parts as [
+          Protocol,
+          ProtocolVersion,
+          ProcedureName
+        ]
+        const fullProtocol = `${protocol}${version}`
+        if (!isProtocolAndVersionSupported(fullProtocol)) {
+          throw new BaseError(`Unsupported UI protocol version: '${fullProtocol}'`)
+        }
+        this.registerProtocolVersionUIService(version)
+
+        req.on('error', error => {
+          logger.error(
+            `${this.logPrefix(moduleName, 'requestListener.req.onerror')} Error on HTTP request:`,
+            error
+          )
+          if (!res.headersSent) {
+            this.sendResponse(this.buildProtocolResponse(uuid, { status: ResponseStatus.FAILURE }))
+          } else {
+            this.responseHandlers.delete(uuid)
+          }
+        })
+
+        if (req.method !== HttpMethods.POST) {
+          // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
+          throw new BaseError(`Unsupported HTTP method: '${req.method}'`)
+        }
+
         const bodyBuffer: Uint8Array[] = []
         req
           .on('data', (chunk: Uint8Array) => {
@@ -140,28 +162,44 @@ export class UIHttpServer extends AbstractUIServer {
               )
               return
             }
-            this.uiServices
-              .get(version)
-              ?.requestHandler(this.buildProtocolRequest(uuid, procedureName, requestPayload))
+            const service = this.uiServices.get(version)
+            if (service == null || typeof service.requestHandler !== 'function') {
+              this.sendResponse(
+                this.buildProtocolResponse(uuid, { status: ResponseStatus.FAILURE })
+              )
+              return
+            }
+            // eslint-disable-next-line promise/no-promise-in-callback
+            service
+              .requestHandler(this.buildProtocolRequest(uuid, procedureName, requestPayload))
               .then((protocolResponse?: ProtocolResponse) => {
                 if (protocolResponse != null) {
                   this.sendResponse(protocolResponse)
+                } else {
+                  this.sendResponse(
+                    this.buildProtocolResponse(uuid, { status: ResponseStatus.SUCCESS })
+                  )
                 }
                 return undefined
               })
-              .catch(Constants.EMPTY_FUNCTION)
+              .catch((error: unknown) => {
+                logger.error(
+                  `${this.logPrefix(moduleName, 'requestListener.handler.catch')} UI service handler error:`,
+                  error
+                )
+                this.sendResponse(
+                  this.buildProtocolResponse(uuid, { status: ResponseStatus.FAILURE })
+                )
+              })
           })
-      } else {
-        // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
-        throw new BaseError(`Unsupported HTTP method: '${req.method}'`)
+      } catch (error) {
+        logger.error(
+          `${this.logPrefix(moduleName, 'requestListener')} Handle HTTP request error:`,
+          error
+        )
+        this.sendResponse(this.buildProtocolResponse(uuid, { status: ResponseStatus.FAILURE }))
       }
-    } catch (error) {
-      logger.error(
-        `${this.logPrefix(moduleName, 'requestListener')} Handle HTTP request error:`,
-        error
-      )
-      this.sendResponse(this.buildProtocolResponse(uuid, { status: ResponseStatus.FAILURE }))
-    }
+    })
   }
 
   private responseStatusToStatusCode (status: ResponseStatus): StatusCodes {
index dd57883e88b1bbe3273452ab3a43d0b3b7222608..0a44a7999d892c907cb65cb0afcb89a1e95e1d74 100644 (file)
@@ -92,16 +92,19 @@ export class UIWebSocketServer extends AbstractUIServer {
 
   public start (): void {
     this.webSocketServer.on('connection', (ws: WebSocket, _req: IncomingMessage): void => {
-      if (!isProtocolAndVersionSupported(ws.protocol)) {
+      const protocol = ws.protocol
+      const protocolAndVersion = getProtocolAndVersion(protocol)
+      if (protocolAndVersion == null || !isProtocolAndVersionSupported(protocol)) {
         logger.error(
           `${this.logPrefix(
             moduleName,
             'start.server.onconnection'
-          )} Unsupported UI protocol version: '${ws.protocol}'`
+          )} Unsupported UI protocol version: '${protocol}'`
         )
         ws.close(WebSocketCloseEventStatusCode.CLOSE_PROTOCOL_ERROR)
+        return
       }
-      const [, version] = getProtocolAndVersion(ws.protocol)
+      const [, version] = protocolAndVersion
       this.registerProtocolVersionUIService(version)
       ws.on('message', rawData => {
         const request = this.validateRawDataRequest(rawData)
@@ -121,6 +124,9 @@ export class UIWebSocketServer extends AbstractUIServer {
             return undefined
           })
           .catch(Constants.EMPTY_FUNCTION)
+          .finally(() => {
+            this.responseHandlers.delete(requestId)
+          })
       })
       ws.on('error', error => {
         logger.error(`${this.logPrefix(moduleName, 'start.ws.onerror')} WebSocket error:`, error)
@@ -134,10 +140,15 @@ export class UIWebSocketServer extends AbstractUIServer {
             code
           )}' - '${reason.toString()}'`
         )
+        for (const [responseId, responseHandlerWs] of this.responseHandlers) {
+          if (responseHandlerWs === ws) this.responseHandlers.delete(responseId)
+        }
       })
     })
     this.httpServer.on('connect', (req: IncomingMessage, socket: Duplex, _head: Buffer) => {
-      if (req.headers.connection !== 'Upgrade' || req.headers.upgrade !== 'websocket') {
+      const connectionHeader = req.headers.connection ?? ''
+      const upgradeHeader = req.headers.upgrade ?? ''
+      if (!/upgrade/i.test(connectionHeader) || !/^websocket$/i.test(upgradeHeader)) {
         socket.write(`HTTP/1.1 ${StatusCodes.BAD_REQUEST.toString()} Bad Request\r\n\r\n`)
         socket.destroy()
       }
@@ -154,6 +165,7 @@ export class UIWebSocketServer extends AbstractUIServer {
       }
       socket.on('error', onSocketError)
       this.authenticate(req, err => {
+        socket.removeListener('error', onSocketError)
         if (err != null) {
           socket.write(`HTTP/1.1 ${StatusCodes.UNAUTHORIZED.toString()} Unauthorized\r\n\r\n`)
           socket.destroy()
@@ -173,7 +185,6 @@ export class UIWebSocketServer extends AbstractUIServer {
           )
         }
       })
-      socket.removeListener('error', onSocketError)
     })
     this.startHttpServer()
   }
@@ -241,6 +252,29 @@ export class UIWebSocketServer extends AbstractUIServer {
       return false
     }
 
+    if (typeof request[1] !== 'string') {
+      logger.error(
+        `${this.logPrefix(
+          moduleName,
+          'validateRawDataRequest'
+        )} UI protocol request procedure field must be a string:`,
+        request
+      )
+      return false
+    }
+
+    // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+    if (!(typeof request[2] === 'object' && request[2] !== null)) {
+      logger.error(
+        `${this.logPrefix(
+          moduleName,
+          'validateRawDataRequest'
+        )} UI protocol request payload field must be an object or an array:`,
+        request
+      )
+      return false
+    }
+
     return request
   }
 }
index 285927bd1812a1d26e8e590560fb4ec11be3639e..3e0f59ccb6fa3c289e8b2b4421e94ab20e0094e7 100644 (file)
@@ -61,7 +61,6 @@ export const has = (property: PropertyKey, object: null | object | undefined): b
 const type = (value: unknown): string => {
   if (value === null) return 'Null'
   if (value === undefined) return 'Undefined'
-  if (typeof value === 'string') return 'String'
   if (Number.isNaN(value)) return 'NaN'
   if (Array.isArray(value)) return 'Array'
   return Object.prototype.toString.call(value).slice(8, -1)
@@ -69,7 +68,7 @@ const type = (value: unknown): string => {
 
 export const isEmpty = (value: unknown): boolean => {
   const valueType = type(value)
-  if (['NaN', 'Null', 'Number', 'Undefined'].includes(valueType)) {
+  if (['BigInt', 'Boolean', 'NaN', 'Null', 'Number', 'Undefined'].includes(valueType)) {
     return false
   }
   if (!value) return true