]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
fix(ocpp): reject a supervision URL key value the ws client cannot dial in OCPP 1...
authorVitor Coelho <6161176+VitorCoelho@users.noreply.github.com>
Fri, 30 Oct 2026 16:23:16 +0000 (16:23 +0000)
committerGitHub <noreply@github.com>
Fri, 30 Oct 2026 16:23:16 +0000 (16:23 +0000)
* Add validation for supervision URL in OCPP16 service

Reject invalid supervision URL values to prevent reconnect failures.

* Add tests for supervision URL OCPP configuration key

* fix(ocpp): also reject supervision URL schemes the ws client cannot dial

URL.canParse accepted values such as ftp://host, which were stored
and then failed on reconnect. Accept only the schemes the ws client
dials: ws, wss, http, https and ws+unix.

Signed-off-by: VitorCoelho <vitorc195@gmail.com>
* test(ocpp): cover supervision URL schemes in OCPP 1.6 ChangeConfiguration

Signed-off-by: VitorCoelho <vitorc195@gmail.com>
* fix(ocpp): reject a supervision URL with a fragment or an empty ws+unix socket path

The ws client throws for a URL with a fragment identifier and for a
ws+unix URL without a pathname, so OCPP 1.6 ChangeConfiguration accepted
and stored values that fail on reconnect. Reject them.

Signed-off-by: VitorCoelho <vitorc195@gmail.com>
* test(ocpp): cover fragments and ws+unix socket paths in OCPP 1.6 ChangeConfiguration

Signed-off-by: VitorCoelho <vitorc195@gmail.com>
* fix(ocpp): reject an empty fragment and a ws+unix URL without a request path separator

The charging station appends its identity to the supervision URL. After a
trailing '#' the identity becomes a fragment, which the ws client rejects,
and a ws+unix URL without a ':' between the socket path and the request
path gets the identity appended to the socket path, so the connection
fails. Reject both in the OCPP 1.6 ChangeConfiguration guard.

Signed-off-by: VitorCoelho <vitorc195@gmail.com>
* test(ocpp): cover an empty fragment and ws+unix URLs without a request path separator

Signed-off-by: VitorCoelho <vitorc195@gmail.com>
* fix(ocpp): reject supervision URL key values with whitespace, a query string or an ambiguous ws+unix separator

The OCPP 1.6 ChangeConfiguration value is stored as sent, but the guard
validated the parsed URL, which strips surrounding whitespace, tabs and
newlines, so 'ws://localhost:8888 ' was accepted and the reconnect then
failed. The station identity is appended to the URL, so after a '?' it
lands in the query, and ws keeps only the first two ':'-separated parts
of a ws+unix pathname. Reject these; the query and ws+unix rules match
SupervisionUrlSchema.

Signed-off-by: VitorCoelho <vitorc195@gmail.com>
* test(ocpp): cover whitespace, query strings and ws+unix separators in the supervision URL key

Signed-off-by: VitorCoelho <vitorc195@gmail.com>
* fix(ocpp): accept one-character ws+unix socket paths and require a '/' request path

A relative socket path such as 's' in 'ws+unix:s:/OCPP16' is valid, so
require a socket path other than '/' instead of two characters. A request
path without a leading '/' (ws+unix:s:OCPP16) makes the server answer 400,
so require it to be empty or to start with '/'. Same rule as
SupervisionUrlSchema in #2168.

Signed-off-by: VitorCoelho <vitorc195@gmail.com>
* test(ocpp): cover one-character ws+unix socket paths and request paths without '/'

Regressions for the supervision URL key fix: 'ws+unix:s:/OCPP16' and
'ws+unix:./s:/OCPP16' are accepted, 'ws+unix:s:OCPP16' and
'ws+unix:///tmp/simulator.sock:OCPP16' are rejected.

Signed-off-by: VitorCoelho <vitorc195@gmail.com>
* refactor(ocpp): validate the supervision URL key with the shared SupervisionUrlSchema

The OCPP 1.6 ChangeConfiguration guard duplicated the supervision endpoint
policy (schemes, '#' and '?' delimiters, ws+unix socket and request path)
that SupervisionUrlSchema owns since #2160 and #2168. Consume the shared
schema and keep only the OCPP-specific part locally: the key stores the raw
value, so whitespace and control characters are rejected before the schema
check, which would otherwise strip them. Accepted values are still stored
verbatim. No behaviour change.

Signed-off-by: VitorCoelho <vitorc195@gmail.com>
---------

