From f8487b2d256fa3e476a4544a750b23af0152c03c Mon Sep 17 00:00:00 2001 From: Vitor Coelho <6161176+VitorCoelho@users.noreply.github.com> Date: Fri, 30 Oct 2026 16:23:16 +0000 Subject: [PATCH] fix(ocpp): reject a supervision URL key value the ws client cannot dial in OCPP 1.6 ChangeConfiguration (#2159) MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit * 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 * test(ocpp): cover supervision URL schemes in OCPP 1.6 ChangeConfiguration Signed-off-by: VitorCoelho * 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 * test(ocpp): cover fragments and ws+unix socket paths in OCPP 1.6 ChangeConfiguration Signed-off-by: VitorCoelho * 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 * test(ocpp): cover an empty fragment and ws+unix URLs without a request path separator Signed-off-by: VitorCoelho * 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 * test(ocpp): cover whitespace, query strings and ws+unix separators in the supervision URL key Signed-off-by: VitorCoelho * 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 * 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 * 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 --------- Signed-off-by: VitorCoelho Co-authored-by: Jérôme Benoit --- .../ocpp/1.6/OCPP16IncomingRequestService.ts | 25 +++ ...comingRequestService-Configuration.test.ts | 146 ++++++++++++++++++ 2 files changed, 171 insertions(+) diff --git a/src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.ts b/src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.ts index 740bbf54..cf4e7d68 100644 --- a/src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.ts +++ b/src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.ts @@ -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 { 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) // --------------------------------------------------------------------------- -- 2.53.0