From 635362f66e7a5d56850ed1fb2c9714f04b9c0953 Mon Sep 17 00:00:00 2001 From: Vitor Coelho <6161176+VitorCoelho@users.noreply.github.com> Date: Fri, 30 Oct 2026 14:59:19 +0000 Subject: [PATCH] fix(simulator): reject supervision URLs that break once the station identity is appended (#2168) MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit * fix(simulator): reject supervision URLs that break once the station identity is appended The charging station dials the supervision URL with its identity appended. 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 (ENOTDIR). Reject both in SupervisionUrlSchema. Signed-off-by: VitorCoelho * test(utils): check the supervision URL schema against the URL the station dials Signed-off-by: VitorCoelho * docs(readme): document the ws+unix supervision URL form Signed-off-by: VitorCoelho * fix(simulator): reject supervision URLs with a query string or an ambiguous ws+unix separator The charging station identity is appended to the supervision URL, so after a '?' it lands in the query (ws://h/ocpp?a=b dials /ocpp with query 'a=b/CS-1'). ws keeps only the first two ':'-separated parts of a ws+unix pathname, so 'ws+unix:///s.sock:/oc:pp' dials /oc, and 'ws+unix:///:/ocpp' dials the socket '/'. Reject these, checked against real TCP and Unix-socket servers. Signed-off-by: VitorCoelho * test(utils): cover query strings and extra ws+unix separators in supervision URLs Signed-off-by: VitorCoelho * docs(readme): document the query string and ws+unix separator rules for supervision URLs Signed-off-by: VitorCoelho * fix(simulator): 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 '/'. Signed-off-by: VitorCoelho * test(utils): cover one-character ws+unix socket paths and request paths without '/' Regressions for the SupervisionUrlSchema 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 --------- Signed-off-by: VitorCoelho Co-authored-by: Jérôme Benoit --- README.md | 2 +- src/utils/ConfigurationSchema.ts | 36 +++++++++--- tests/utils/ConfigurationSchema.test.ts | 74 ++++++++++++++++++++----- 3 files changed, 90 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index a14a6820..0b228549 100644 --- a/README.md +++ b/README.md @@ -180,7 +180,7 @@ The `/configurations` subdirectory is created by the runtime when pe | Key | Value(s) | Default Value | Value type | Description | | -------------------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | $schemaVersion | 2 | 2 | integer | Configuration schema version. Set to 2. Files without this field are migrated from v0; deprecated keys are remapped on every load. | -| supervisionUrls | | [] | string \| string[] | string or strings array containing global connection URIs to OCPP-J servers (`ws://`, `wss://`, `http://`, `https://` or `ws+unix://`) | +| supervisionUrls | | [] | string \| string[] | string or strings array containing global connection URIs to OCPP-J servers (`ws://`, `wss://`, `http://`, `https://` or `ws+unix://`; no `#` fragment or `?` query, since the charging station identity is appended to the URL; a `ws+unix://` URL separates the socket path from the request path with one `:`, e.g. `ws+unix:///path/to/socket.sock:/OCPP16`) | | supervisionUrlDistribution | round-robin/random/charging-station-affinity | charging-station-affinity | string | supervision urls distribution policy to simulated charging stations | | log | | {
"enabled": true,
"file": "logs/combined.log",
"errorFile": "logs/error.log",
"statisticsInterval": 60,
"level": "info",
"console": false,
"format": "simple",
"rotate": true
} | {
enabled?: boolean;
file?: string;
errorFile?: string;
statisticsInterval?: number;
level?: string;
console?: boolean;
format?: string;
rotate?: boolean;
maxFiles?: string \| number;
maxSize?: string \| number;
} | Log configuration section:
- _enabled_: enable logging
- _file_: log file relative path
- _errorFile_: error log file relative path
- _statisticsInterval_: seconds between charging stations statistics output in the logs
- _level_: emerg/alert/crit/error/warning/notice/info/debug [winston](https://github.com/winstonjs/winston) logging level
- _console_: output logs on the console
- _format_: [winston](https://github.com/winstonjs/winston) log format
- _rotate_: enable daily log files rotation
- _maxFiles_: maximum number of log files: https://github.com/winstonjs/winston-daily-rotate-file#options
- _maxSize_: maximum size of log files in bytes, or units of kb, mb, and gb: https://github.com/winstonjs/winston-daily-rotate-file#options | | worker | | {
"processType": "workerSet",
"startDelay": 500,
"elementAddDelay": 0,
"elementsPerWorker": 'auto',
"poolMinSize": 4,
"poolMaxSize": 16
} | {
processType?: WorkerProcessType;
startDelay?: number;
elementAddDelay?: number;
elementsPerWorker?: number \| 'auto' \| 'all';
poolMinSize?: number;
poolMaxSize?: number;
resourceLimits?: ResourceLimits;
} | Worker configuration section:
- _processType_: worker threads process type (`workerSet`/`fixedPool`/`dynamicPool`)
- _startDelay_: milliseconds to wait at worker threads startup (only for `workerSet` worker threads process type)
- _elementAddDelay_: milliseconds to wait between charging station add
- _elementsPerWorker_: number of charging stations per worker threads for the `workerSet` process type (`auto` means (number of stations) / (number of CPUs) \* 1.5 if (number of stations) > (number of CPUs), otherwise 1; `all` means a unique worker will run all charging stations)
- _poolMinSize_: worker threads pool minimum number of threads
- _poolMaxSize_: worker threads pool maximum number of threads
- _resourceLimits_: worker threads [resource limits](https://nodejs.org/api/worker_threads.html#new-workerfilename-options) object option | diff --git a/src/utils/ConfigurationSchema.ts b/src/utils/ConfigurationSchema.ts index 2ec0f4b9..ec10e0ff 100644 --- a/src/utils/ConfigurationSchema.ts +++ b/src/utils/ConfigurationSchema.ts @@ -286,11 +286,18 @@ export const StationTemplateUrlSchema = z .strict() /** - * Supervision URL — a URL that the `ws` WebSocket client can dial: scheme - * `ws:`, `wss:`, `http:`, `https:` or `ws+unix:`, no fragment identifier, and - * a socket path for `ws+unix:`. A scheme-less `host:port/path` string parses - * as a URL with the host as its scheme, so syntax validation alone would - * accept a value that fails at connection time. + * Supervision URL — a URL that the `ws` WebSocket client can dial once the + * charging station identity is appended to it: scheme `ws:`, `wss:`, `http:`, + * `https:` or `ws+unix:`; no fragment delimiter (`#`, even with an empty + * fragment) and no query delimiter (`?`), since the appended identity would + * end up in the fragment or the query; and for `ws+unix:` a socket path other + * than `/`, then exactly one `:`, then an empty request path or one starting + * with `/` (`ws` splits the pathname at `:`, so without the separator the + * identity is appended to the socket path, and a request path without a + * leading `/` is not a valid HTTP request target). A + * scheme-less `host:port/path` string parses as a URL with the host as its + * scheme, so syntax validation alone would accept a value that fails at + * connection time. * Shared by the configuration file and the UI request payload gate so a value * is accepted by both or neither. */ @@ -299,19 +306,32 @@ export const SupervisionUrlSchema = z error: 'must be a WebSocket URL (ws://, wss://, http://, https:// or ws+unix://)', protocol: /^(?:wss?|https?|ws\+unix)$/, }) - .refine(value => !URL.canParse(value) || new URL(value).hash === '', { + .refine(value => !URL.canParse(value) || !new URL(value).href.includes('#'), { message: 'must not contain a fragment identifier (#...)', }) + .refine(value => !URL.canParse(value) || !new URL(value).href.includes('?'), { + message: 'must not contain a query string (?...)', + }) .refine( value => { if (!URL.canParse(value)) { return true } const url = new URL(value) - return url.protocol !== 'ws+unix:' || url.pathname !== '' + if (url.protocol !== 'ws+unix:') { + return true + } + const [socketPath, ...requestPath] = url.pathname.split(':') + return ( + requestPath.length === 1 && + socketPath !== '' && + socketPath !== '/' && + (requestPath[0] === '' || requestPath[0].startsWith('/')) + ) }, { - message: 'ws+unix:// URLs must include the socket path', + message: + "ws+unix:// URLs must include the socket path, then exactly one ':' before a request path starting with '/' (ws+unix:///path/to/socket.sock:/path)", } ) diff --git a/tests/utils/ConfigurationSchema.test.ts b/tests/utils/ConfigurationSchema.test.ts index 977c5d96..524338b1 100644 --- a/tests/utils/ConfigurationSchema.test.ts +++ b/tests/utils/ConfigurationSchema.test.ts @@ -860,8 +860,11 @@ await describe('ConfigurationSchema', async () => { 'wss://localhost:8010/OCPP16', 'http://localhost:8010/OCPP16', 'https://localhost:8010/OCPP16', - 'ws+unix:///tmp/simulator.sock', 'ws+unix:///tmp/simulator.sock:/OCPP16', + 'ws+unix:///tmp/simulator.sock:/', + 'ws+unix:///tmp/simulator.sock:', + 'ws+unix:s:/OCPP16', + 'ws+unix:./s:/OCPP16', 'ws:///OCPP16', ] const rejectedUrls = [ @@ -888,16 +891,39 @@ await describe('ConfigurationSchema', async () => { } }) - await it('should reject a URL with a fragment identifier', () => { - const result = SupervisionUrlSchema.safeParse('ws://localhost:8010/OCPP16#fragment') - if (result.success) { - assert.fail('Expected a URL with a fragment identifier to be rejected') + await it('should reject a URL with a fragment delimiter, even with an empty fragment', () => { + for (const url of ['ws://localhost:8010/OCPP16#fragment', 'ws://localhost:8010/OCPP16#']) { + const result = SupervisionUrlSchema.safeParse(url) + if (result.success) { + assert.fail(`Expected '${url}' to be rejected`) + } + assert.match(result.error.issues[0].message, /fragment identifier/) + } + }) + + await it('should reject a URL with a query string delimiter, even with an empty query', () => { + for (const url of [ + 'ws://localhost:8010/OCPP16?token=secret', + 'ws://localhost:8010/OCPP16?', + ]) { + const result = SupervisionUrlSchema.safeParse(url) + if (result.success) { + assert.fail(`Expected '${url}' to be rejected`) + } + assert.match(result.error.issues[0].message, /query string/) } - assert.match(result.error.issues[0].message, /fragment identifier/) }) - await it('should reject a ws+unix URL without a socket path', () => { - for (const url of ['ws+unix://', 'ws+unix:']) { + await it("should reject a ws+unix URL without a socket path, exactly one ':' and a request path starting with '/'", () => { + for (const url of [ + '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', + ]) { const result = SupervisionUrlSchema.safeParse(url) if (result.success) { assert.fail(`Expected '${url}' to be rejected`) @@ -906,27 +932,49 @@ await describe('ConfigurationSchema', async () => { } }) - await it('should match the URLs accepted by the ws client', () => { + await it('should match the connection URLs the ws client can dial', () => { const edgeCaseUrls = [ 'ws://localhost:8010/OCPP16#fragment', 'wss://localhost:8010/OCPP16#', 'ws+unix://', 'ws+unix:', + 'ws+unix:///tmp/simulator.sock', + ] + // URLs the ws client accepts but that do not reach the intended + // endpoint once the identity is appended (checked against real servers). + const wrongEndpointUrls = [ + 'ws://localhost:8010/OCPP16?token=secret', + 'ws://localhost:8010/OCPP16?', + 'ws+unix:///:/OCPP16', + 'ws+unix:///tmp/simulator.sock:/OC:PP16', + 'ws+unix:s:OCPP16', ] + for (const url of wrongEndpointUrls) { + assert.strictEqual(SupervisionUrlSchema.safeParse(url).success, false, url) + } for (const url of [ ...acceptedUrls, ...rejectedUrls.filter(url => URL.canParse(url)), ...edgeCaseUrls, ]) { - let wsAccepts = true + // The charging station dials the supervision URL with its identity + // appended (see ChargingStation.wsConnectionUrl). + const connectionUrl = `${url}${url.endsWith('/') ? '' : '/'}CS-1` + let wsCanDial = true try { - const webSocket = new WebSocket(url) + const webSocket = new WebSocket(connectionUrl) webSocket.on('error', () => undefined) webSocket.terminate() + // ws splits a ws+unix pathname at ':' into socket path and request + // path; without ':' the identity ends up in the socket path. + const { pathname, protocol } = new URL(connectionUrl) + if (protocol === 'ws+unix:' && pathname.indexOf(':') <= 0) { + wsCanDial = false + } } catch { - wsAccepts = false + wsCanDial = false } - assert.strictEqual(SupervisionUrlSchema.safeParse(url).success, wsAccepts, url) + assert.strictEqual(SupervisionUrlSchema.safeParse(url).success, wsCanDial, url) } }) }) -- 2.53.0