Signed-off-by: VitorCoelho <vitorc195@gmail.com>
Co-authored-by: Jérôme Benoit <jerome.benoit@piment-noir.org>
src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.ts
tests/charging-station/ocpp/1.6/OCPP16IncomingRequestService-Configuration.test.ts

index 740bbf54c5c7004806f17f08e8be143a14501371..cf4e7d68d1790126fb4fa78d6dc5d0b33b125366 100644 (file)
@@ -133,6 +133,7 @@ import {
   isValidRandomIntBounds,
   logger,
   sleep,
+  SupervisionUrlSchema,
   truncateId,
 } from '../../../utils/index.js'
 import { AuthContext, type DifferentialAuthEntry, OCPPAuthServiceFactory } from '../auth/index.js'
@@ -158,6 +159,21 @@ import { OCPP16ServiceUtils } from './OCPP16ServiceUtils.js'
 
 const moduleName = 'OCPP16IncomingRequestService'
 
+/**
+ * Whether a value is a URL the supervision WebSocket client can dial once the
+ * charging station identity is appended to it. The endpoint policy (schemes,
+ * fragment and query delimiters, `ws+unix:` socket and request path) is the
+ * shared `SupervisionUrlSchema`. The OCPP key stores the value as sent, so
+ * whitespace and control characters are rejected here first: `z.url()` strips
+ * them from its output, but the raw value would still be dialed with them.
+ * @param value - Candidate supervision URL.
+ * @returns `true` when the raw value has no whitespace or control characters
+ * and satisfies `SupervisionUrlSchema`.
+ */
+const isSupportedSupervisionUrl = (value: string): boolean =>
+  // eslint-disable-next-line no-control-regex -- control characters are rejected on purpose
+  !/[\s\u0000-\u001F\u007F]/.test(value) && SupervisionUrlSchema.safeParse(value).success
+
 const requireOCPP16ChargePointStatus = (status: string | undefined): OCPP16ChargePointStatus => {
   const ocpp16Status = getEnumStringValue(OCPP16ChargePointStatus, status)
   if (ocpp16Status == null) {
@@ -1183,6 +1199,15 @@ export class OCPP16IncomingRequestService extends OCPPIncomingRequestService<OCP
           return OCPP16Constants.OCPP_CONFIGURATION_RESPONSE_REJECTED
         }
       }
