From 72bb3f08a9545d1d8eec1aeb9444f73b7ed1ae6c Mon Sep 17 00:00:00 2001 From: =?utf8?q?J=C3=A9r=C3=B4me=20Benoit?= Date: Sun, 14 Jun 2026 20:24:13 +0200 Subject: [PATCH] feat(ui-server): add source-aware gateway access policy (#1891) MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit * feat(config): add UI server access policy schema * feat(config): add UI server access policy defaults * feat(ui-server): add source-aware access evaluation * feat(ui-server): authorize requests before authentication * feat(ui-server): gate HTTP requests by access policy * feat(ui-server): gate MCP requests by access policy * feat(ui-server): gate WebSocket upgrades by access policy * chore(docker): align UI access policy for local compose * docs(ui): document gateway access policy * [autofix.ci] apply automated fixes * refactor(ui-server): type access decisions and unify request prologue - Replace stringly-typed denial reason with UIServerAccessDenialReason enum and DENIAL_MESSAGES rendering map; UIServerAccessDecision is a discriminated union narrowed by 'allowed'. - Memoize the per-request access decision via WeakMap so the policy is evaluated once per request. - Add runRequestPrologue template method on AbstractUIServer; rate-limit now accounts denied requests, closing pre-auth flood amplification. - Validate accessPolicy.trustedProxies entries as IPv4/IPv6 literals at the schema layer; precompile the normalized set per UIServerConfiguration. - Drop the dead httpServer.on('connect') listener in UIWebSocketServer; use StatusCodes consistently in UIMCPServer. - README clarifies the binary loopback-vs-non-loopback model, single-hop XFF, TLS termination via reverse proxy only, and adds a migration note for the default requireTlsForNonLoopback: true. * refactor(ui-server): split access policy module and tighten its surface - Split UIServerUtils.ts into UIServerAccessPolicy (decision, forwarded parsing, host/origin allowlists) and UIServerNet (IP/host normalization, loopback, header tokenization). UIServerUtils retains auth tokens and WS subprotocol negotiation only. - Make evaluateUIServerAccess private; tests and runtime callers go through resolveUIServerAccess so the decision cache is always exercised. - Move accessDecisionCache and trustedProxiesCache from module scope to a per-AbstractUIServer instance cache injected into resolveUIServerAccess. - Quote-aware splitHeaderList honors RFC 7239 / RFC 7230 double-quoted values; commas inside "…" are preserved. - Replace the {error?, value?} dialects in forwarded-header parsers with a discriminated ParseOutcome; close the Forwarded params type to by/for/host/proto. - Drop the unreachable isDirectTLSRequest helper and its TLSSocket import. - Set HTTP server requestTimeout=30s and headersTimeout=5s on AbstractUIServer to bound slow-loris exposure on rejected upgrades. - Ensure UIMCPServer.handleMcpRequest releases the transport and the MCP server in every error branch (idempotent cleanup). - Promote MockUpgradeSocket to UIServerTestUtils.ts; replace the duplicate test factory createAccessPolicyConfiguration with the canonical createMockUIServerConfiguration. - Cover the Docker dual-stack case where an IPv4-mapped IPv6 remote address matches an IPv4 trustedProxies entry. - README accessPolicy cell switches to per-field sub-bullets, aligned with the log and worker rows. Signed-off-by: Jérôme Benoit * refactor(ui-server): drop @file headers from new policy modules @file JSDoc is the test-suite convention (TEST_STYLE_GUIDE.md) and is absent from the rest of src/. Align the new UIServerAccessPolicy and UIServerNet modules with the existing source-tree style. Signed-off-by: Jérôme Benoit * docs(ui-server): tighten access policy comments and README - Drop README migration note and the duplicate access policy paragraph; the configuration table cell already specifies the policy. - Tighten the Docker section access policy paragraph. - Trim narrative meta-commentary in UIServerAccessPolicy and UIServerNet JSDoc; let constant and type names carry the rest. Signed-off-by: Jérôme Benoit * refactor(ui-server): address review feedback on access policy - isHostAllowed: when the immediate peer is a trusted proxy and X-Forwarded-Host is present, match it (not the immediate Host) against allowedHosts, so reverse proxies that rewrite Host to an internal upstream name are not rejected. - isOriginAllowed: compare allowedOrigins via parsed URL protocol+host rather than literal string equality, so entries with a trailing slash or with a protocol-default port match the canonical browser-emitted Origin. - UIMCPServer: destroy the response and request sockets after a denied prologue, mirroring UIHttpServer. - evaluateUIServerAccess: parse the Forwarded header once and pass the outcome to getForwardedProtocol and getForwardedClientAddress. - hasDuplicateHeaders: count rawHeaders in a single pass. Signed-off-by: Jérôme Benoit * fix(ui-server): include prologue headers on WebSocket upgrade denial The WebSocket upgrade denial path wrote only the HTTP/1.1 status line, so rate-limit denials reached clients without the Retry-After header that the HTTP and MCP transports already emit. Signed-off-by: Jérôme Benoit * fix(ui-server): align auth denial across transports - UIMCPServer: destroy the response and request sockets after a denied authentication, mirroring UIHttpServer. - UIWebSocketServer: include the WWW-Authenticate header on auth-denied upgrades so RFC 7235 clients receive the Basic challenge that the HTTP and MCP transports already emit. Signed-off-by: Jérôme Benoit * fix(ui-server): close denied connections cleanly and harden allowedOrigins - AbstractUIServer: also call setTimeout() on the http server so the HTTP/2 transport (Http2Server lacks requestTimeout/headersTimeout) gets the same idle bound. - UIHttpServer / UIMCPServer: emit Connection: close on prologue and auth denials and drop the explicit res.destroy()/req.destroy() calls; the body now flushes before the connection closes. - UIWebSocketServer: destroy the upgrade socket from the write callback so the response bytes are flushed before teardown. - UIServerAccessPolicySchema: refine allowedOrigins entries to reject paths, query strings, and fragments at config load instead of silently ignoring them at runtime. Signed-off-by: Jérôme Benoit * fix(ui-server): honor RFC 7239 host parameter and standardize upgrade rejection - UIServerAccessPolicy: parse the Forwarded host parameter symmetrically with for and proto. Both Forwarded host=... and X-Forwarded-Host now feed isHostAllowed when the immediate peer is trusted, with a new AmbiguousForwardedHost denial reason when they conflict. - UIWebSocketServer: build upgrade rejection responses through a single helper that always emits Connection: close and Content-Length: 0, matching the HTTP and MCP transports. Signed-off-by: Jérôme Benoit * fix(test): wait on response finish event in gzip tests Replace the fixed 50 ms waitForStreamFlush delay with await once(res, 'finish') in the UIHttpServer gzip tests. MockServerResponse already emits 'finish' from end(), so the wait is now event-driven and stops flaking on slow CI runners (notably Windows / Node 24.x). Drop the now-unused waitForStreamFlush helper and GZIP_STREAM_FLUSH_DELAY_MS constant. Signed-off-by: Jérôme Benoit * fix(ui-server): scope HTTP timeouts to slow-loris and tighten Forwarded parser - AbstractUIServer: drop the connection-wide setTimeout call. requestTimeout and headersTimeout already cover slow-loris on HTTP/1.1, and HTTP/2 streams are per-request and not vulnerable to the same pattern. The dropped 30 s socket-inactivity cap was killing legitimate long-running responses on the deprecated HTTP transport. - UIServerAccessPolicy: split Forwarded pairs with quote awareness so that RFC 7239 quoted-string values containing semicolons are not truncated. Mirrors the quote handling already in splitHeaderList. - UIWebSocketServer: place Connection: close after the extraHeaders spread in buildUpgradeRejectionResponse to match the HTTP and MCP transports and keep the close header non-overridable by callers. Signed-off-by: Jérôme Benoit * fix(ui-server): omit Connection header on HTTP/2 and prioritize trusted-peer denial reason - AbstractUIServer: add getConnectionCloseHeader() returning an empty header set when the underlying server is HTTP/2. HTTP/2 forbids the Connection header as a connection-specific header; Node otherwise emits an UnsupportedWarning and silently drops the value. Streams are closed by res.end() on that path. HTTP/1.1 keeps the explicit Connection: close to terminate keep-alive on denial. - UIHttpServer, UIMCPServer: route prologue-denial and auth-denial responses through the helper instead of hardcoding Connection: close. - UIServerAccessPolicy: hoist the trusted-peer check above the protocol and host ambiguity checks. An untrusted peer sending forwarded headers is now denied as ForwardedFromUntrustedPeer regardless of duplicate proto/host metadata, so operator logs reflect the trust violation rather than a parser-level symptom. Duplicate-headers retains absolute priority. Tests added for the protocol- and host-ambiguity paths from an untrusted peer. Signed-off-by: Jérôme Benoit * fix(ui-server): treat empty forwarded values as absent Empty forwarded-header values were treated as present-with-empty-string, which caused two misbehaviors when the immediate peer was a trusted proxy: - An empty 'host=' parameter inside a Forwarded header (or an empty X-Forwarded-Host) shadowed the real Host header. The policy resolved the host to '' and denied the request as HostNotAllowed even though the actual Host was in allowedHosts. - An empty X-Forwarded-Proto was rejected as AmbiguousForwardedProtocol (via splitHeaderList returning an empty array) instead of being treated as missing. A nonEmpty() helper now normalizes empty/undefined header values to undefined at parse time. parseSingleForwardedHeader skips empty parameter values rather than recording them. The two getters fall through to the next source when the upstream value is empty. Tests cover the host fallback paths (Forwarded host= and X-Forwarded-Host) and the protocol absent-vs-ambiguous distinction. Signed-off-by: Jérôme Benoit * fix(ui-server): accept RFC 7239 hidden-identity forms and tighten Forwarded parser - UIServerAccessPolicy: recognize the RFC 7239 nodename forms 'unknown' and obfuscated identifiers ('_' followed by token characters) in the Forwarded 'for=' parameter and X-Forwarded-For header. Hidden-identity forms now resolve to ABSENT, so the access decision falls back to the trusted proxy's own remote address as the client identifier (used as the rate-limit bucket and the logged identity). Previously these values were rejected as InvalidForwardedClient even though the rest of the request (TLS, host, origin, trust) was valid. - UIServerAccessPolicy: strip Forwarded parameter quotes only when the pair is balanced. The previous '/^"|"$/g' replace stripped the leading or trailing quote independently and silently accepted unbalanced inputs such as 'for="203.0.113.10'. The new '/^"(.*)"$/' anchors both ends in a single match. - README: clarify that allowLoopbackProxy requires the loopback peer to also be listed in trustedProxies. allowLoopbackProxy alone has no effect because the trusted-peer check denies forwarded headers from peers absent from the trustedProxies list. - Tests: positive path for allowLoopbackProxy=true; identity-hidden paths for Forwarded for=unknown, Forwarded for=_obfuscated, and X-Forwarded-For: unknown. Signed-off-by: Jérôme Benoit * fix(ui-server): normalize empty X-Forwarded-For and align MCP auth with continuation pattern - UIServerAccessPolicy: normalize an empty X-Forwarded-For header to undefined so the access decision falls back to the trusted proxy's own remote address rather than denying the request as InvalidForwardedClient or as AmbiguousForwardedClient when a Forwarded for= parameter is also present. Restores symmetry with X-Forwarded-Host and X-Forwarded-Proto empty-value handling. - UIMCPServer: rewrite the auth path as a callback continuation. The previous capture-then-check pattern relied on authenticate() being synchronous and on the last next() call winning. The new shape matches UIHttpServer and UIWebSocketServer and continues to handle MCP requests only after a successful authentication callback. Signed-off-by: Jérôme Benoit * fix(ui-server): align MCP error responses and treat empty forwarded headers as absent - UIMCPServer: emit Connection: close on the 404 not-found and on the centralized sendErrorResponse helper (BAD_REQUEST, METHOD_NOT_ALLOWED, INTERNAL_SERVER_ERROR), aligning these paths with the post-prologue and auth denial responses. The connection-close header is omitted on HTTP/2 by the existing getConnectionCloseHeader() helper. - UIServerAccessPolicy: hasForwardedHeaders now ignores empty header values, restoring symmetry with the downstream forwarded-header parsers that already map empty values to absent. Empty X-Forwarded-* headers from an untrusted peer no longer trigger ForwardedFromUntrustedPeer; an empty X-Forwarded-Proto from a non-loopback untrusted peer now denies as TlsRequired (no proxy proof of TLS). Empty headers from a loopback peer no longer block the request. - README: clarify that requireTlsForNonLoopback honors X-Forwarded-Proto / Forwarded: proto= from a trusted proxy and does not detect native direct TLS; note that a compromised trustedProxies entry can bypass per-client rate limiting by varying X-Forwarded-For. Signed-off-by: Jérôme Benoit * chore(ui-server): trim redundant access-policy comments and JSDoc - Drop module-level JSDoc on UIServerAccessPolicy and UIServerNet to match the codebase convention (no module-level JSDoc on peer files). - Drop redundant inline comments whose content is already documented in the README or self-evident from the surrounding code: the empty X-Forwarded-For malformed branch, the allowedOrigins fallback, and the loopback-aliases derivation. - Trim the verbose 'Discriminated by ...' prose on the UIServerAccessDecision and ParseOutcome types and remove the JSDoc on resolveUIServerAccess (function name and signature are self-explanatory; memoization is visible from the cache lookup). Signed-off-by: Jérôme Benoit * refactor(ui-server): extract splitQuoted and pickForwardedValue helpers - UIServerNet: factor the quote-aware splitter into splitQuoted(value, delimiter); splitHeaderList becomes a thin wrapper. The previously duplicated state machine in UIServerAccessPolicy (splitForwardedPairs) is replaced by a direct splitQuoted call on ';'. - UIServerAccessPolicy: factor the X-Forwarded-* / Forwarded ambiguity pick into pickForwardedValue. The three getForwarded* helpers now share a single source of truth for the present/absent/ambiguous decision; transport-specific tail logic (multi-hop X-Forwarded-For rejection, X-Forwarded-Proto multi-value rejection, lowercasing) stays local. - UIServerAccessPolicy: drop the unreachable string-trim fallback in normalizeIPAddress (every input that fails normalizeHost also fails the downstream IP checks), narrow isSecureForwardedProtocol's parameter to string | undefined (no caller passes null), and skip the redundant pre-pass through normalizeHost in isSameHost (the IP-address branch already calls normalizeHost internally). - UIServerAccessPolicy: replace Reflect.get with direct property access on IncomingMessage.headersDistinct and rawHeaders (typed members on Node 18+). - Tests: extend the createMockIncomingMessage and the policy test's createAccessPolicyRequest factories with headersDistinct and rawHeaders defaults so direct property access works against the mocks. Signed-off-by: Jérôme Benoit * refactor(ui-server): unify denial rendering and switch authenticate to boolean - AbstractUIServer: introduce renderDenial(res, payload) and getUnauthorizedDenial() so HTTP and MCP route every prologue, 401, and post-handler error response through the same code path. The helper centralizes Content-Type, Connection-close (HTTP/2-aware), and 'res.headersSent' guarding. - AbstractUIServer: change authenticate(req) to return a boolean instead of invoking a continuation with an Error. The CPS shape was fully synchronous in practice — every caller did 'if (err != null)' — and the BaseError allocation was dead. The three transports collapse to 'if (!this.authenticate(req)) { … }'. - AbstractUIServer + UIServerUtils: replace hand-typed reason phrases with getReasonPhrase() from http-status-codes; the helper functions isValidBasicAuth / isValidProtocolBasicAuth and getUsernameAndPasswordFromAuthorizationToken drop their continuation parameter accordingly. - UIServerSecurity: replace the chunk-counter createBodySizeLimiter() with a shared readLimitedBody(req, maxBytes): Promise helper and a typed PayloadTooLargeError. UIHttpServer reads the request body via the helper instead of an event-based loop, and UIMCPServer detects oversized payloads via 'instanceof PayloadTooLargeError' rather than string-matching the error message. - UIMCPServer: route sendErrorResponse through renderDenial so all 4xx/5xx error responses share the same headers and Connection semantics; preserve the path-filter-before-authenticate ordering with a comment so unknown paths return 404 without revealing whether authentication would have succeeded. - UIWebSocketServer: use getUnauthorizedDenial() for the upgrade rejection on auth failure instead of inlining the WWW-Authenticate string and the 'Unauthorized' literal. - Tests: align UIServerUtils.test, UIServerSecurity.test, and UIMCPServer.test with the new APIs (boolean signatures, new PayloadTooLargeError, new readLimitedBody). Signed-off-by: Jérôme Benoit * refactor(ui-server): centralize access-policy defaults and validate allowedHosts entries - ConfigurationSchema: introduce UI_SERVER_ACCESS_POLICY_DEFAULTS as the single source of truth for the access-policy field defaults (requireTlsForNonLoopback=true, allowLoopbackProxy=false, and the empty allowlists). The defaults previously lived inline in evaluateUIServerAccess. - UIServerAccessPolicy: consume UI_SERVER_ACCESS_POLICY_DEFAULTS instead of the hardcoded literals so a default change propagates uniformly across the policy code, the schema, and the README. - ConfigurationSchema: validate allowedHosts entries via normalizeHost so paths, queries, fragments, and other malformed Host values are rejected at config load. The previous z.string().min(1) accepted any non-empty string. Signed-off-by: Jérôme Benoit * test(ui-server): factor access-policy test fixtures and address constants - UIServerTestUtils: introduce TRUSTED_PROXY_IP, EXTERNAL_CLIENT_IP, and GATEWAY_HOST constants from RFC 5737 / RFC 3849 reserved ranges; factor the recurrent gateway access-policy configurations into createGatewayConfigWithTrustedProxy() and createGatewayConfigWithoutTrustedProxies() so subsequent test additions inherit a single source of truth for the gateway shape. Signed-off-by: Jérôme Benoit * test(ui-server): cover missing denial reasons and tighten weak assertions - UIServerAccessPolicy.test: cover the previously untested denial reasons AmbiguousForwardedProtocol (multi-value X-Forwarded-Proto), AmbiguousForwardedHeader (multi-entry Forwarded), and AmbiguousForwardedParameter (duplicate parameter inside one entry); cover the InvalidForwardedClient branch for non-IP, non-hidden 'for=' values; cover AmbiguousForwardedClient when 'Forwarded: for=unknown' collides with X-Forwarded-For; cover the headersDistinct path of hasDuplicateHeaders. - UIServerAccessPolicy.test: cover the malformed Origin URL branch in isOriginAllowed and the allowedHosts-fallback branch when allowedOrigins is empty; pin the design choices for whitespace-padded Host normalization, uppercase X-Forwarded-Proto values, IPv4-mapped IPv6 loopback proxies under allowLoopbackProxy, and the policy's intentional non-detection of req.socket.encrypted without forwarded protocol headers. - UIServerAccessPolicy.test: tighten the previously weak assertions on the accessPolicy-undefined paths (now check the denial reason and the resolved client address); rename the misleading 'should reject empty-but-present X-Forwarded-For' test to reflect what it actually exercises (no parseable addresses); replace the trivial cache-isolation assertion with a real cacheA.decisions.has(req) / cacheB.decisions.has(req) check. - UIMCPServer.test: drop the two malformed-host tests that re-tested policy logic already covered by UIServerAccessPolicy.test; the Connection: close tests in the same describe still anchor the transport-level rendering contract. Signed-off-by: Jérôme Benoit * test(ui-server): close coverage gaps and tighten weak assertions identified in second-pass audit - ConfigurationSchema.test: validate the allowedHosts schema refine rejects malformed entries (excess colons, non-numeric port, port out of range, empty input) and accepts canonical hostnames and IP literals; lock the canonical UI_SERVER_ACCESS_POLICY_DEFAULTS map shape so a default change cannot drift silently. - UIServerNet.test: cover normalizeHost directly (rejection branch is the boundary of the allowedHosts schema refine; previously only exercised indirectly through transport tests). - UIServerSecurity.test: assert readLimitedBody propagates upstream stream errors (security-relevant fail-closed behavior). - UIServerAccessPolicy.test: rename the misleading for=unknown collision test to reflect the actual code path (collision is generic, not unknown-specific); drop the duplicate encrypted-proxy plaintext rejection (the encrypted flag is ignored by the policy by design and a dedicated test already pins it); tighten the whitespace-padded Host test to use an explicit allowedHosts list so the trim path is genuinely exercised rather than the loopback alias fallback. - UIServerTestUtils: drop the dead EXTERNAL_CLIENT_IP export and correct the RFC reference comment (RFC 5737 IPv4 / RFC 2606 example.com — RFC 3849 covers IPv6 and is irrelevant here). - UIWebSocketServer: add a brief comment documenting why buildUpgradeRejectionResponse stays separate from AbstractUIServer.renderDenial — pre-handshake WS rejections write raw HTTP/1.1 to a Duplex socket while renderDenial targets ServerResponse. - src/utils/index: re-export UI_SERVER_ACCESS_POLICY_DEFAULTS so the defaults can be asserted from the schema test layer. Signed-off-by: Jérôme Benoit * fix(ui-server): tighten X-Forwarded-Host validation and host charset - UIServerAccessPolicy: getForwardedHost rejects multi-value X-Forwarded-Host as AmbiguousForwardedHost, mirroring the symmetric guards in getForwardedProtocol and getForwardedClientAddress. The PR description's claim that 'multi-value X-Forwarded-* are rejected' now applies uniformly across the three forwarded dimensions. - UIServerNet: normalizeHost validates the hostname charset against a conservative [a-z0-9._-]+ allowlist on the dot path. Inputs with commas, spaces, or unbalanced brackets are rejected at config-load time when used as allowedHosts entries, and at runtime as a defense-in-depth check on the Host header. - UIServerNet: isValidPort rejects port 0 (RFC 6335 reserved). Port 0 in a client-side Host header has no useful meaning; tightening to 1..65535 matches common practice and the prior config-bind path is unaffected. - README: requireTlsForNonLoopback wording corrected — non-loopback requests without forwarded protocol headers are denied as tls-required, not merely 'not detected', even when the underlying socket is encrypted. Signed-off-by: Jérôme Benoit * fix(ui-server): empty Forwarded header, 405 Allow, wildcard misconfig warn - UIServerAccessPolicy: parseSingleForwardedHeader normalises an empty Forwarded header value (e.g., 'Forwarded: ') to ABSENT instead of rejecting it as AmbiguousForwardedHeader. The PR description claim that 'empty forwarded values are treated as absent' now applies to the header value itself, not only to parameters inside the header. - UIMCPServer: sendErrorResponse accepts an optional headers argument so 405 Method Not Allowed responses advertise 'Allow: GET, POST, DELETE' per RFC 9110 §15.5.6. - AbstractUIServer: warn at startup when the UI server is bound to a wildcard host ('', '0.0.0.0', '::') with an empty accessPolicy.allowedHosts; the previously silent lockout (every request denied as host-not-allowed) now produces an actionable log line pointing at the configuration to adjust. Signed-off-by: Jérôme Benoit * fix(ui-server): broaden misconfig warning and log rate-limit denials - AbstractUIServer: warnIfMisconfigured now also fires when the UI server is bound to a non-loopback specific host with requireTlsForNonLoopback=true and no accessPolicy.trustedProxies; the previously silent path (every plaintext request denied as tls-required) now produces an actionable startup log line. Wildcard binding with empty allowedHosts retains its dedicated warning. - AbstractUIServer: rate-limit denials now emit a warn log symmetric to the access-policy 403 path so operators can detect probing. Previously the 429 branch returned silently, which made abusive clients invisible from the logs. - README: drop the 'even when the underlying socket is encrypted' caveat from the requireTlsForNonLoopback description; the simulator only listens via plaintext or h2c, so the scenario described was unreachable. The runtime behaviour is unchanged and the test that pins the design choice stays as defense in depth. Signed-off-by: Jérôme Benoit --------- Signed-off-by: Jérôme Benoit Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- README.md | 24 +- docker/config.json | 7 + docker/docker-compose.yml | 2 +- src/assets/config-template.json | 7 + .../ui-server/AbstractUIServer.ts | 183 ++- .../ui-server/UIHttpServer.ts | 230 ++- src/charging-station/ui-server/UIMCPServer.ts | 111 +- .../ui-server/UIServerAccessPolicy.ts | 503 +++++++ .../ui-server/UIServerFactory.ts | 2 +- src/charging-station/ui-server/UIServerNet.ts | 164 +++ .../ui-server/UIServerSecurity.ts | 27 +- .../ui-server/UIServerUtils.ts | 21 +- .../ui-server/UIWebSocketServer.ts | 96 +- src/utils/Configuration.ts | 10 +- src/utils/ConfigurationSchema.ts | 60 +- src/utils/index.ts | 2 + .../ui-server/UIHttpServer.test.ts | 97 +- .../ui-server/UIMCPServer.test.ts | 228 ++- .../ui-server/UIServerAccessPolicy.test.ts | 1271 +++++++++++++++++ .../ui-server/UIServerNet.test.ts | 141 ++ .../ui-server/UIServerSecurity.test.ts | 48 +- .../ui-server/UIServerTestConstants.ts | 7 - .../ui-server/UIServerTestUtils.ts | 102 +- .../ui-server/UIServerUtils.test.ts | 65 +- .../ui-server/UIWebSocketServer.test.ts | 124 ++ tests/utils/Configuration.test.ts | 8 + tests/utils/ConfigurationSchema.test.ts | 245 +++- tests/utils/ConfigurationValidation.test.ts | 24 + 28 files changed, 3442 insertions(+), 367 deletions(-) create mode 100644 src/charging-station/ui-server/UIServerAccessPolicy.ts create mode 100644 src/charging-station/ui-server/UIServerNet.ts create mode 100644 tests/charging-station/ui-server/UIServerAccessPolicy.test.ts create mode 100644 tests/charging-station/ui-server/UIServerNet.test.ts diff --git a/README.md b/README.md index 07951ecd..ef5858c2 100644 --- a/README.md +++ b/README.md @@ -173,17 +173,17 @@ But the modifications to test have to be done to the files in the build target d **src/assets/config.json**: -| Key | Value(s) | Default Value | Value type | Description | -| -------------------------- | -------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| $schemaVersion | 1 | 1 | integer | Configuration schema version. Set to 1. 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 | -| 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 | -| uiServer | | {
"enabled": false,
"type": "ws",
"version": "1.1",
"options": {
"host": "localhost",
"port": 8080
}
} | {
enabled?: boolean;
type?: ApplicationProtocol;
version?: ApplicationProtocolVersion;
options?: ServerOptions;
authentication?: {
enabled: boolean;
type: AuthenticationType;
username?: string;
password?: string;
}
} | UI server configuration section:
- _enabled_: enable UI server
- _type_: 'ws', 'mcp' or 'http' (deprecated)
- _version_: HTTP version '1.1' or '2.0' (ws and mcp transports only support '1.1')
- _options_: node.js net module [listen options](https://nodejs.org/api/net.html#serverlistenoptions-callback)
- _authentication_: authentication type configuration section | -| performanceStorage | | {
"enabled": true,
"type": "none",
} | {
enabled?: boolean;
type?: string;
uri?: string;
} | Performance storage configuration section:
- _enabled_: enable performance storage
- _type_: 'jsonfile', 'mongodb' or 'none'
- _uri_: storage URI | -| stationTemplateUrls | | {}[] | {
file: string;
numberOfStations: number;
provisionedNumberOfStations?: number;
}[] | array of charging station templates URIs configuration section:
- _file_: charging station configuration template file relative path
- _numberOfStations_: template number of stations at startup
- _provisionedNumberOfStations_: template provisioned number of stations after startup | -| persistState | true/false | true | boolean | persist the simulator stopped state to `dist/assets/configurations/.simulator-state.json`. On the next process startup, if the simulator was stopped via the UI procedure `stopSimulator`, charging stations are not auto-spawned and the user can recover via the `startSimulator` procedure. Signal-driven shutdowns (SIGINT/SIGTERM/SIGQUIT) and configuration-reload restarts do not modify the persisted state. The feature requires `uiServer.enabled` to be `true`; otherwise it is silently ignored (no recovery channel without UI). Set the environment variable `SIMULATOR_COLD_START=true` for one run to ignore the persisted state and force a cold start. UI-added charging stations beyond `numberOfStations` are not auto-respawned | +| Key | Value(s) | Default Value | Value type | Description | +| -------------------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| $schemaVersion | 1 | 1 | integer | Configuration schema version. Set to 1. 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 | +| 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 | +| uiServer | | {
"enabled": false,
"type": "ws",
"version": "1.1",
"accessPolicy": {
"requireTlsForNonLoopback": true,
"trustedProxies": [],
"allowLoopbackProxy": false,
"allowedHosts": [],
"allowedOrigins": []
},
"options": {
"host": "localhost",
"port": 8080
}
} | {
enabled?: boolean;
type?: ApplicationProtocol;
version?: ApplicationProtocolVersion;
accessPolicy?: {
requireTlsForNonLoopback?: boolean;
trustedProxies?: string[];
allowLoopbackProxy?: boolean;
allowedHosts?: string[];
allowedOrigins?: string[];
};
options?: ServerOptions;
authentication?: {
enabled: boolean;
type: AuthenticationType;
username?: string;
password?: string;
}
} | UI server configuration section:
- _enabled_: enable UI server
- _type_: 'ws', 'mcp' or 'http' (deprecated)
- _version_: HTTP version '1.1' or '2.0' (ws and mcp transports only support '1.1')
- _accessPolicy_: gateway access policy. Loopback request sources are allowed in plaintext; non-loopback sources require TLS termination by a reverse proxy:
  - _requireTlsForNonLoopback_: reject non-loopback plaintext requests; the check honors `X-Forwarded-Proto` or `Forwarded: proto=` from a trusted proxy, non-loopback requests without forwarded protocol headers are denied as `tls-required`
  - _trustedProxies_: IPv4 or IPv6 literals of the immediate reverse proxies whose forwarded headers are honored (hostnames and CIDR ranges are not accepted; only single-hop forwarded chains are honored); a compromised entry can bypass per-client rate limiting by varying `X-Forwarded-For`
  - _allowLoopbackProxy_: accept forwarded headers when the immediate peer is loopback AND listed in _trustedProxies_ (e.g. `['127.0.0.1', '::1']`)
  - _allowedHosts_: explicit Host header allowlist; mitigates DNS rebinding when the UI server is exposed through a browser-facing host
  - _allowedOrigins_: explicit Origin header allowlist; when empty, the request Origin's URL hostname falls back to matching against _allowedHosts_
- _options_: node.js net module [listen options](https://nodejs.org/api/net.html#serverlistenoptions-callback)
- _authentication_: authentication type configuration section | +| performanceStorage | | {
"enabled": true,
"type": "none",
} | {
enabled?: boolean;
type?: string;
uri?: string;
} | Performance storage configuration section:
- _enabled_: enable performance storage
- _type_: 'jsonfile', 'mongodb' or 'none'
- _uri_: storage URI | +| stationTemplateUrls | | {}[] | {
file: string;
numberOfStations: number;
provisionedNumberOfStations?: number;
}[] | array of charging station templates URIs configuration section:
- _file_: charging station configuration template file relative path
- _numberOfStations_: template number of stations at startup
- _provisionedNumberOfStations_: template provisioned number of stations after startup | +| persistState | true/false | true | boolean | persist the simulator stopped state to `dist/assets/configurations/.simulator-state.json`. On the next process startup, if the simulator was stopped via the UI procedure `stopSimulator`, charging stations are not auto-spawned and the user can recover via the `startSimulator` procedure. Signal-driven shutdowns (SIGINT/SIGTERM/SIGQUIT) and configuration-reload restarts do not modify the persisted state. The feature requires `uiServer.enabled` to be `true`; otherwise it is silently ignored (no recovery channel without UI). Set the environment variable `SIMULATOR_COLD_START=true` for one run to ignore the persisted state and force a cold start. UI-added charging stations beyond `numberOfStations` are not auto-respawned | #### Worker process model @@ -493,6 +493,8 @@ In the [docker](./docker) folder: make ``` +The bundled Docker Compose configuration publishes the UI server on host loopback only (`127.0.0.1:8080:8080`) and disables `requireTlsForNonLoopback` for this local-only plaintext path. To expose the UI through a public host or reverse proxy, keep `requireTlsForNonLoopback` enabled and set `uiServer.accessPolicy.allowedHosts`, `allowedOrigins`, and `trustedProxies` in `docker/config.json` accordingly. +