+      // The supervision URL key is dialed by the WebSocket client on reconnect;
+      // reject a value it cannot dial instead of persisting it.
+      if (
+        chargingStation.stationInfo?.supervisionUrlOcppConfiguration === true &&
+        keyToChange.key === chargingStation.stationInfo.supervisionUrlOcppKey &&
+        !isSupportedSupervisionUrl(value)
+      ) {
+        return OCPP16Constants.OCPP_CONFIGURATION_RESPONSE_REJECTED
+      }
       let valueChanged = false
       if (keyToChange.value !== value) {
         setConfigurationKeyValue(chargingStation, key, value, true)
index ef962416a26cad71abb459a6e65e87ac15dbdb0b..f08b19a25c9dda07f8e96c1f8d1f012f2b2f89da 100644 (file)
@@ -178,6 +178,152 @@ await describe('OCPP16IncomingRequestService — Configuration', async () => {
     assert.deepStrictEqual(restartedConnectorIds, [1, 3])
   })
 
+  await describe('supervision URL OCPP configuration key', async () => {
+    const supervisionUrlOcppKey = 'ConnectionUrl'
+    const supervisionUrl = 'ws://localhost:9999/'
+
+    beforeEach(() => {
+      const { station } = testContext
+      if (station.stationInfo != null) {
+        station.stationInfo.supervisionUrlOcppConfiguration = true
+        station.stationInfo.supervisionUrlOcppKey = supervisionUrlOcppKey
+      }
+      upsertConfigurationKey(station, supervisionUrlOcppKey, supervisionUrl)
+    })
+
+    await it('should reject a non-URL value without mutating the key', () => {
+      // Arrange
+      const { station, testableService } = testContext
+      const request: ChangeConfigurationRequest = {
+        key: supervisionUrlOcppKey,
+        value: 'not a url',
+      }
+
+      // Act
+      const response = testableService.handleRequestChangeConfiguration(station, request)
+
+      // Assert
+      assert.strictEqual(response.status, OCPP16ConfigurationStatus.REJECTED)
+      assert.strictEqual(getConfigurationKey(station, supervisionUrlOcppKey)?.value, supervisionUrl)
+    })
+
+    await it('should reject a non-URL value via the changeConfiguration seam', () => {
+      // Arrange
+      const { incomingRequestService, station } = testContext
+
+      // Act
+      const status = incomingRequestService.changeConfiguration(
+        station,
+        supervisionUrlOcppKey,
+        'not a url'
+      )
+
+      // Assert
+      assert.strictEqual(status, OCPP16ConfigurationStatus.REJECTED)
+      assert.strictEqual(getConfigurationKey(station, supervisionUrlOcppKey)?.value, supervisionUrl)
+    })
+
+    await it('should accept a valid URL value', () => {
+      // Arrange
+      const { station, testableService } = testContext
+      const request: ChangeConfigurationRequest = {
+        key: supervisionUrlOcppKey,
+        value: 'ws://localhost:8888/',
+      }
+
+      // Act
+      const response = testableService.handleRequestChangeConfiguration(station, request)
+
+      // Assert
+      assert.strictEqual(response.status, OCPP16ConfigurationStatus.ACCEPTED)
+      assert.strictEqual(
+        getConfigurationKey(station, supervisionUrlOcppKey)?.value,
+        'ws://localhost:8888/'
+      )
+    })
+
+    await it('should reject a URL whose scheme the WebSocket client cannot dial', () => {
+      // Arrange
+      const { station, testableService } = testContext
+      const request: ChangeConfigurationRequest = {
+        key: supervisionUrlOcppKey,
+        value: 'ftp://localhost:8888/',
+      }
+
+      // Act
+      const response = testableService.handleRequestChangeConfiguration(station, request)
+
+      // Assert
+      assert.strictEqual(response.status, OCPP16ConfigurationStatus.REJECTED)
+      assert.strictEqual(getConfigurationKey(station, supervisionUrlOcppKey)?.value, supervisionUrl)
+    })
+
+    await it('should reject a URL the WebSocket client cannot dial despite a supported scheme', () => {
+      // Arrange
+      const { station, testableService } = testContext
+      const urls = [
+        'ws://localhost:8888/#fragment',
+        'ws://localhost:8888/#',
+        'ws://localhost:8888/OCPP16?token=secret',
+        'ws://localhost:8888/OCPP16?',
+        'ws://localhost:8888 ',
+        ' ws://localhost:8888/',
+        'ws://localhost:8888/OC\tPP16',
+        'ws+unix://',
+        'ws+unix:',
+        'ws+unix:///tmp/simulator.sock',
+        'ws+unix:///:/OCPP16',
+        'ws+unix:///tmp/simulator.sock:/OC:PP16',
+        'ws+unix:///tmp/simulator.sock:OCPP16',
+        'ws+unix:s:OCPP16',
+      ]
+
+      for (const value of urls) {
+        // Act
+        const response = testableService.handleRequestChangeConfiguration(station, {
+          key: supervisionUrlOcppKey,
+          value,
+        })
+
+        // Assert
+        assert.strictEqual(response.status, OCPP16ConfigurationStatus.REJECTED, value)
+        assert.strictEqual(
+          getConfigurationKey(station, supervisionUrlOcppKey)?.value,
+          supervisionUrl,
+          value
+        )
+      }
+    })
+
+    await it('should accept every scheme the WebSocket client can dial', () => {
+      // Arrange
+      const { station, testableService } = testContext
+      const urls = [
+        'ws://localhost:8888/',
+        'wss://localhost:8888/',
+        'http://localhost:8888/',
+        'https://localhost:8888/',
+        'ws+unix:///tmp/simulator.sock:/OCPP16',
+        'ws+unix:///tmp/simulator.sock:/',
+        'ws+unix:///tmp/simulator.sock:',
+        'ws+unix:s:/OCPP16',
+        'ws+unix:./s:/OCPP16',
+      ]
+
+      for (const value of urls) {
+        // Act
+        const response = testableService.handleRequestChangeConfiguration(station, {
+          key: supervisionUrlOcppKey,
+          value,
+        })
+
+        // Assert
+        assert.strictEqual(response.status, OCPP16ConfigurationStatus.ACCEPTED, value)
+        assert.strictEqual(getConfigurationKey(station, supervisionUrlOcppKey)?.value, value)
+      }
+    })
+  })
+
   // ---------------------------------------------------------------------------
   // changeConfiguration (local Web UI seam over §5.4)
   // ---------------------------------------------------------------------------