]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
feat(ui-server): add source-aware gateway access policy (#1891)
authorJérôme Benoit <jerome.benoit@piment-noir.org>
Sun, 14 Jun 2026 18:24:13 +0000 (20:24 +0200)
committerGitHub <noreply@github.com>
Sun, 14 Jun 2026 18:24:13 +0000 (20:24 +0200)
* 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<T>; 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 <jerome.benoit@sap.com>
* 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 <jerome.benoit@sap.com>
* 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 <jerome.benoit@sap.com>
* 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 <jerome.benoit@sap.com>
* 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 <jerome.benoit@sap.com>
* 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 <jerome.benoit@sap.com>
* 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 <jerome.benoit@sap.com>
* 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 <jerome.benoit@sap.com>
* 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 <jerome.benoit@sap.com>
* 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 <jerome.benoit@sap.com>
* 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 <jerome.benoit@sap.com>
* 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 <jerome.benoit@sap.com>
* 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 <jerome.benoit@sap.com>
* 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 <jerome.benoit@sap.com>
* 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 <jerome.benoit@sap.com>
* 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 <jerome.benoit@sap.com>
* 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 <jerome.benoit@sap.com>
* 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<Buffer> 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 <jerome.benoit@sap.com>
* 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 <jerome.benoit@sap.com>
* 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 <jerome.benoit@sap.com>
* 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 <jerome.benoit@sap.com>
* 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 <jerome.benoit@sap.com>
* 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 <jerome.benoit@sap.com>
* 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 <jerome.benoit@sap.com>
* 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 <jerome.benoit@sap.com>
---------

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
28 files changed:
README.md
docker/config.json
docker/docker-compose.yml
src/assets/config-template.json
src/charging-station/ui-server/AbstractUIServer.ts
src/charging-station/ui-server/UIHttpServer.ts
src/charging-station/ui-server/UIMCPServer.ts
src/charging-station/ui-server/UIServerAccessPolicy.ts [new file with mode: 0644]
src/charging-station/ui-server/UIServerFactory.ts
src/charging-station/ui-server/UIServerNet.ts [new file with mode: 0644]
src/charging-station/ui-server/UIServerSecurity.ts
src/charging-station/ui-server/UIServerUtils.ts
src/charging-station/ui-server/UIWebSocketServer.ts
src/utils/Configuration.ts
src/utils/ConfigurationSchema.ts
src/utils/index.ts
tests/charging-station/ui-server/UIHttpServer.test.ts
tests/charging-station/ui-server/UIMCPServer.test.ts
tests/charging-station/ui-server/UIServerAccessPolicy.test.ts [new file with mode: 0644]
tests/charging-station/ui-server/UIServerNet.test.ts [new file with mode: 0644]
tests/charging-station/ui-server/UIServerSecurity.test.ts
tests/charging-station/ui-server/UIServerTestConstants.ts
tests/charging-station/ui-server/UIServerTestUtils.ts
tests/charging-station/ui-server/UIServerUtils.test.ts
tests/charging-station/ui-server/UIWebSocketServer.test.ts
tests/utils/Configuration.test.ts
tests/utils/ConfigurationSchema.test.ts
tests/utils/ConfigurationValidation.test.ts

index 07951ecd386e2f7fc5a0863001825704e21c9cd3..ef5858c2b9df03eaf7ae5f02f17af2ea56cb7d15 100644 (file)
--- 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                        |                                              | {<br />"enabled": true,<br />"file": "logs/combined.log",<br />"errorFile": "logs/error.log",<br />"statisticsInterval": 60,<br />"level": "info",<br />"console": false,<br />"format": "simple",<br />"rotate": true<br />} | {<br />enabled?: boolean;<br />file?: string;<br />errorFile?: string;<br />statisticsInterval?: number;<br />level?: string;<br />console?: boolean;<br />format?: string;<br />rotate?: boolean;<br />maxFiles?: string \| number;<br />maxSize?: string \| number;<br />}    | Log configuration section:<br />- _enabled_: enable logging<br />- _file_: log file relative path<br />- _errorFile_: error log file relative path<br />- _statisticsInterval_: seconds between charging stations statistics output in the logs<br />- _level_: emerg/alert/crit/error/warning/notice/info/debug [winston](https://github.com/winstonjs/winston) logging level</br >- _console_: output logs on the console<br />- _format_: [winston](https://github.com/winstonjs/winston) log format<br />- _rotate_: enable daily log files rotation<br />- _maxFiles_: maximum number of log files: https://github.com/winstonjs/winston-daily-rotate-file#options<br />- _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                     |                                              | {<br />"processType": "workerSet",<br />"startDelay": 500,<br />"elementAddDelay": 0,<br />"elementsPerWorker": 'auto',<br />"poolMinSize": 4,<br />"poolMaxSize": 16<br />}                                                  | {<br />processType?: WorkerProcessType;<br />startDelay?: number;<br />elementAddDelay?: number;<br />elementsPerWorker?: number \| 'auto' \| 'all';<br />poolMinSize?: number;<br />poolMaxSize?: number;<br />resourceLimits?: ResourceLimits;<br />}                         | Worker configuration section:<br />- _processType_: worker threads process type (`workerSet`/`fixedPool`/`dynamicPool`)<br />- _startDelay_: milliseconds to wait at worker threads startup (only for `workerSet` worker threads process type)<br />- _elementAddDelay_: milliseconds to wait between charging station add<br />- _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)<br />- _poolMinSize_: worker threads pool minimum number of threads</br >- _poolMaxSize_: worker threads pool maximum number of threads<br />- _resourceLimits_: worker threads [resource limits](https://nodejs.org/api/worker_threads.html#new-workerfilename-options) object option |
-| uiServer                   |                                              | {<br />"enabled": false,<br />"type": "ws",<br />"version": "1.1",<br />"options": {<br />"host": "localhost",<br />"port": 8080<br />}<br />}                                                                                | {<br />enabled?: boolean;<br />type?: ApplicationProtocol;<br />version?: ApplicationProtocolVersion;<br />options?: ServerOptions;<br />authentication?: {<br />enabled: boolean;<br />type: AuthenticationType;<br />username?: string;<br />password?: string;<br />}<br />} | UI server configuration section:<br />- _enabled_: enable UI server<br />- _type_: 'ws', 'mcp' or 'http' (deprecated)<br />- _version_: HTTP version '1.1' or '2.0' (ws and mcp transports only support '1.1')<br />- _options_: node.js net module [listen options](https://nodejs.org/api/net.html#serverlistenoptions-callback)<br />- _authentication_: authentication type configuration section                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                           |
-| performanceStorage         |                                              | {<br />"enabled": true,<br />"type": "none",<br />}                                                                                                                                                                           | {<br />enabled?: boolean;<br />type?: string;<br />uri?: string;<br />}                                                                                                                                                                                                         | Performance storage configuration section:<br />- _enabled_: enable performance storage<br />- _type_: 'jsonfile', 'mongodb' or 'none'<br />- _uri_: storage URI                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
-| stationTemplateUrls        |                                              | {}[]                                                                                                                                                                                                                          | {<br />file: string;<br />numberOfStations: number;<br />provisionedNumberOfStations?: number;<br />}[]                                                                                                                                                                         | array of charging station templates URIs configuration section:<br />- _file_: charging station configuration template file relative path<br />- _numberOfStations_: template number of stations at startup<br />- _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                        |                                              | {<br />"enabled": true,<br />"file": "logs/combined.log",<br />"errorFile": "logs/error.log",<br />"statisticsInterval": 60,<br />"level": "info",<br />"console": false,<br />"format": "simple",<br />"rotate": true<br />}                                                                                                        | {<br />enabled?: boolean;<br />file?: string;<br />errorFile?: string;<br />statisticsInterval?: number;<br />level?: string;<br />console?: boolean;<br />format?: string;<br />rotate?: boolean;<br />maxFiles?: string \| number;<br />maxSize?: string \| number;<br />}                                                                                                                                                                                                            | Log configuration section:<br />- _enabled_: enable logging<br />- _file_: log file relative path<br />- _errorFile_: error log file relative path<br />- _statisticsInterval_: seconds between charging stations statistics output in the logs<br />- _level_: emerg/alert/crit/error/warning/notice/info/debug [winston](https://github.com/winstonjs/winston) logging level</br >- _console_: output logs on the console<br />- _format_: [winston](https://github.com/winstonjs/winston) log format<br />- _rotate_: enable daily log files rotation<br />- _maxFiles_: maximum number of log files: https://github.com/winstonjs/winston-daily-rotate-file#options<br />- _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                     |                                              | {<br />"processType": "workerSet",<br />"startDelay": 500,<br />"elementAddDelay": 0,<br />"elementsPerWorker": 'auto',<br />"poolMinSize": 4,<br />"poolMaxSize": 16<br />}                                                                                                                                                         | {<br />processType?: WorkerProcessType;<br />startDelay?: number;<br />elementAddDelay?: number;<br />elementsPerWorker?: number \| 'auto' \| 'all';<br />poolMinSize?: number;<br />poolMaxSize?: number;<br />resourceLimits?: ResourceLimits;<br />}                                                                                                                                                                                                                                 | Worker configuration section:<br />- _processType_: worker threads process type (`workerSet`/`fixedPool`/`dynamicPool`)<br />- _startDelay_: milliseconds to wait at worker threads startup (only for `workerSet` worker threads process type)<br />- _elementAddDelay_: milliseconds to wait between charging station add<br />- _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)<br />- _poolMinSize_: worker threads pool minimum number of threads</br >- _poolMaxSize_: worker threads pool maximum number of threads<br />- _resourceLimits_: worker threads [resource limits](https://nodejs.org/api/worker_threads.html#new-workerfilename-options) object option                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                      |
+| uiServer                   |                                              | {<br />"enabled": false,<br />"type": "ws",<br />"version": "1.1",<br />"accessPolicy": {<br />"requireTlsForNonLoopback": true,<br />"trustedProxies": [],<br />"allowLoopbackProxy": false,<br />"allowedHosts": [],<br />"allowedOrigins": []<br />},<br />"options": {<br />"host": "localhost",<br />"port": 8080<br />}<br />} | {<br />enabled?: boolean;<br />type?: ApplicationProtocol;<br />version?: ApplicationProtocolVersion;<br />accessPolicy?: {<br />requireTlsForNonLoopback?: boolean;<br />trustedProxies?: string[];<br />allowLoopbackProxy?: boolean;<br />allowedHosts?: string[];<br />allowedOrigins?: string[];<br />};<br />options?: ServerOptions;<br />authentication?: {<br />enabled: boolean;<br />type: AuthenticationType;<br />username?: string;<br />password?: string;<br />}<br />} | UI server configuration section:<br />- _enabled_: enable UI server<br />- _type_: 'ws', 'mcp' or 'http' (deprecated)<br />- _version_: HTTP version '1.1' or '2.0' (ws and mcp transports only support '1.1')<br />- _accessPolicy_: gateway access policy. Loopback request sources are allowed in plaintext; non-loopback sources require TLS termination by a reverse proxy:<br />&nbsp;&nbsp;- _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`<br />&nbsp;&nbsp;- _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`<br />&nbsp;&nbsp;- _allowLoopbackProxy_: accept forwarded headers when the immediate peer is loopback AND listed in _trustedProxies_ (e.g. `['127.0.0.1', '::1']`)<br />&nbsp;&nbsp;- _allowedHosts_: explicit Host header allowlist; mitigates DNS rebinding when the UI server is exposed through a browser-facing host<br />&nbsp;&nbsp;- _allowedOrigins_: explicit Origin header allowlist; when empty, the request Origin's URL hostname falls back to matching against _allowedHosts_<br />- _options_: node.js net module [listen options](https://nodejs.org/api/net.html#serverlistenoptions-callback)<br />- _authentication_: authentication type configuration section |
+| performanceStorage         |                                              | {<br />"enabled": true,<br />"type": "none",<br />}                                                                                                                                                                                                                                                                                  | {<br />enabled?: boolean;<br />type?: string;<br />uri?: string;<br />}                                                                                                                                                                                                                                                                                                                                                                                                                 | Performance storage configuration section:<br />- _enabled_: enable performance storage<br />- _type_: 'jsonfile', 'mongodb' or 'none'<br />- _uri_: storage URI                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                     |
+| stationTemplateUrls        |                                              | {}[]                                                                                                                                                                                                                                                                                                                                 | {<br />file: string;<br />numberOfStations: number;<br />provisionedNumberOfStations?: number;<br />}[]                                                                                                                                                                                                                                                                                                                                                                                 | array of charging station templates URIs configuration section:<br />- _file_: charging station configuration template file relative path<br />- _numberOfStations_: template number of stations at startup<br />- _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.
+
 <!-- Or with the optional git submodules:
 
 ```shell
index 5bd7ccee417749c3a558520b39cdb30419db2efa..07d5447447234b876a65b0a844efc56c29f95f8a 100644 (file)
     "options": {
       "host": "::"
     },
+    "accessPolicy": {
+      "allowedHosts": ["localhost", "127.0.0.1", "::1"],
+      "allowedOrigins": [],
+      "allowLoopbackProxy": false,
+      "requireTlsForNonLoopback": false,
+      "trustedProxies": []
+    },
     "type": "ws",
     "authentication": {
       "enabled": true,
index e5044cf1bb62bafd6d8dbdccef8332fa49df67a9..827b0f491ed56de5d7c6ed98c875335669fb5281 100644 (file)
@@ -12,4 +12,4 @@ services:
     networks:
       - ev_network
     ports:
-      - 8080:8080
+      - 127.0.0.1:8080:8080
index 02f759c387e53c005280f780c0eb90794f79ebc1..613e44eb2a828e2c65be1ebf1e4b7423bdcaf5be 100644 (file)
   "uiServer": {
     "enabled": false,
     "type": "ws",
+    "accessPolicy": {
+      "requireTlsForNonLoopback": true,
+      "trustedProxies": [],
+      "allowLoopbackProxy": false,
+      "allowedHosts": [],
+      "allowedOrigins": []
+    },
     "authentication": {
       "enabled": true,
       "type": "protocol-basic-auth",
index 752da6537fe752ef68d216bfe5a8f84d56d04211..6b97e92eb51598d24459757e9599ae63768999f1 100644 (file)
@@ -1,5 +1,6 @@
 import type { WebSocket } from 'ws'
 
+import { getReasonPhrase, StatusCodes } from 'http-status-codes'
 import { type IncomingMessage, Server, type ServerResponse } from 'node:http'
 import { createServer, type Http2Server } from 'node:http2'
 
@@ -23,6 +24,13 @@ import {
 } from '../../types/index.js'
 import { isEmpty, isNotEmptyString, logger, logPrefix } from '../../utils/index.js'
 import { UIServiceFactory } from './ui-services/UIServiceFactory.js'
+import {
+  createUIServerAccessCache,
+  resolveUIServerAccess,
+  type UIServerAccessCache,
+  type UIServerAccessDecision,
+} from './UIServerAccessPolicy.js'
+import { isLoopback } from './UIServerNet.js'
 import {
   createRateLimiter,
   DEFAULT_RATE_LIMIT,
@@ -31,8 +39,31 @@ import {
 } from './UIServerSecurity.js'
 import { getUsernameAndPasswordFromAuthorizationToken } from './UIServerUtils.js'
 
+/**
+ * Outcome of {@link AbstractUIServer.runRequestPrologue}.
+ *
+ * Discriminated by `ok`. On `ok: true` the caller proceeds to authentication
+ * and protocol handling with the resolved {@link UIServerAccessDecision}
+ * (always `allowed: true`). On `ok: false` the caller renders the rejection
+ * to its native transport response (HTTP body / WebSocket status line).
+ */
+type UIServerRequestPrologueResult =
+  | {
+    readonly decision: Extract<UIServerAccessDecision, { allowed: true }>
+    readonly ok: true
+  }
+  | {
+    readonly headers?: Readonly<Record<string, string>>
+    readonly ok: false
+    readonly reasonPhrase: string
+    readonly status: StatusCodes
+  }
+
 const CLIENT_NOTIFICATION_DEBOUNCE_MS = 500
 
+const HTTP_HEADERS_TIMEOUT_MS = 5_000
+const HTTP_REQUEST_TIMEOUT_MS = 30_000
+
 const moduleName = 'AbstractUIServer'
 
 export abstract class AbstractUIServer {
@@ -44,6 +75,7 @@ export abstract class AbstractUIServer {
 
   protected readonly uiServices: Map<ProtocolVersion, AbstractUIService>
 
+  private readonly accessCache: UIServerAccessCache
   private readonly bootstrap: IBootstrap
   private readonly chargingStations: Map<string, ChargingStationData>
   private readonly chargingStationTemplates: Set<string>
@@ -69,9 +101,17 @@ export abstract class AbstractUIServer {
           `Unsupported application protocol version ${this.uiServerConfiguration.version} in '${ConfigurationSection.uiServer}' configuration section`
         )
     }
+    if ('requestTimeout' in this.httpServer) {
+      this.httpServer.requestTimeout = HTTP_REQUEST_TIMEOUT_MS
+    }
+    if ('headersTimeout' in this.httpServer) {
+      this.httpServer.headersTimeout = HTTP_HEADERS_TIMEOUT_MS
+    }
     this.responseHandlers = new Map<UUIDv4, ServerResponse | WebSocket>()
     this.rateLimiter = createRateLimiter(DEFAULT_RATE_LIMIT, DEFAULT_RATE_WINDOW_MS)
     this.uiServices = new Map<ProtocolVersion, AbstractUIService>()
+    this.accessCache = createUIServerAccessCache()
+    this.warnIfMisconfigured()
   }
 
   public buildProtocolRequest (
@@ -190,18 +230,44 @@ export abstract class AbstractUIServer {
     this.clearCaches()
   }
 
-  protected authenticate (req: IncomingMessage, next: (err?: Error) => void): void {
+  protected authenticate (req: IncomingMessage): boolean {
     if (this.uiServerConfiguration.authentication?.enabled !== true) {
-      next()
-      return
+      return true
     }
-    let ok = false
     if (this.isBasicAuthEnabled()) {
-      ok = this.isValidBasicAuth(req, next)
-    } else if (this.isProtocolBasicAuthEnabled()) {
-      ok = this.isValidProtocolBasicAuth(req, next)
+      return this.isValidBasicAuth(req)
+    }
+    if (this.isProtocolBasicAuthEnabled()) {
+      return this.isValidProtocolBasicAuth(req)
+    }
+    return false
+  }
+
+  /**
+   * Connection-close header to attach on denial responses.
+   *
+   * HTTP/2 forbids `Connection` as a connection-specific header; emitting it
+   * triggers a Node `UnsupportedWarning` and the value is dropped. Streams are
+   * closed by `res.end()` instead. HTTP/1.1 responses keep the explicit close
+   * to terminate keep-alive on denial.
+   * @returns Header object spreadable into `writeHead` (empty on HTTP/2).
+   */
+  protected getConnectionCloseHeader (): Record<string, string> {
+    return this.uiServerConfiguration.version === ApplicationProtocolVersion.VERSION_20
+      ? {}
+      : { Connection: 'close' }
+  }
+
+  protected getUnauthorizedDenial (): {
+    headers: Readonly<Record<string, string>>
+    reasonPhrase: string
+    status: StatusCodes
+  } {
+    return {
+      headers: { 'WWW-Authenticate': 'Basic realm=users' },
+      reasonPhrase: getReasonPhrase(StatusCodes.UNAUTHORIZED),
+      status: StatusCodes.UNAUTHORIZED,
     }
-    next(ok ? undefined : new BaseError('Unauthorized'))
   }
 
   protected notifyClients (): void {
@@ -214,6 +280,69 @@ export abstract class AbstractUIServer {
     }
   }
 
+  protected renderDenial (
+    res: ServerResponse,
+    payload: {
+      headers?: Readonly<Record<string, string>>
+      reasonPhrase: string
+      status: StatusCodes
+    }
+  ): void {
+    if (res.headersSent) return
+    res
+      .writeHead(payload.status, {
+        'Content-Type': 'text/plain',
+        ...payload.headers,
+        ...this.getConnectionCloseHeader(),
+      })
+      .end(`${payload.status.toString()} ${payload.reasonPhrase}`)
+  }
+
+  /**
+   * Run the access-policy + rate-limit prologue for a request.
+   *
+   * The order is fixed:
+   * 1. Resolve the access decision (memoized on the request).
+   * 2. Account every request against the rate limiter, including denied
+   *    ones.
+   * 3. Apply the access verdict.
+   *
+   * Authentication is delegated to each transport.
+   * @param req The incoming HTTP request.
+   * @returns A discriminated {@link UIServerRequestPrologueResult}.
+   */
+  protected runRequestPrologue (req: IncomingMessage): UIServerRequestPrologueResult {
+    const decision = resolveUIServerAccess(req, this.uiServerConfiguration, this.accessCache)
+    const rateLimitKey = decision.clientAddress.length > 0 ? decision.clientAddress : 'unknown'
+    if (!this.rateLimiter(rateLimitKey)) {
+      logger.warn(
+        `${this.logPrefix(
+          moduleName,
+          'runRequestPrologue'
+        )} UI rate limit exceeded for client '${rateLimitKey}'`
+      )
+      return {
+        headers: { 'Retry-After': '60' },
+        ok: false,
+        reasonPhrase: getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
+        status: StatusCodes.TOO_MANY_REQUESTS,
+      }
+    }
+    if (!decision.allowed) {
+      logger.warn(
+        `${this.logPrefix(moduleName, 'runRequestPrologue')} UI access denied: ${
+          decision.message
+        } (reason=${decision.reason})`
+      )
+      return {
+        ok: false,
+        reasonPhrase: getReasonPhrase(StatusCodes.FORBIDDEN),
+        status: StatusCodes.FORBIDDEN,
+      }
+    }
+    return { decision, ok: true }
+  }
+
   protected startHttpServer (): void {
     this.httpServer.on('error', error => {
       logger.error(
@@ -240,10 +369,9 @@ export abstract class AbstractUIServer {
     )
   }
 
-  private isValidBasicAuth (req: IncomingMessage, next: (err?: Error) => void): boolean {
+  private isValidBasicAuth (req: IncomingMessage): boolean {
     const usernameAndPassword = getUsernameAndPasswordFromAuthorizationToken(
-      req.headers.authorization?.split(/\s+/).pop() ?? '',
-      next
+      req.headers.authorization?.split(/\s+/).pop() ?? ''
     )
     if (usernameAndPassword == null) {
       return false
@@ -252,7 +380,7 @@ export abstract class AbstractUIServer {
     return this.isValidUsernameAndPassword(username, password)
   }
 
-  private isValidProtocolBasicAuth (req: IncomingMessage, next: (err?: Error) => void): boolean {
+  private isValidProtocolBasicAuth (req: IncomingMessage): boolean {
     const authorizationProtocol = req.headers['sec-websocket-protocol']?.split(/,\s+/).pop()
     if (authorizationProtocol == null || isEmpty(authorizationProtocol)) {
       return false
@@ -262,8 +390,7 @@ export abstract class AbstractUIServer {
         '='
       )}`
         .split('.')
-        .pop() ?? '',
-      next
+        .pop() ?? ''
     )
     if (usernameAndPassword == null) {
       return false
@@ -285,4 +412,32 @@ export abstract class AbstractUIServer {
       this.httpServer.removeAllListeners()
     }
   }
+
+  private warnIfMisconfigured (): void {
+    const configuredHost = this.uiServerConfiguration.options?.host ?? ''
+    const accessPolicy = this.uiServerConfiguration.accessPolicy
+    const allowedHosts = accessPolicy?.allowedHosts ?? []
+    const trustedProxies = accessPolicy?.trustedProxies ?? []
+    const requireTls = accessPolicy?.requireTlsForNonLoopback ?? true
+    const isWildcard =
+      configuredHost === '' || configuredHost === '0.0.0.0' || configuredHost === '::'
+
+    if (isWildcard && allowedHosts.length === 0) {
+      logger.warn(
+        `${this.logPrefix(
+          moduleName,
+          'constructor'
+        )} UI server bound to wildcard host '${configuredHost}' with no accessPolicy.allowedHosts; all requests will be denied as host-not-allowed. Configure accessPolicy.allowedHosts or set options.host to a specific address.`
+      )
+      return
+    }
+    if (!isWildcard && !isLoopback(configuredHost) && requireTls && trustedProxies.length === 0) {
+      logger.warn(
+        `${this.logPrefix(
+          moduleName,
+          'constructor'
+        )} UI server bound to non-loopback host '${configuredHost}' with requireTlsForNonLoopback=true and no accessPolicy.trustedProxies; plaintext requests will be denied as tls-required. Configure accessPolicy.trustedProxies to terminate TLS upstream, or set requireTlsForNonLoopback=false on private bindings.`
+      )
+    }
+  }
 }
index 3e79fdde3907ad01f15b8c52f11c0aa592a0a75d..ec4c1d6ec02c62a7404994c4b0aa28e3ce2ddc1d 100644 (file)
@@ -1,6 +1,6 @@
 import type { IncomingMessage, ServerResponse } from 'node:http'
 
-import { StatusCodes } from 'http-status-codes'
+import { getReasonPhrase, StatusCodes } from 'http-status-codes'
 import { createGzip } from 'node:zlib'
 
 import type { IBootstrap } from '../IBootstrap.js'
@@ -22,9 +22,10 @@ import {
 import { generateUUID, getErrorMessage, JSONStringify, logger } from '../../utils/index.js'
 import { AbstractUIServer } from './AbstractUIServer.js'
 import {
-  createBodySizeLimiter,
   DEFAULT_COMPRESSION_THRESHOLD_BYTES,
   DEFAULT_MAX_PAYLOAD_SIZE_BYTES,
+  PayloadTooLargeError,
+  readLimitedBody,
 } from './UIServerSecurity.js'
 import { HttpMethod, isProtocolAndVersionSupported } from './UIServerUtils.js'
 
@@ -104,148 +105,117 @@ export class UIHttpServer extends AbstractUIServer {
     this.startHttpServer()
   }
 
-  private requestListener (req: IncomingMessage, res: ServerResponse): void {
-    // Rate limiting check
-    const clientIp = req.socket.remoteAddress ?? 'unknown'
-    if (!this.rateLimiter(clientIp)) {
-      res
-        .writeHead(StatusCodes.TOO_MANY_REQUESTS, {
-          'Content-Type': 'text/plain',
-          'Retry-After': '60',
+  private async handleRequestBody (
+    req: IncomingMessage,
+    res: ServerResponse,
+    uuid: UUIDv4,
+    version: ProtocolVersion,
+    procedureName: ProcedureName
+  ): Promise<void> {
+    const buffer = await readLimitedBody(req, DEFAULT_MAX_PAYLOAD_SIZE_BYTES)
+    let requestPayload: RequestPayload
+    try {
+      requestPayload = JSON.parse(buffer.toString()) as RequestPayload
+    } catch (error) {
+      this.sendResponse(
+        this.buildProtocolResponse(uuid, {
+          errorMessage: getErrorMessage(error),
+          errorStack: error instanceof Error ? error.stack : undefined,
+          status: ResponseStatus.FAILURE,
         })
-        .end(`${StatusCodes.TOO_MANY_REQUESTS.toString()} Too Many Requests`)
-      res.destroy()
-      req.destroy()
+      )
+      return
+    }
+    const service = this.uiServices.get(version)
+    if (service == null || typeof service.requestHandler !== 'function') {
+      this.sendResponse(this.buildProtocolResponse(uuid, { status: ResponseStatus.FAILURE }))
       return
     }
+    const protocolResponse = await service.requestHandler(
+      this.buildProtocolRequest(uuid, procedureName, requestPayload)
+    )
+    if (protocolResponse != null) {
+      this.sendResponse(protocolResponse)
+    } else {
+      this.sendResponse(this.buildProtocolResponse(uuid, { status: ResponseStatus.SUCCESS }))
+    }
+  }
 
-    this.authenticate(req, err => {
-      if (err != null) {
-        res
-          .writeHead(StatusCodes.UNAUTHORIZED, {
-            'Content-Type': 'text/plain',
-            'WWW-Authenticate': 'Basic realm=users',
-          })
-          .end(`${StatusCodes.UNAUTHORIZED.toString()} Unauthorized`)
-        res.destroy()
-        req.destroy()
-        return
-      }
+  private requestListener (req: IncomingMessage, res: ServerResponse): void {
+    const prologue = this.runRequestPrologue(req)
+    if (!prologue.ok) {
+      this.renderDenial(res, prologue)
+      return
+    }
+    if (!this.authenticate(req)) {
+      this.renderDenial(res, this.getUnauthorizedDenial())
+      return
+    }
 
-      const uuid = generateUUID()
-      this.responseHandlers.set(uuid, res)
-      const acceptEncoding = req.headers['accept-encoding'] ?? ''
-      this.acceptsGzip.set(uuid, /\bgzip\b/.test(acceptEncoding))
-      res.on('close', () => {
-        this.responseHandlers.delete(uuid)
-        this.acceptsGzip.delete(uuid)
-      })
-      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)
+    const uuid = generateUUID()
+    this.responseHandlers.set(uuid, res)
+    const acceptEncoding = req.headers['accept-encoding'] ?? ''
+    this.acceptsGzip.set(uuid, /\bgzip\b/.test(acceptEncoding))
+    res.on('close', () => {
+      this.responseHandlers.delete(uuid)
+      this.acceptsGzip.delete(uuid)
+    })
 
-        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)
-          }
-        })
+    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)
 
-        if (req.method !== HttpMethod.POST) {
-          // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
-          throw new BaseError(`Unsupported HTTP method: '${req.method}'`)
+      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)
         }
+      })
 
-        const bodyBuffer: Uint8Array[] = []
-        const checkBodySize = createBodySizeLimiter(DEFAULT_MAX_PAYLOAD_SIZE_BYTES)
-        req
-          .on('data', (chunk: Uint8Array) => {
-            if (!checkBodySize(chunk.length)) {
-              res
-                .writeHead(StatusCodes.REQUEST_TOO_LONG, {
-                  'Content-Type': 'text/plain',
-                })
-                .end(`${StatusCodes.REQUEST_TOO_LONG.toString()} Payload Too Large`)
-              res.destroy()
-              req.destroy()
-              return
-            }
-            bodyBuffer.push(chunk)
-          })
-          .on('end', () => {
-            let requestPayload: RequestPayload | undefined
-            try {
-              requestPayload = JSON.parse(Buffer.concat(bodyBuffer).toString()) as RequestPayload
-            } catch (error) {
-              this.sendResponse(
-                this.buildProtocolResponse(uuid, {
-                  errorMessage: getErrorMessage(error),
-                  errorStack: error instanceof Error ? error.stack : undefined,
-                  status: ResponseStatus.FAILURE,
-                })
-              )
-              return
-            }
-            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((error: unknown) => {
-                logger.error(
-                  `${this.logPrefix(moduleName, 'requestListener.service.requestHandler')} UI service request handler error:`,
-                  error
-                )
-                this.sendResponse(
-                  this.buildProtocolResponse(uuid, { status: ResponseStatus.FAILURE })
-                )
-              })
+      if (req.method !== HttpMethod.POST) {
+        // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
+        throw new BaseError(`Unsupported HTTP method: '${req.method}'`)
+      }
+
+      this.handleRequestBody(req, res, uuid, version, procedureName).catch((error: unknown) => {
+        if (error instanceof PayloadTooLargeError) {
+          this.renderDenial(res, {
+            reasonPhrase: getReasonPhrase(StatusCodes.REQUEST_TOO_LONG),
+            status: StatusCodes.REQUEST_TOO_LONG,
           })
-      } catch (error) {
+          return
+        }
         logger.error(
-          `${this.logPrefix(moduleName, 'requestListener')} Handle HTTP request error:`,
+          `${this.logPrefix(moduleName, 'requestListener.service.requestHandler')} UI service request handler 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 cbeaea3e7f2901f40390932c1e71019bb0541c4e..fd302a4362ce6cf371af7a833c784a98ef20cecb 100644 (file)
@@ -3,6 +3,7 @@ import type { IncomingMessage, ServerResponse } from 'node:http'
 
 import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
 import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
+import { getReasonPhrase, StatusCodes } from 'http-status-codes'
 import { readFileSync } from 'node:fs'
 import { dirname, join } from 'node:path'
 import { fileURLToPath } from 'node:url'
@@ -37,7 +38,11 @@ import {
   registerMCPResources,
   registerMCPSchemaResources,
 } from './mcp/index.js'
-import { DEFAULT_MAX_PAYLOAD_SIZE_BYTES } from './UIServerSecurity.js'
+import {
+  DEFAULT_MAX_PAYLOAD_SIZE_BYTES,
+  PayloadTooLargeError,
+  readLimitedBody,
+} from './UIServerSecurity.js'
 import { HttpMethod } from './UIServerUtils.js'
 
 const moduleName = 'UIMCPServer'
@@ -111,33 +116,28 @@ export class UIMCPServer extends AbstractUIServer {
     this.ocppSchemaCache = this.loadOcppSchemas()
 
     this.httpServer.on('request', (req: IncomingMessage, res: ServerResponse) => {
-      const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`)
+      const prologue = this.runRequestPrologue(req)
+      if (!prologue.ok) {
+        this.renderDenial(res, prologue)
+        return
+      }
+
+      const url = new URL(req.url ?? '/', 'http://localhost')
+      // Path filter runs before authenticate so unknown paths return 404
+      // without revealing whether authentication would have succeeded.
       if (url.pathname !== '/mcp') {
-        res.writeHead(404, { 'Content-Type': 'text/plain' }).end('404 Not Found')
+        this.renderDenial(res, {
+          reasonPhrase: getReasonPhrase(StatusCodes.NOT_FOUND),
+          status: StatusCodes.NOT_FOUND,
+        })
         if (!req.complete) {
           req.destroy()
         }
         return
       }
 
-      const clientIp = req.socket.remoteAddress ?? 'unknown'
-      if (!this.rateLimiter(clientIp)) {
-        res.writeHead(429, { 'Content-Type': 'text/plain' }).end('429 Too Many Requests')
-        return
-      }
-
-      let authError: Error | undefined
-      // authenticate() is synchronous — authError is set before the if-check
-      this.authenticate(req, err => {
-        authError = err
-      })
-      if (authError != null) {
-        res
-          .writeHead(401, {
-            'Content-Type': 'text/plain',
-            'WWW-Authenticate': 'Basic realm=users',
-          })
-          .end('401 Unauthorized')
+      if (!this.authenticate(req)) {
+        this.renderDenial(res, this.getUnauthorizedDenial())
         return
       }
 
@@ -250,16 +250,10 @@ export class UIMCPServer extends AbstractUIServer {
   private async handleMcpRequest (req: IncomingMessage, res: ServerResponse): Promise<void> {
     const mcpServer = this.createMcpServer()
     const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined })
-    try {
-      await mcpServer.connect(transport)
-    } catch (error: unknown) {
-      logger.error(`${this.logPrefix(moduleName, 'handleMcpRequest')} MCP connect error:`, error)
-      this.closeTransportSafely(transport)
-      this.sendErrorResponse(res, 500)
-      return
-    }
-
+    let cleanedUp = false
     const cleanup = (): void => {
+      if (cleanedUp) return
+      cleanedUp = true
       this.closeTransportSafely(transport)
       mcpServer.close().catch((error: unknown) => {
         logger.error(
@@ -268,24 +262,37 @@ export class UIMCPServer extends AbstractUIServer {
         )
       })
     }
+    res.on('close', cleanup)
+
+    try {
+      await mcpServer.connect(transport)
+    } catch (error: unknown) {
+      logger.error(`${this.logPrefix(moduleName, 'handleMcpRequest')} MCP connect error:`, error)
+      cleanup()
+      this.sendErrorResponse(res, StatusCodes.INTERNAL_SERVER_ERROR)
+      return
+    }
 
     try {
       if (req.method === HttpMethod.POST) {
         const body = await this.readRequestBody(req)
-        res.on('close', cleanup)
         await transport.handleRequest(req, res, body)
       } else if (req.method === HttpMethod.GET || req.method === HttpMethod.DELETE) {
-        res.on('close', cleanup)
         await transport.handleRequest(req, res)
       } else {
-        this.sendErrorResponse(res, 405)
         cleanup()
+        this.sendErrorResponse(res, StatusCodes.METHOD_NOT_ALLOWED, {
+          Allow: 'GET, POST, DELETE',
+        })
       }
     } catch (error: unknown) {
       logger.error(`${this.logPrefix(moduleName, 'handleMcpRequest')} MCP transport error:`, error)
-      const isBadRequest =
-        error instanceof SyntaxError || getErrorMessage(error).includes('Payload too large')
-      this.sendErrorResponse(res, isBadRequest ? 400 : 500)
+      cleanup()
+      const isBadRequest = error instanceof SyntaxError || error instanceof PayloadTooLargeError
+      this.sendErrorResponse(
+        res,
+        isBadRequest ? StatusCodes.BAD_REQUEST : StatusCodes.INTERNAL_SERVER_ERROR
+      )
     }
   }
 
@@ -454,27 +461,19 @@ export class UIMCPServer extends AbstractUIServer {
   }
 
   private async readRequestBody (req: IncomingMessage): Promise<unknown> {
-    const chunks: Buffer[] = []
-    let received = 0
-    for await (const chunk of req) {
-      received += (chunk as Buffer).length
-      if (received > DEFAULT_MAX_PAYLOAD_SIZE_BYTES) {
-        throw new BaseError('Payload too large')
-      }
-      chunks.push(chunk as Buffer)
-    }
-    return JSON.parse(Buffer.concat(chunks).toString('utf8'))
+    const buffer = await readLimitedBody(req, DEFAULT_MAX_PAYLOAD_SIZE_BYTES)
+    return JSON.parse(buffer.toString('utf8'))
   }
 
-  private sendErrorResponse (res: ServerResponse, statusCode: number): void {
-    if (res.headersSent) return
-    const messages: Record<number, string> = {
-      400: '400 Bad Request',
-      405: '405 Method Not Allowed',
-      500: '500 Internal Server Error',
-    }
-    res
-      .writeHead(statusCode, { 'Content-Type': 'text/plain' })
-      .end(messages[statusCode] ?? `${statusCode.toString()} Error`)
+  private sendErrorResponse (
+    res: ServerResponse,
+    statusCode: StatusCodes,
+    headers?: Readonly<Record<string, string>>
+  ): void {
+    this.renderDenial(res, {
+      headers,
+      reasonPhrase: getReasonPhrase(statusCode),
+      status: statusCode,
+    })
   }
 }
diff --git a/src/charging-station/ui-server/UIServerAccessPolicy.ts b/src/charging-station/ui-server/UIServerAccessPolicy.ts
new file mode 100644 (file)
index 0000000..97e0e2f
--- /dev/null
@@ -0,0 +1,503 @@
+import type { IncomingMessage } from 'node:http'
+
+import type { UIServerConfiguration } from '../../types/index.js'
+
+import { UI_SERVER_ACCESS_POLICY_DEFAULTS } from '../../utils/ConfigurationSchema.js'
+import {
+  isLoopback,
+  normalizeHost,
+  normalizeIPAddress,
+  splitHeaderList,
+  splitQuoted,
+} from './UIServerNet.js'
+
+const FORWARDED_HEADER_NAMES = [
+  'forwarded',
+  'x-forwarded-for',
+  'x-forwarded-host',
+  'x-forwarded-proto',
+] as const
+const SECURE_FORWARDED_PROTOCOLS = new Set(['https', 'wss'])
+const WILDCARD_HOSTS = new Set(['', '0.0.0.0', '::'])
+
+/**
+ * Reasons a UI server access decision is denied.
+ *
+ * The enum value is the machine-readable identity; the rendered text is
+ * `DENIAL_MESSAGES[reason]`.
+ */
+export enum UIServerAccessDenialReason {
+  AmbiguousForwardedClient = 'ambiguous-forwarded-client',
+  AmbiguousForwardedHeader = 'ambiguous-forwarded-header',
+  AmbiguousForwardedHost = 'ambiguous-forwarded-host',
+  AmbiguousForwardedParameter = 'ambiguous-forwarded-parameter',
+  AmbiguousForwardedProtocol = 'ambiguous-forwarded-protocol',
+  DuplicateGatewayHeaders = 'duplicate-gateway-headers',
+  ForwardedFromUntrustedPeer = 'forwarded-from-untrusted-peer',
+  HostNotAllowed = 'host-not-allowed',
+  InvalidForwardedClient = 'invalid-forwarded-client',
+  LoopbackProxyDisabled = 'loopback-proxy-disabled',
+  OriginNotAllowed = 'origin-not-allowed',
+  ProxyTlsRequired = 'proxy-tls-required',
+  TlsRequired = 'tls-required',
+}
+
+const DENIAL_MESSAGES: Readonly<Record<UIServerAccessDenialReason, string>> = {
+  [UIServerAccessDenialReason.AmbiguousForwardedClient]:
+    'Ambiguous forwarded client address headers are not allowed',
+  [UIServerAccessDenialReason.AmbiguousForwardedHeader]:
+    'Ambiguous Forwarded header is not allowed',
+  [UIServerAccessDenialReason.AmbiguousForwardedHost]:
+    'Ambiguous forwarded host headers are not allowed',
+  [UIServerAccessDenialReason.AmbiguousForwardedParameter]:
+    'Ambiguous Forwarded parameter is not allowed',
+  [UIServerAccessDenialReason.AmbiguousForwardedProtocol]:
+    'Ambiguous forwarded protocol headers are not allowed',
+  [UIServerAccessDenialReason.DuplicateGatewayHeaders]:
+    'Duplicate gateway security headers are not allowed',
+  [UIServerAccessDenialReason.ForwardedFromUntrustedPeer]:
+    'Forwarded headers are only accepted from trusted proxies',
+  [UIServerAccessDenialReason.HostNotAllowed]: 'Host header is not allowed',
+  [UIServerAccessDenialReason.InvalidForwardedClient]:
+    'Invalid X-Forwarded-For header is not allowed',
+  [UIServerAccessDenialReason.LoopbackProxyDisabled]:
+    'Loopback proxy forwarding requires accessPolicy.allowLoopbackProxy=true',
+  [UIServerAccessDenialReason.OriginNotAllowed]: 'Origin header is not allowed',
+  [UIServerAccessDenialReason.ProxyTlsRequired]:
+    'Trusted proxy requests must use a secure forwarded protocol',
+  [UIServerAccessDenialReason.TlsRequired]: 'TLS is required for non-loopback UI server access',
+}
+
+/**
+ * Per-{@link AbstractUIServer} cache holding the decisions of in-flight
+ * requests and the normalized trusted-proxy index of the active
+ * configuration. Both maps are weakly keyed so entries are released with
+ * their owning object.
+ */
+export interface UIServerAccessCache {
+  readonly decisions: WeakMap<IncomingMessage, UIServerAccessDecision>
+  readonly trustedProxies: WeakMap<UIServerConfiguration, ReadonlySet<string>>
+}
+
+/**
+ * UI server access decision: `allowed: true` carries the resolved client
+ * address; `allowed: false` carries the denial reason and rendered message.
+ */
+export type UIServerAccessDecision =
+  | {
+    readonly allowed: false
+    readonly clientAddress: string
+    readonly message: string
+    readonly reason: UIServerAccessDenialReason
+  }
+  | { readonly allowed: true; readonly clientAddress: string }
+
+export const createUIServerAccessCache = (): UIServerAccessCache => ({
+  decisions: new WeakMap<IncomingMessage, UIServerAccessDecision>(),
+  trustedProxies: new WeakMap<UIServerConfiguration, ReadonlySet<string>>(),
+})
+
+type ParseOutcome<T> =
+  | { readonly kind: 'absent' }
+  | { readonly kind: 'error'; readonly reason: UIServerAccessDenialReason }
+  | { readonly kind: 'ok'; readonly value: T }
+
+const ABSENT: ParseOutcome<never> = { kind: 'absent' }
+
+type ForwardedParams = Partial<Record<'by' | 'for' | 'host' | 'proto', string>>
+
+export const resolveUIServerAccess = (
+  req: IncomingMessage,
+  uiServerConfiguration: UIServerConfiguration,
+  cache: UIServerAccessCache
+): UIServerAccessDecision => {
+  const cached = cache.decisions.get(req)
+  if (cached != null) {
+    return cached
+  }
+  const decision = evaluateUIServerAccess(req, uiServerConfiguration, cache)
+  cache.decisions.set(req, decision)
+  return decision
+}
+
+const evaluateUIServerAccess = (
+  req: IncomingMessage,
+  uiServerConfiguration: UIServerConfiguration,
+  cache: UIServerAccessCache
+): UIServerAccessDecision => {
+  const accessPolicy = uiServerConfiguration.accessPolicy
+  const allowLoopbackProxy =
+    accessPolicy?.allowLoopbackProxy ?? UI_SERVER_ACCESS_POLICY_DEFAULTS.allowLoopbackProxy
+  const requireTlsForNonLoopback =
+    accessPolicy?.requireTlsForNonLoopback ??
+    UI_SERVER_ACCESS_POLICY_DEFAULTS.requireTlsForNonLoopback
+  const trustedProxies = getTrustedProxies(uiServerConfiguration, cache)
+  const remoteAddress = req.socket.remoteAddress ?? ''
+  const remoteAddressIsLoopback = isLoopback(remoteAddress)
+  const remoteAddressIsTrustedProxy = isTrustedProxy(remoteAddress, trustedProxies)
+  const forwardedHeadersPresent = hasForwardedHeaders(req)
+  const forwarded = parseSingleForwardedHeader(req)
+  const forwardedProtocol = getForwardedProtocol(req, forwarded)
+  const forwardedClientAddress = getForwardedClientAddress(
+    req,
+    remoteAddressIsTrustedProxy,
+    forwarded
+  )
+  const forwardedHost = getForwardedHost(req, forwarded)
+  const clientAddress =
+    forwardedClientAddress.kind === 'ok' ? forwardedClientAddress.value : remoteAddress
+
+  if (hasDuplicateHeaders(req, [...FORWARDED_HEADER_NAMES, 'host', 'origin'])) {
+    return deny(clientAddress, UIServerAccessDenialReason.DuplicateGatewayHeaders)
+  }
+  if (forwardedHeadersPresent && !remoteAddressIsTrustedProxy) {
+    return deny(clientAddress, UIServerAccessDenialReason.ForwardedFromUntrustedPeer)
+  }
+  if (forwardedProtocol.kind === 'error') {
+    return deny(clientAddress, forwardedProtocol.reason)
+  }
+  if (forwardedClientAddress.kind === 'error') {
+    return deny(clientAddress, forwardedClientAddress.reason)
+  }
+  if (forwardedHost.kind === 'error') {
+    return deny(clientAddress, forwardedHost.reason)
+  }
+  if (forwardedHeadersPresent && remoteAddressIsLoopback && !allowLoopbackProxy) {
+    return deny(clientAddress, UIServerAccessDenialReason.LoopbackProxyDisabled)
+  }
+  if (!isHostAllowed(req, uiServerConfiguration, remoteAddressIsTrustedProxy, forwardedHost)) {
+    return deny(clientAddress, UIServerAccessDenialReason.HostNotAllowed)
+  }
+  if (!isOriginAllowed(req, uiServerConfiguration)) {
+    return deny(clientAddress, UIServerAccessDenialReason.OriginNotAllowed)
+  }
+
+  const forwardedProtocolValue =
+    forwardedProtocol.kind === 'ok' ? forwardedProtocol.value : undefined
+  const secureForwardedProtocol = isSecureForwardedProtocol(forwardedProtocolValue)
+  if (requireTlsForNonLoopback && forwardedHeadersPresent && !secureForwardedProtocol) {
+    return deny(clientAddress, UIServerAccessDenialReason.ProxyTlsRequired)
+  }
+  if (requireTlsForNonLoopback && !remoteAddressIsLoopback && !secureForwardedProtocol) {
+    return deny(clientAddress, UIServerAccessDenialReason.TlsRequired)
+  }
+
+  return { allowed: true, clientAddress }
+}
+
+const deny = (
+  clientAddress: string,
+  reason: UIServerAccessDenialReason
+): UIServerAccessDecision => {
+  return {
+    allowed: false,
+    clientAddress,
+    message: DENIAL_MESSAGES[reason],
+    reason,
+  }
+}
+
+const getForwardedClientAddress = (
+  req: IncomingMessage,
+  trustedProxy: boolean,
+  forwarded: ParseOutcome<ForwardedParams>
+): ParseOutcome<string> => {
+  if (!trustedProxy) {
+    return ABSENT
+  }
+  if (forwarded.kind === 'error') {
+    return forwarded
+  }
+  const picked = pickForwardedValue(
+    nonEmpty(getSingleHeaderValue(req, 'x-forwarded-for')),
+    forwarded.kind === 'ok' ? forwarded.value.for : undefined,
+    UIServerAccessDenialReason.AmbiguousForwardedClient
+  )
+  if (picked.kind !== 'ok') {
+    return picked
+  }
+  const addresses = splitHeaderList(picked.value)
+  if (addresses.length === 0) {
+    return { kind: 'error', reason: UIServerAccessDenialReason.InvalidForwardedClient }
+  }
+  // Multi-hop X-Forwarded-For chains are intentionally rejected: ambiguity in
+  // trust depth would require a CIDR/hop-count model (see proxy-addr
+  // semantics) that is out of scope for this version. Documented in the
+  // README "UI Protocol" section.
+  if (addresses.length !== 1) {
+    return { kind: 'error', reason: UIServerAccessDenialReason.AmbiguousForwardedClient }
+  }
+  const candidate = addresses[0]
+  if (isHiddenIdentity(candidate)) {
+    return ABSENT
+  }
+  const normalizedAddress = normalizeIPAddress(candidate)
+  return normalizedAddress != null
+    ? { kind: 'ok', value: normalizedAddress.value }
+    : { kind: 'error', reason: UIServerAccessDenialReason.InvalidForwardedClient }
+}
+
+const getForwardedProtocol = (
+  req: IncomingMessage,
+  forwarded: ParseOutcome<ForwardedParams>
+): ParseOutcome<string> => {
+  if (forwarded.kind === 'error') {
+    return forwarded
+  }
+  const xForwardedProtocol = nonEmpty(getSingleHeaderValue(req, 'x-forwarded-proto'))
+  const picked = pickForwardedValue(
+    xForwardedProtocol,
+    forwarded.kind === 'ok' ? forwarded.value.proto : undefined,
+    UIServerAccessDenialReason.AmbiguousForwardedProtocol
+  )
+  if (picked.kind !== 'ok') {
+    return picked
+  }
+  if (xForwardedProtocol != null) {
+    const protocols = splitHeaderList(xForwardedProtocol)
+    if (protocols.length !== 1) {
+      return { kind: 'error', reason: UIServerAccessDenialReason.AmbiguousForwardedProtocol }
+    }
+    return { kind: 'ok', value: protocols[0].toLowerCase() }
+  }
+  return { kind: 'ok', value: picked.value.toLowerCase() }
+}
+
+const getForwardedHost = (
+  req: IncomingMessage,
+  forwarded: ParseOutcome<ForwardedParams>
+): ParseOutcome<string> => {
+  if (forwarded.kind === 'error') {
+    return forwarded
+  }
+  const xForwardedHost = nonEmpty(getSingleHeaderValue(req, 'x-forwarded-host'))
+  const picked = pickForwardedValue(
+    xForwardedHost,
+    forwarded.kind === 'ok' ? forwarded.value.host : undefined,
+    UIServerAccessDenialReason.AmbiguousForwardedHost
+  )
+  if (picked.kind !== 'ok') {
+    return picked
+  }
+  if (xForwardedHost != null) {
+    const hosts = splitHeaderList(xForwardedHost)
+    if (hosts.length !== 1) {
+      return { kind: 'error', reason: UIServerAccessDenialReason.AmbiguousForwardedHost }
+    }
+    return { kind: 'ok', value: hosts[0] }
+  }
+  return { kind: 'ok', value: picked.value }
+}
+
+const pickForwardedValue = (
+  xValue: string | undefined,
+  forwardedValue: string | undefined,
+  ambiguousReason: UIServerAccessDenialReason
+): ParseOutcome<string> => {
+  if (xValue != null && forwardedValue != null) {
+    return { kind: 'error', reason: ambiguousReason }
+  }
+  const value = forwardedValue ?? xValue
+  return value != null ? { kind: 'ok', value } : ABSENT
+}
+
+const nonEmpty = (value: string | undefined): string | undefined =>
+  value == null || value === '' ? undefined : value
+
+// RFC 7239 §6: "unknown" and obfuscated node identifiers ("_" + token chars).
+// Optional ":port" suffix is stripped before comparison.
+const isHiddenIdentity = (value: string): boolean => {
+  const withoutPort = value.replace(/:\d+$/, '')
+  return withoutPort.toLowerCase() === 'unknown' || /^_[A-Za-z0-9._-]+$/.test(withoutPort)
+}
+
+const parseSingleForwardedHeader = (req: IncomingMessage): ParseOutcome<ForwardedParams> => {
+  const forwarded = nonEmpty(getSingleHeaderValue(req, 'forwarded'))
+  if (forwarded == null) {
+    return ABSENT
+  }
+  const entries = splitHeaderList(forwarded)
+  if (entries.length !== 1) {
+    return { kind: 'error', reason: UIServerAccessDenialReason.AmbiguousForwardedHeader }
+  }
+  const params: ForwardedParams = {}
+  for (const part of splitQuoted(entries[0], ';')) {
+    const separatorIndex = part.indexOf('=')
+    if (separatorIndex === -1) {
+      continue
+    }
+    const key = part.slice(0, separatorIndex).trim().toLowerCase()
+    const value = nonEmpty(
+      part
+        .slice(separatorIndex + 1)
+        .trim()
+        .replace(/^"(.*)"$/, '$1')
+    )
+    if (key !== 'by' && key !== 'for' && key !== 'host' && key !== 'proto') {
+      continue
+    }
+    if (value == null) {
+      continue
+    }
+    if (Object.hasOwn(params, key)) {
+      return { kind: 'error', reason: UIServerAccessDenialReason.AmbiguousForwardedParameter }
+    }
+    params[key] = value
+  }
+  return { kind: 'ok', value: params }
+}
+
+const getHeaderValues = (req: IncomingMessage, headerName: string): string[] => {
+  const value = req.headers[headerName]
+  if (Array.isArray(value)) {
+    return value
+  }
+  return typeof value === 'string' ? [value] : []
+}
+
+const getSingleHeaderValue = (req: IncomingMessage, headerName: string): string | undefined => {
+  const values = getHeaderValues(req, headerName)
+  return values.length === 1 ? values[0] : undefined
+}
+
+const hasDuplicateHeaders = (req: IncomingMessage, headerNames: readonly string[]): boolean => {
+  const distinctHeaders = req.headersDistinct
+  const rawHeaders = req.rawHeaders
+  const rawHeaderCounts = new Map<string, number>()
+  for (let index = 0; index < rawHeaders.length; index += 2) {
+    const name = rawHeaders[index].toLowerCase()
+    rawHeaderCounts.set(name, (rawHeaderCounts.get(name) ?? 0) + 1)
+  }
+  for (const headerName of headerNames) {
+    if ((distinctHeaders[headerName]?.length ?? 0) > 1) {
+      return true
+    }
+    if ((rawHeaderCounts.get(headerName) ?? 0) > 1) {
+      return true
+    }
+  }
+  return false
+}
+
+const hasForwardedHeaders = (req: IncomingMessage): boolean =>
+  FORWARDED_HEADER_NAMES.some(headerName =>
+    getHeaderValues(req, headerName).some(value => value !== '')
+  )
+
+const isHostAllowed = (
+  req: IncomingMessage,
+  uiServerConfiguration: UIServerConfiguration,
+  trustedProxy: boolean,
+  forwardedHost: ParseOutcome<string>
+): boolean => {
+  const allowedHosts = getAllowedHosts(uiServerConfiguration)
+  if (allowedHosts.length === 0) {
+    return false
+  }
+  const host = getSingleHeaderValue(req, 'host')
+  if (host == null) {
+    return false
+  }
+  // When the immediate peer is a trusted proxy and a forwarded host header
+  // is present, it is the canonical public host (proxies that rewrite `Host`
+  // to an internal upstream name forward the public name here).
+  const trustedForwardedHost =
+    trustedProxy && forwardedHost.kind === 'ok' ? forwardedHost.value : undefined
+  const hostToCheck = trustedForwardedHost ?? host
+  return allowedHosts.some(allowedHost => isSameHost(hostToCheck, allowedHost))
+}
+
+const isOriginAllowed = (
+  req: IncomingMessage,
+  uiServerConfiguration: UIServerConfiguration
+): boolean => {
+  const origin = getSingleHeaderValue(req, 'origin')
+  if (origin == null) {
+    return true
+  }
+  let originUrl: URL
+  try {
+    originUrl = new URL(origin)
+  } catch {
+    return false
+  }
+  const allowedOrigins = uiServerConfiguration.accessPolicy?.allowedOrigins ?? []
+  if (allowedOrigins.length > 0) {
+    return allowedOrigins.some(allowedOrigin => isSameOrigin(originUrl, allowedOrigin))
+  }
+  const allowedHosts = getAllowedHosts(uiServerConfiguration)
+  return (
+    allowedHosts.length > 0 &&
+    allowedHosts.some(allowedHost => isSameHost(originUrl.hostname, allowedHost))
+  )
+}
+
+const isSameOrigin = (left: URL, right: string): boolean => {
+  let rightUrl: URL
+  try {
+    rightUrl = new URL(right)
+  } catch {
+    return false
+  }
+  return left.protocol === rightUrl.protocol && left.host === rightUrl.host
+}
+
+const getAllowedHosts = (uiServerConfiguration: UIServerConfiguration): string[] => {
+  const allowedHosts = uiServerConfiguration.accessPolicy?.allowedHosts ?? []
+  const configuredHost = uiServerConfiguration.options?.host ?? ''
+  if (WILDCARD_HOSTS.has(configuredHost)) {
+    return allowedHosts
+  }
+  const derivedHosts = isLoopback(configuredHost)
+    ? ['localhost', '127.0.0.1', '::1']
+    : [configuredHost]
+  return [...new Set([...allowedHosts, ...derivedHosts])]
+}
+
+const isSameHost = (left: string, right: string): boolean => {
+  const leftAddress = normalizeIPAddress(left)
+  const rightAddress = normalizeIPAddress(right)
+  if (leftAddress != null || rightAddress != null) {
+    return (
+      leftAddress?.family === rightAddress?.family && leftAddress?.value === rightAddress?.value
+    )
+  }
+  const leftHost = normalizeHost(left)
+  const rightHost = normalizeHost(right)
+  return leftHost != null && rightHost != null && leftHost === rightHost
+}
+
+const isSecureForwardedProtocol = (protocol: string | undefined): boolean => {
+  return protocol != null && SECURE_FORWARDED_PROTOCOLS.has(protocol)
+}
+
+const getTrustedProxies = (
+  uiServerConfiguration: UIServerConfiguration,
+  cache: UIServerAccessCache
+): ReadonlySet<string> => {
+  const cached = cache.trustedProxies.get(uiServerConfiguration)
+  if (cached != null) {
+    return cached
+  }
+  const trustedProxies = uiServerConfiguration.accessPolicy?.trustedProxies ?? []
+  const normalized = new Set<string>()
+  for (const proxy of trustedProxies) {
+    const normalizedProxy = normalizeIPAddress(proxy)
+    if (normalizedProxy != null) {
+      normalized.add(`${normalizedProxy.family}:${normalizedProxy.value}`)
+    }
+  }
+  cache.trustedProxies.set(uiServerConfiguration, normalized)
+  return normalized
+}
+
+const isTrustedProxy = (remoteAddress: string, trustedProxies: ReadonlySet<string>): boolean => {
+  if (trustedProxies.size === 0) {
+    return false
+  }
+  const normalizedRemoteAddress = normalizeIPAddress(remoteAddress)
+  if (normalizedRemoteAddress == null) {
+    return false
+  }
+  return trustedProxies.has(`${normalizedRemoteAddress.family}:${normalizedRemoteAddress.value}`)
+}
index f07e8527922d368a2fca2afb656c4623be2fa3e2..2a9d686caa6192f36c9760d60a741d0e8d39b88a 100644 (file)
@@ -12,7 +12,7 @@ import {
 import { logger, logPrefix } from '../../utils/index.js'
 import { UIHttpServer } from './UIHttpServer.js'
 import { UIMCPServer } from './UIMCPServer.js'
-import { isLoopback } from './UIServerUtils.js'
+import { isLoopback } from './UIServerNet.js'
 import { UIWebSocketServer } from './UIWebSocketServer.js'
 
 const moduleName = 'UIServerFactory'
diff --git a/src/charging-station/ui-server/UIServerNet.ts b/src/charging-station/ui-server/UIServerNet.ts
new file mode 100644 (file)
index 0000000..7510370
--- /dev/null
@@ -0,0 +1,164 @@
+import { isIP } from 'node:net'
+
+export const LOOPBACK_HOSTNAME = 'localhost'
+
+export const isLoopback = (address: string): boolean => {
+  if (address.trim().toLowerCase() === LOOPBACK_HOSTNAME) {
+    return true
+  }
+  const normalizedAddress = normalizeIPAddress(address)
+  if (normalizedAddress == null) {
+    return false
+  }
+  if (normalizedAddress.family === 'ipv4') {
+    return normalizedAddress.value.split('.')[0] === '127'
+  }
+  const groups = normalizedAddress.value.split(':')
+  return groups.slice(0, -1).every(group => group === '0') && groups.at(-1) === '1'
+}
+
+/**
+ * Parse an IP literal (IPv4, IPv6, or IPv4-mapped IPv6) into a normalized
+ * `family:value` pair.
+ * @param address The address to normalize.
+ * @returns The normalized IP literal, or `undefined` when not a valid IP.
+ */
+export const normalizeIPAddress = (
+  address: string
+): undefined | { family: 'ipv4' | 'ipv6'; value: string } => {
+  const host = normalizeHost(address)
+  if (host == null) {
+    return undefined
+  }
+  if (host === LOOPBACK_HOSTNAME) {
+    return { family: 'ipv4', value: '127.0.0.1' }
+  }
+  const ipv4MappedAddress = parseIPv4MappedAddress(host)
+  if (ipv4MappedAddress != null) {
+    return { family: 'ipv4', value: ipv4MappedAddress }
+  }
+  if (isIP(host) === 4) {
+    return { family: 'ipv4', value: host }
+  }
+  if (isIP(host) === 6) {
+    const groups = expandIPv6(host)
+    return groups != null ? { family: 'ipv6', value: groups.join(':') } : undefined
+  }
+  return undefined
+}
+
+/**
+ * Strip a bracketed IPv6 wrapper and a trailing `:port` suffix, lowercase
+ * the result, and drop a single trailing dot.
+ * @param host The raw `Host` header or address value.
+ * @returns The bare host, or `undefined` when the input is malformed.
+ */
+export const normalizeHost = (host: string): string | undefined => {
+  const trimmedHost = host.trim().toLowerCase().replace(/\.$/, '')
+  if (trimmedHost === '') {
+    return undefined
+  }
+  const bracketMatch = /^\[([^\]]+)](?::(\d+))?$/.exec(trimmedHost)
+  if (bracketMatch != null) {
+    return isValidPort(bracketMatch[2]) ? bracketMatch[1] : undefined
+  }
+  if (isIP(trimmedHost) === 6) {
+    return trimmedHost
+  }
+  const parts = trimmedHost.split(':')
+  if (parts.length > 2 || (parts.length === 2 && !isValidPort(parts[1]))) {
+    return undefined
+  }
+  return HOSTNAME_PATTERN.test(parts[0]) ? parts[0] : undefined
+}
+
+const HOSTNAME_PATTERN = /^[a-z0-9._-]+$/
+
+const isValidPort = (port: string | undefined): boolean => {
+  if (port == null) {
+    return true
+  }
+  if (!/^\d+$/.test(port)) {
+    return false
+  }
+  const parsedPort = Number.parseInt(port, 10)
+  return parsedPort >= 1 && parsedPort <= 65535
+}
+
+/**
+ * Split a comma-separated header list while honoring RFC 7239 / RFC 7230
+ * double-quoted values. Commas inside `"…"` are preserved.
+ * @param value Raw header value.
+ * @returns Trimmed non-empty entries.
+ */
+export const splitHeaderList = (value: string): string[] => splitQuoted(value, ',')
+
+/**
+ * Split a string on `delimiter` while honoring RFC 7230 double-quoted values.
+ * Delimiters inside `"…"` are preserved.
+ * @param value Raw input.
+ * @param delimiter Single-character delimiter.
+ * @returns Trimmed non-empty entries.
+ */
+export const splitQuoted = (value: string, delimiter: string): string[] => {
+  const entries: string[] = []
+  let current = ''
+  let inQuotes = false
+  for (const char of value) {
+    if (char === '"') {
+      inQuotes = !inQuotes
+      current += char
+      continue
+    }
+    if (char === delimiter && !inQuotes) {
+      const trimmed = current.trim()
+      if (trimmed !== '') {
+        entries.push(trimmed)
+      }
+      current = ''
+      continue
+    }
+    current += char
+  }
+  const trimmed = current.trim()
+  if (trimmed !== '') {
+    entries.push(trimmed)
+  }
+  return entries
+}
+
+const parseIPv4MappedAddress = (address: string): string | undefined => {
+  const dottedMatch = /^::ffff:(.+)$/i.exec(address)
+  if (dottedMatch != null && isIP(dottedMatch[1]) === 4) {
+    return dottedMatch[1]
+  }
+  const hexMatch = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(address)
+  if (hexMatch == null) {
+    return undefined
+  }
+  const high = Number.parseInt(hexMatch[1], 16)
+  const low = Number.parseInt(hexMatch[2], 16)
+  return [high >> 8, high & 0xff, low >> 8, low & 0xff].join('.')
+}
+
+const expandIPv6 = (address: string): string[] | undefined => {
+  const sections = address.toLowerCase().split('::')
+  if (sections.length > 2) {
+    return undefined
+  }
+  const head = sections[0] === '' ? [] : sections[0].split(':')
+  const tail = sections.length === 1 || sections[1] === '' ? [] : sections[1].split(':')
+  const missingGroups = 8 - head.length - tail.length
+  if ((sections.length === 1 && missingGroups !== 0) || missingGroups < 0) {
+    return undefined
+  }
+  const groups = [...head, ...Array<string>(missingGroups).fill('0'), ...tail]
+  if (groups.length !== 8) {
+    return undefined
+  }
+  return groups.every(isIPv6Group)
+    ? groups.map(group => Number.parseInt(group, 16).toString(16))
+    : undefined
+}
+
+const isIPv6Group = (group: string): boolean => /^[0-9a-f]{1,4}$/i.test(group)
index 27c545ea4c28be4443315fba8bf4f50bb038eaaa..c99646ddd31b9bc31c90a05f3b8a0dfb63594138 100644 (file)
@@ -1,5 +1,9 @@
+import type { IncomingMessage } from 'node:http'
+
 import { timingSafeEqual } from 'node:crypto'
 
+import { BaseError } from '../../exception/index.js'
+
 interface RateLimitEntry {
   count: number
   resetTime: number
@@ -12,6 +16,12 @@ export const DEFAULT_MAX_STATIONS = 100
 export const DEFAULT_MAX_TRACKED_IPS = 10000
 export const DEFAULT_COMPRESSION_THRESHOLD_BYTES = 1024
 
+export class PayloadTooLargeError extends BaseError {
+  public constructor (maxBytes: number) {
+    super(`Request body exceeds limit of ${maxBytes.toString()} bytes`)
+  }
+}
+
 export const isValidCredential = (provided: string, expected: string): boolean => {
   try {
     const providedBuffer = Buffer.from(provided, 'utf8')
@@ -32,13 +42,18 @@ export const isValidCredential = (provided: string, expected: string): boolean =
   }
 }
 
-export const createBodySizeLimiter = (maxBytes: number): ((chunkSize: number) => boolean) => {
-  let accumulatedBytes = 0
-
-  return (chunkSize: number): boolean => {
-    accumulatedBytes += chunkSize
-    return accumulatedBytes <= maxBytes
+export const readLimitedBody = async (req: IncomingMessage, maxBytes: number): Promise<Buffer> => {
+  const chunks: Buffer[] = []
+  let received = 0
+  for await (const chunk of req) {
+    const buffer = chunk as Buffer
+    received += buffer.length
+    if (received > maxBytes) {
+      throw new PayloadTooLargeError(maxBytes)
+    }
+    chunks.push(buffer)
   }
+  return Buffer.concat(chunks)
 }
 
 export const createRateLimiter = (
index f56f6457959f70a96146c2ac456a71a36b48cbe8..a2df33d0284f950d9ff3c04cdd1ae903bb9e7ff0 100644 (file)
@@ -1,8 +1,7 @@
 import type { IncomingMessage } from 'node:http'
 
-import { BaseError } from '../../exception/index.js'
 import { Protocol, ProtocolVersion } from '../../types/index.js'
-import { getErrorMessage, isEmpty, logger, logPrefix } from '../../utils/index.js'
+import { isEmpty, logger, logPrefix } from '../../utils/index.js'
 
 export enum HttpMethod {
   DELETE = 'DELETE',
@@ -13,29 +12,21 @@ export enum HttpMethod {
 }
 
 export const getUsernameAndPasswordFromAuthorizationToken = (
-  authorizationToken: string,
-  next: (err?: Error) => void
+  authorizationToken: string
 ): [string, string] | undefined => {
   try {
     const authentication = Buffer.from(authorizationToken, 'base64').toString('utf8')
     const separatorIndex = authentication.indexOf(':')
     if (separatorIndex === -1) {
-      next(new BaseError('Invalid basic authentication token format: missing ":" separator'))
       return undefined
     }
     const username = authentication.slice(0, separatorIndex)
     const password = authentication.slice(separatorIndex + 1)
-    if (isEmpty(username)) {
-      next(new BaseError('Invalid basic authentication token format: empty username'))
-      return undefined
-    }
-    if (isEmpty(password)) {
-      next(new BaseError('Invalid basic authentication token format: empty password'))
+    if (isEmpty(username) || isEmpty(password)) {
       return undefined
     }
     return [username, password]
-  } catch (error) {
-    next(new BaseError(`Invalid basic authentication token format: ${getErrorMessage(error)}`))
+  } catch {
     return undefined
   }
 }
@@ -88,7 +79,3 @@ export const getProtocolAndVersion = (
   }
   return [protocol, version] as [Protocol, ProtocolVersion]
 }
-
-export const isLoopback = (address: string): boolean => {
-  return /^localhost$|^127(?:\.\d+){0,2}\.\d+$|^(?:0*:)*?:?0*1$/i.test(address)
-}
index 4684560aeb5682ee4f1bae4400eff22aaa20fb4c..a866d6836e404057b0d4b49f7b35fc60fdbc909a 100644 (file)
@@ -1,7 +1,7 @@
 import type { IncomingMessage } from 'node:http'
 import type { Duplex } from 'node:stream'
 
-import { StatusCodes } from 'http-status-codes'
+import { getReasonPhrase, StatusCodes } from 'http-status-codes'
 import { type RawData, WebSocket, WebSocketServer } from 'ws'
 
 import type { IBootstrap } from '../IBootstrap.js'
@@ -34,6 +34,24 @@ import {
 
 const moduleName = 'UIWebSocketServer'
 
+// Pre-handshake WS rejections write raw HTTP/1.1 to the Duplex socket;
+// AbstractUIServer.renderDenial targets ServerResponse and is not applicable.
+const buildUpgradeRejectionResponse = (
+  status: StatusCodes,
+  reasonPhrase: string,
+  extraHeaders: Readonly<Record<string, string>> = {}
+): string => {
+  const headers: Readonly<Record<string, string>> = {
+    'Content-Length': '0',
+    ...extraHeaders,
+    Connection: 'close',
+  }
+  const headerLines = Object.entries(headers)
+    .map(([name, value]) => `${name}: ${value}`)
+    .join('\r\n')
+  return `HTTP/1.1 ${status.toString()} ${reasonPhrase}\r\n${headerLines}\r\n\r\n`
+}
+
 export class UIWebSocketServer extends AbstractUIServer {
   protected override readonly uiServerType = 'UI WebSocket Server'
 
@@ -164,15 +182,33 @@ export class UIWebSocketServer extends AbstractUIServer {
         }
       })
     })
-    this.httpServer.on('connect', (req: IncomingMessage, socket: Duplex, _head: Buffer) => {
+    this.httpServer.on('upgrade', (req: IncomingMessage, socket: Duplex, head: Buffer): void => {
       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()
+        socket.write(
+          buildUpgradeRejectionResponse(
+            StatusCodes.BAD_REQUEST,
+            getReasonPhrase(StatusCodes.BAD_REQUEST)
+          ),
+          () => {
+            socket.destroy()
+          }
+        )
+        return
       }
-    })
-    this.httpServer.on('upgrade', (req: IncomingMessage, socket: Duplex, head: Buffer): void => {
+
+      const prologue = this.runRequestPrologue(req)
+      if (!prologue.ok) {
+        socket.write(
+          buildUpgradeRejectionResponse(prologue.status, prologue.reasonPhrase, prologue.headers),
+          () => {
+            socket.destroy()
+          }
+        )
+        return
+      }
+
       const onSocketError = (error: Error): void => {
         logger.error(
           `${this.logPrefix(
@@ -183,27 +219,35 @@ export class UIWebSocketServer extends AbstractUIServer {
         )
       }
       socket.on('error', onSocketError)
-      this.authenticate(req, err => {
+      if (!this.authenticate(req)) {
         socket.removeListener('error', onSocketError)
-        if (err != null) {
-          socket.write(`HTTP/1.1 ${StatusCodes.UNAUTHORIZED.toString()} Unauthorized\r\n\r\n`)
-          socket.destroy()
-          return
-        }
-        try {
-          this.webSocketServer.handleUpgrade(req, socket, head, (ws: WebSocket) => {
-            this.webSocketServer.emit('connection', ws, req)
-          })
-        } catch (error) {
-          logger.error(
-            `${this.logPrefix(
-              moduleName,
-              'start.httpServer.on.upgrade'
-            )} Error at connection upgrade event handling:`,
-            error
-          )
-        }
-      })
+        const unauthorized = this.getUnauthorizedDenial()
+        socket.write(
+          buildUpgradeRejectionResponse(
+            unauthorized.status,
+            unauthorized.reasonPhrase,
+            unauthorized.headers
+          ),
+          () => {
+            socket.destroy()
+          }
+        )
+        return
+      }
+      socket.removeListener('error', onSocketError)
+      try {
+        this.webSocketServer.handleUpgrade(req, socket, head, (ws: WebSocket) => {
+          this.webSocketServer.emit('connection', ws, req)
+        })
+      } catch (error) {
+        logger.error(
+          `${this.logPrefix(
+            moduleName,
+            'start.httpServer.on.upgrade'
+          )} Error at connection upgrade event handling:`,
+          error
+        )
+      }
     })
     this.startHttpServer()
   }
index 1774b604607a2909ca9c9bed7bc9bb073ce7e2d3..fb2f65af2443a6278a693856f0aad1ae926f7971 100644 (file)
@@ -37,6 +37,7 @@ import { Constants } from './Constants.js'
 import { ensureError, handleFileException } from './ErrorUtils.js'
 import { logger } from './Logger.js'
 import {
+  clone,
   convertToInt,
   has,
   isCFEnvironment,
@@ -52,6 +53,13 @@ type ConfigurationSectionType =
   | WorkerConfiguration
 
 const defaultUIServerConfiguration: UIServerConfiguration = {
+  accessPolicy: {
+    allowedHosts: [],
+    allowedOrigins: [],
+    allowLoopbackProxy: false,
+    requireTlsForNonLoopback: true,
+    trustedProxies: [],
+  },
   enabled: false,
   options: {
     host: Constants.DEFAULT_UI_SERVER_HOST,
@@ -272,7 +280,7 @@ export class Configuration {
   }
 
   private static buildUIServerSection (): UIServerConfiguration {
-    let uiServerConfiguration: UIServerConfiguration = defaultUIServerConfiguration
+    let uiServerConfiguration: UIServerConfiguration = clone(defaultUIServerConfiguration)
     if (has(ConfigurationSection.uiServer, Configuration.getConfigurationData())) {
       uiServerConfiguration = mergeDeepRight(
         uiServerConfiguration,
index bade24fec440aec5b57e60c98ce6837308b29a1a..0e181b52f847fb34c12a631b1e83527ee1bc6eba 100644 (file)
@@ -1,8 +1,10 @@
 import type { ListenOptions } from 'node:net'
 import type { ResourceLimits } from 'node:worker_threads'
 
+import { isIP } from 'node:net'
 import { z } from 'zod'
 
+import { normalizeHost } from '../charging-station/ui-server/UIServerNet.js'
 import {
   ApplicationProtocol,
   ApplicationProtocolVersion,
@@ -91,6 +93,61 @@ export const UIServerAuthenticationSchema = z
   })
   .strict()
 
+export const UI_SERVER_ACCESS_POLICY_DEFAULTS = {
+  allowedHosts: [],
+  allowedOrigins: [],
+  allowLoopbackProxy: false,
+  requireTlsForNonLoopback: true,
+  trustedProxies: [],
+} as const
+
+export const UIServerAccessPolicySchema = z
+  .object({
+    allowedHosts: z
+      .array(
+        z.string().refine(value => normalizeHost(value) != null, {
+          message: 'must be a valid host (no path, query, or fragment)',
+        })
+      )
+      .optional(),
+    allowedOrigins: z
+      .array(
+        z.url().refine(
+          value => {
+            const url = new URL(value)
+            return (
+              (url.pathname === '/' || url.pathname === '') && url.search === '' && url.hash === ''
+            )
+          },
+          {
+            message:
+              'must be an origin URL without path, query, or fragment (e.g. https://example.com)',
+          }
+        )
+      )
+      .optional(),
+    allowLoopbackProxy: z.boolean().optional(),
+    requireTlsForNonLoopback: z.boolean().optional(),
+    trustedProxies: z
+      .array(
+        z.string().refine(value => isIP(value) !== 0, {
+          message:
+            'must be an IPv4 or IPv6 literal (hostnames, brackets, and CIDR ranges are not supported)',
+        })
+      )
+      .optional(),
+  })
+  .strict()
+
+const UIServerListenOptionsSchema = z.custom<ListenOptions>(value => {
+  return (
+    value != null &&
+    typeof value === 'object' &&
+    !Array.isArray(value) &&
+    !Object.hasOwn(value, 'accessPolicy')
+  )
+}, "'accessPolicy' must be configured under 'uiServer', not 'uiServer.options'")
+
 /**
  * UIServerConfiguration — UI server configuration section.
  * `options` is structurally typed as `ListenOptions` from node:net; the schema
@@ -98,9 +155,10 @@ export const UIServerAuthenticationSchema = z
  */
 export const UIServerConfigurationSchema = z
   .object({
+    accessPolicy: UIServerAccessPolicySchema.optional(),
     authentication: UIServerAuthenticationSchema.optional(),
     enabled: z.boolean().optional(),
-    options: z.custom<ListenOptions>().optional(),
+    options: UIServerListenOptionsSchema.optional(),
     type: z.enum(ApplicationProtocol).optional(),
     version: z.enum(ApplicationProtocolVersion).optional(),
   })
index 104338c9eec24fda5dfdf5daeaf545a4851d9ee5..c1642c6fcf6444a4670430b11913d83981ec58ad 100644 (file)
@@ -21,6 +21,8 @@ export {
   LogConfigurationSchema,
   StationTemplateUrlSchema,
   StorageConfigurationSchema,
+  UI_SERVER_ACCESS_POLICY_DEFAULTS,
+  UIServerAccessPolicySchema,
   UIServerAuthenticationSchema,
   UIServerConfigurationSchema,
   WorkerConfigurationSchema,
index 075a42b15c2153fac7f0f8c19197de5d350ccf26..22dca1059df441a363c31d6957afcdefcd6d6e1b 100644 (file)
@@ -3,7 +3,10 @@
  * @description Unit tests for HTTP-based UI server and response handling
  */
 
+import type { IncomingMessage } from 'node:http'
+
 import assert from 'node:assert/strict'
+import { once } from 'node:events'
 import { afterEach, beforeEach, describe, it } from 'node:test'
 import { gunzipSync } from 'node:zlib'
 
@@ -13,12 +16,12 @@ import { UIHttpServer } from '../../../src/charging-station/ui-server/UIHttpServ
 import { DEFAULT_COMPRESSION_THRESHOLD_BYTES } from '../../../src/charging-station/ui-server/UIServerSecurity.js'
 import { ApplicationProtocol, ResponseStatus } from '../../../src/types/index.js'
 import { standardCleanup } from '../../helpers/TestLifecycleHelpers.js'
-import { GZIP_STREAM_FLUSH_DELAY_MS, TEST_UUID } from './UIServerTestConstants.js'
+import { TEST_UUID } from './UIServerTestConstants.js'
 import {
   createMockBootstrap,
+  createMockIncomingMessage,
   createMockUIServerConfiguration,
   MockServerResponse,
-  waitForStreamFlush,
 } from './UIServerTestUtils.js'
 
 // eslint-disable-next-line @typescript-eslint/no-deprecated
@@ -195,6 +198,90 @@ await describe('UIHttpServer', async () => {
     assert.notStrictEqual(serverCustom, undefined)
   })
 
+  await it('should reject non-loopback plaintext requests before routing', t => {
+    const gatedServer = new TestableUIHttpServer(
+      createMockUIServerConfiguration({
+        accessPolicy: {
+          allowedHosts: ['gateway.example.com'],
+          requireTlsForNonLoopback: true,
+        },
+        options: { host: 'localhost', port: 0 },
+        type: ApplicationProtocol.HTTP,
+      })
+    )
+    const httpServer = Reflect.get(gatedServer, 'httpServer') as {
+      emit: (eventName: string, req: IncomingMessage, res: MockServerResponse) => boolean
+      listen: (...args: unknown[]) => unknown
+      removeAllListeners: () => void
+    }
+    t.mock.method(httpServer, 'listen', () => httpServer)
+    const req = createMockIncomingMessage({
+      complete: true,
+      headers: { host: 'gateway.example.com' },
+      socket: { encrypted: false, remoteAddress: '203.0.113.10' } as never,
+      url: '/ui/ui0.0.1/listChargingStations',
+    })
+    const res = new MockServerResponse()
+
+    try {
+      // eslint-disable-next-line @typescript-eslint/no-deprecated
+      gatedServer.start()
+      httpServer.emit('request', req, res)
+    } finally {
+      httpServer.removeAllListeners()
+      gatedServer.stop()
+    }
+
+    assert.strictEqual(res.statusCode, 403)
+    assert.strictEqual(res.body, '403 Forbidden')
+    assert.strictEqual(res.headers.Connection, 'close')
+  })
+
+  await it('should account denied requests against the rate limiter', t => {
+    const gatedServer = new TestableUIHttpServer(
+      createMockUIServerConfiguration({
+        accessPolicy: {
+          allowedHosts: ['gateway.example.com'],
+          requireTlsForNonLoopback: true,
+        },
+        options: { host: 'localhost', port: 0 },
+        type: ApplicationProtocol.HTTP,
+      })
+    )
+    const httpServer = Reflect.get(gatedServer, 'httpServer') as {
+      emit: (eventName: string, req: IncomingMessage, res: MockServerResponse) => boolean
+      listen: (...args: unknown[]) => unknown
+      removeAllListeners: () => void
+    }
+    t.mock.method(httpServer, 'listen', () => httpServer)
+    const rateLimiterCalls: string[] = []
+    const originalLimiter = Reflect.get(gatedServer, 'rateLimiter') as (ip: string) => boolean
+    Reflect.set(gatedServer, 'rateLimiter', (ip: string) => {
+      rateLimiterCalls.push(ip)
+      return originalLimiter(ip)
+    })
+    const denyingReq = createMockIncomingMessage({
+      complete: true,
+      headers: { host: 'gateway.example.com' },
+      socket: { encrypted: false, remoteAddress: '203.0.113.10' } as never,
+      url: '/ui/ui0.0.1/listChargingStations',
+    })
+    const res = new MockServerResponse()
+
+    try {
+      // eslint-disable-next-line @typescript-eslint/no-deprecated
+      gatedServer.start()
+      httpServer.emit('request', denyingReq, res)
+    } finally {
+      httpServer.removeAllListeners()
+      gatedServer.stop()
+    }
+
+    assert.strictEqual(res.statusCode, 403)
+    assert.strictEqual(rateLimiterCalls.length, 1)
+    assert.strictEqual(rateLimiterCalls[0], '203.0.113.10')
+  })
+
   await describe('Gzip compression', async () => {
     let gzipServer: TestableUIHttpServer
 
@@ -245,7 +332,7 @@ await describe('UIHttpServer', async () => {
       gzipServer.setAcceptsGzip(TEST_UUID, true)
       gzipServer.sendResponse([TEST_UUID, createLargePayload()])
 
-      await waitForStreamFlush(GZIP_STREAM_FLUSH_DELAY_MS)
+      await once(res, 'finish')
 
       assert.strictEqual(res.headers['Content-Encoding'], 'gzip')
       assert.strictEqual(res.headers['Content-Type'], 'application/json')
@@ -260,7 +347,7 @@ await describe('UIHttpServer', async () => {
       gzipServer.setAcceptsGzip(TEST_UUID, true)
       gzipServer.sendResponse([TEST_UUID, payload])
 
-      await waitForStreamFlush(GZIP_STREAM_FLUSH_DELAY_MS)
+      await once(res, 'finish')
 
       assert.notStrictEqual(res.bodyBuffer, undefined)
       if (res.bodyBuffer == null) {
@@ -291,7 +378,7 @@ await describe('UIHttpServer', async () => {
 
       gzipServer.sendResponse([TEST_UUID, createLargePayload()])
 
-      await waitForStreamFlush(GZIP_STREAM_FLUSH_DELAY_MS)
+      await once(res, 'finish')
 
       assert.strictEqual(gzipServer.getAcceptsGzip().has(TEST_UUID), false)
     })
index c7a3aedd024c450cff7eaf9e0fb70a9437a61394..df26b7a42d622e0a7e01950ca73417272cf248c7 100644 (file)
@@ -4,8 +4,9 @@
  */
 
 import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'
-import type { IncomingMessage } from 'node:http'
+import type { IncomingMessage, ServerResponse } from 'node:http'
 
+import { StatusCodes } from 'http-status-codes'
 import assert from 'node:assert/strict'
 import { dirname, join } from 'node:path'
 import { Readable } from 'node:stream'
@@ -25,8 +26,10 @@ import {
 } from '../../../src/charging-station/ui-server/mcp/index.js'
 import { AbstractUIService } from '../../../src/charging-station/ui-server/ui-services/AbstractUIService.js'
 import { UIMCPServer } from '../../../src/charging-station/ui-server/UIMCPServer.js'
-import { DEFAULT_MAX_PAYLOAD_SIZE_BYTES } from '../../../src/charging-station/ui-server/UIServerSecurity.js'
-import { BaseError } from '../../../src/exception/index.js'
+import {
+  DEFAULT_MAX_PAYLOAD_SIZE_BYTES,
+  PayloadTooLargeError,
+} from '../../../src/charging-station/ui-server/UIServerSecurity.js'
 import {
   ApplicationProtocol,
   OCPPVersion,
@@ -43,7 +46,10 @@ import { TEST_HASH_ID, TEST_HASH_ID_2, TEST_UUID, TEST_UUID_2 } from './UIServer
 import {
   createMockBootstrap,
   createMockChargingStationDataWithVersion,
+  createMockIncomingMessage,
   createMockUIServerConfiguration,
+  createMockUIServerConfigurationWithAuth,
+  MockServerResponse,
 } from './UIServerTestUtils.js'
 
 const TEST_TIMEOUT_MS = 30_000
@@ -100,6 +106,20 @@ class TestableUIMCPServer extends UIMCPServer {
     ).call(this, req)
   }
 
+  public callSendErrorResponse (
+    res: ServerResponse,
+    statusCode: StatusCodes,
+    headers?: Readonly<Record<string, string>>
+  ): void {
+    ;(
+      Reflect.get(this, 'sendErrorResponse') as (
+        res: ServerResponse,
+        statusCode: StatusCodes,
+        headers?: Readonly<Record<string, string>>
+      ) => void
+    ).call(this, res, statusCode, headers)
+  }
+
   public getPendingMcpRequest (uuid: string):
     | undefined
     | {
@@ -184,6 +204,200 @@ await describe('UIMCPServer', async () => {
     })
   })
 
+  await describe('request access gate', async () => {
+    await it('should signal Connection: close after denial', t => {
+      const gatedServer = new TestableUIMCPServer(
+        createMockUIServerConfiguration({
+          options: { host: 'localhost', port: 0 },
+          type: ApplicationProtocol.MCP,
+        })
+      )
+      const httpServer = Reflect.get(gatedServer, 'httpServer') as {
+        emit: (eventName: string, req: IncomingMessage, res: MockServerResponse) => boolean
+        listen: (...args: unknown[]) => unknown
+        removeAllListeners: () => void
+      }
+      t.mock.method(httpServer, 'listen', () => httpServer)
+      const req = createMockIncomingMessage({
+        complete: true,
+        headers: { host: 'attacker.test' },
+        socket: { encrypted: false, remoteAddress: '127.0.0.1' } as never,
+        url: '/mcp',
+      })
+      const res = new MockServerResponse()
+
+      try {
+        gatedServer.start()
+        httpServer.emit('request', req, res)
+      } finally {
+        httpServer.removeAllListeners()
+        gatedServer.stop()
+      }
+
+      assert.strictEqual(res.statusCode, 403)
+      assert.strictEqual(res.ended, true)
+      assert.strictEqual(res.headers.Connection, 'close')
+    })
+
+    await it('should signal Connection: close after auth denial', t => {
+      const gatedServer = new TestableUIMCPServer(
+        createMockUIServerConfigurationWithAuth({
+          options: { host: 'localhost', port: 0 },
+          type: ApplicationProtocol.MCP,
+        })
+      )
+      const httpServer = Reflect.get(gatedServer, 'httpServer') as {
+        emit: (eventName: string, req: IncomingMessage, res: MockServerResponse) => boolean
+        listen: (...args: unknown[]) => unknown
+        removeAllListeners: () => void
+      }
+      t.mock.method(httpServer, 'listen', () => httpServer)
+      const req = createMockIncomingMessage({
+        complete: true,
+        headers: { host: 'localhost' },
+        socket: { encrypted: false, remoteAddress: '127.0.0.1' } as never,
+        url: '/mcp',
+      })
+      const res = new MockServerResponse()
+
+      try {
+        gatedServer.start()
+        httpServer.emit('request', req, res)
+      } finally {
+        httpServer.removeAllListeners()
+        gatedServer.stop()
+      }
+
+      assert.strictEqual(res.statusCode, 401)
+      assert.strictEqual(res.headers['WWW-Authenticate'], 'Basic realm=users')
+      assert.strictEqual(res.headers.Connection, 'close')
+      assert.strictEqual(res.ended, true)
+    })
+
+    await it('should advertise allowed methods on 405 Method Not Allowed responses', () => {
+      const gatedServer = new TestableUIMCPServer(
+        createMockUIServerConfiguration({
+          options: { host: 'localhost', port: 0 },
+          type: ApplicationProtocol.MCP,
+        })
+      )
+      const res = new MockServerResponse()
+
+      gatedServer.callSendErrorResponse(
+        res as unknown as ServerResponse,
+        StatusCodes.METHOD_NOT_ALLOWED,
+        { Allow: 'GET, POST, DELETE' }
+      )
+
+      assert.strictEqual(res.statusCode, 405)
+      assert.strictEqual(res.headers.Allow, 'GET, POST, DELETE')
+      assert.strictEqual(res.ended, true)
+    })
+
+    await it('should warn at startup when bound to wildcard host with empty allowedHosts', t => {
+      const { warnMock } = createLoggerMocks(t, logger)
+
+      const wildcardServer = new TestableUIMCPServer(
+        createMockUIServerConfiguration({
+          accessPolicy: {
+            allowedHosts: [],
+            allowedOrigins: [],
+            allowLoopbackProxy: false,
+            requireTlsForNonLoopback: true,
+            trustedProxies: [],
+          },
+          options: { host: '0.0.0.0', port: 0 },
+          type: ApplicationProtocol.MCP,
+        })
+      )
+
+      assert.strictEqual(warnMock.mock.calls.length, 1)
+      assert.match(
+        warnMock.mock.calls[0].arguments[0] as string,
+        /wildcard host '0\.0\.0\.0' with no accessPolicy\.allowedHosts/
+      )
+      wildcardServer.stop()
+    })
+
+    await it('should warn at startup when bound to non-loopback host with no trusted proxies', t => {
+      const { warnMock } = createLoggerMocks(t, logger)
+
+      const exposedServer = new TestableUIMCPServer(
+        createMockUIServerConfiguration({
+          accessPolicy: {
+            allowedHosts: ['gateway.example.com'],
+            allowedOrigins: [],
+            allowLoopbackProxy: false,
+            requireTlsForNonLoopback: true,
+            trustedProxies: [],
+          },
+          options: { host: '203.0.113.10', port: 0 },
+          type: ApplicationProtocol.MCP,
+        })
+      )
+
+      assert.strictEqual(warnMock.mock.calls.length, 1)
+      assert.match(
+        warnMock.mock.calls[0].arguments[0] as string,
+        /non-loopback host '203\.0\.113\.10' with requireTlsForNonLoopback=true and no accessPolicy\.trustedProxies/
+      )
+      exposedServer.stop()
+    })
+
+    await it('should not warn at startup when bound to a loopback host', t => {
+      const { warnMock } = createLoggerMocks(t, logger)
+
+      const loopbackServer = new TestableUIMCPServer(
+        createMockUIServerConfiguration({
+          options: { host: 'localhost', port: 0 },
+          type: ApplicationProtocol.MCP,
+        })
+      )
+
+      assert.strictEqual(warnMock.mock.calls.length, 0)
+      loopbackServer.stop()
+    })
+
+    await it('should log rate-limit denials at warn level', t => {
+      const { warnMock } = createLoggerMocks(t, logger)
+      const gatedServer = new TestableUIMCPServer(
+        createMockUIServerConfiguration({
+          options: { host: 'localhost', port: 0 },
+          type: ApplicationProtocol.MCP,
+        })
+      )
+      ;(gatedServer as unknown as { rateLimiter: () => boolean }).rateLimiter = () => false
+      const httpServer = Reflect.get(gatedServer, 'httpServer') as {
+        emit: (eventName: string, req: IncomingMessage, res: MockServerResponse) => boolean
+        listen: (...args: unknown[]) => unknown
+        removeAllListeners: () => void
+      }
+      t.mock.method(httpServer, 'listen', () => httpServer)
+      const req = createMockIncomingMessage({
+        complete: true,
+        headers: { host: 'localhost' },
+        socket: { encrypted: false, remoteAddress: '127.0.0.1' } as never,
+        url: '/mcp',
+      })
+      const res = new MockServerResponse()
+
+      try {
+        gatedServer.start()
+        httpServer.emit('request', req, res)
+      } finally {
+        httpServer.removeAllListeners()
+        gatedServer.stop()
+      }
+
+      assert.strictEqual(res.statusCode, 429)
+      assert.strictEqual(warnMock.mock.calls.length, 1)
+      assert.match(
+        warnMock.mock.calls[0].arguments[0] as string,
+        /UI rate limit exceeded for client '127\.0\.0\.1'/
+      )
+    })
+  })
+
   await describe('Tool schema registration', async () => {
     await it('should have a tool schema for every ProcedureName', () => {
       assert.strictEqual(mcpToolSchemas.size, Object.keys(ProcedureName).length)
@@ -663,17 +877,13 @@ await describe('UIMCPServer', async () => {
       assert.deepStrictEqual(result, expected)
     })
 
-    await it('should reject with BaseError when payload too large', async () => {
+    await it('should reject with PayloadTooLargeError when payload too large', async () => {
       const oversizedChunk = Buffer.alloc(DEFAULT_MAX_PAYLOAD_SIZE_BYTES + 1)
       const mockReq = Readable.from([oversizedChunk])
 
       await assert.rejects(
         server.callReadRequestBody(mockReq as unknown as IncomingMessage),
-        (error: Error) => {
-          assert.ok(error instanceof BaseError)
-          assert.ok(error.message.includes('Payload too large'))
-          return true
-        }
+        PayloadTooLargeError
       )
     })
 
diff --git a/tests/charging-station/ui-server/UIServerAccessPolicy.test.ts b/tests/charging-station/ui-server/UIServerAccessPolicy.test.ts
new file mode 100644 (file)
index 0000000..98c0457
--- /dev/null
@@ -0,0 +1,1271 @@
+/**
+ * @file Tests for UIServerAccessPolicy
+ * @description Unit tests for the UI server gateway access policy:
+ *   per-request decision evaluation, forwarded-header parsing, host/origin
+ *   allowlists, trusted-proxy classification, and the per-request memo cache.
+ */
+
+import type { IncomingHttpHeaders, IncomingMessage } from 'node:http'
+
+import assert from 'node:assert/strict'
+import { afterEach, describe, it } from 'node:test'
+
+import {
+  createUIServerAccessCache,
+  resolveUIServerAccess,
+  type UIServerAccessDecision,
+  UIServerAccessDenialReason,
+} from '../../../src/charging-station/ui-server/UIServerAccessPolicy.js'
+import { type UIServerConfiguration } from '../../../src/types/index.js'
+import { standardCleanup } from '../../helpers/TestLifecycleHelpers.js'
+import {
+  createGatewayConfigWithoutTrustedProxies,
+  createGatewayConfigWithTrustedProxy,
+  createMockUIServerConfiguration,
+} from './UIServerTestUtils.js'
+
+await describe('UIServerAccessPolicy', async () => {
+  const createAccessPolicyConfiguration = (
+    overrides?: Partial<UIServerConfiguration>
+  ): UIServerConfiguration => createMockUIServerConfiguration(overrides)
+
+  const createAccessPolicyRequest = ({
+    encrypted = false,
+    headers = {},
+    headersDistinct = {},
+    rawHeaders = [],
+    remoteAddress = '127.0.0.1',
+  }: {
+    encrypted?: boolean
+    headers?: IncomingHttpHeaders
+    headersDistinct?: NodeJS.Dict<string[]>
+    rawHeaders?: string[]
+    remoteAddress?: string
+  }): IncomingMessage => {
+    return {
+      headers,
+      headersDistinct,
+      rawHeaders,
+      socket: { encrypted, remoteAddress } as never,
+    } as unknown as IncomingMessage
+  }
+
+  // Narrows the discriminated union and asserts the enum reason; tests assert
+  // on the machine-readable identity rather than the rendered message.
+  const expectDenied = (
+    decision: UIServerAccessDecision,
+    expectedReason: UIServerAccessDenialReason
+  ): void => {
+    assert.strictEqual(decision.allowed, false)
+    assert.strictEqual(decision.reason, expectedReason)
+    assert.strictEqual(decision.message.length > 0, true)
+  }
+
+  const evaluate = (req: IncomingMessage, config: UIServerConfiguration): UIServerAccessDecision =>
+    resolveUIServerAccess(req, config, createUIServerAccessCache())
+
+  afterEach(() => {
+    standardCleanup()
+  })
+
+  await describe('resolveUIServerAccess', async () => {
+    await it('should allow direct loopback plaintext requests', () => {
+      const decision = evaluate(
+        createAccessPolicyRequest({ headers: { host: 'localhost:8080' } }),
+        createAccessPolicyConfiguration()
+      )
+
+      assert.strictEqual(decision.allowed, true)
+    })
+
+    await it('should allow direct IPv6 loopback plaintext requests', () => {
+      const decision = evaluate(
+        createAccessPolicyRequest({
+          headers: { host: '[::1]:8080' },
+          remoteAddress: '::1',
+        }),
+        createAccessPolicyConfiguration()
+      )
+
+      assert.strictEqual(decision.allowed, true)
+    })
+
+    await it('should reject direct non-loopback plaintext requests', () => {
+      const decision = evaluate(
+        createAccessPolicyRequest({
+          headers: { host: 'gateway.example.com' },
+          remoteAddress: '203.0.113.10',
+        }),
+        createAccessPolicyConfiguration({
+          accessPolicy: {
+            allowedHosts: ['gateway.example.com'],
+            allowedOrigins: [],
+            allowLoopbackProxy: false,
+            requireTlsForNonLoopback: true,
+            trustedProxies: [],
+          },
+        })
+      )
+
+      expectDenied(decision, UIServerAccessDenialReason.TlsRequired)
+    })
+
+    await it('should reject spoofed forwarded proto from direct non-loopback clients', () => {
+      const decision = evaluate(
+        createAccessPolicyRequest({
+          headers: { host: 'gateway.example.com', 'x-forwarded-proto': 'https' },
+          remoteAddress: '203.0.113.10',
+        }),
+        createAccessPolicyConfiguration({
+          accessPolicy: {
+            allowedHosts: ['gateway.example.com'],
+            allowedOrigins: [],
+            allowLoopbackProxy: false,
+            requireTlsForNonLoopback: true,
+            trustedProxies: [],
+          },
+        })
+      )
+
+      expectDenied(decision, UIServerAccessDenialReason.ForwardedFromUntrustedPeer)
+    })
+
+    await it('should allow secure traffic from a trusted proxy', () => {
+      const decision = evaluate(
+        createAccessPolicyRequest({
+          headers: {
+            host: 'gateway.example.com',
+            'x-forwarded-for': '203.0.113.10',
+            'x-forwarded-proto': 'https',
+          },
+          remoteAddress: '192.0.2.10',
+        }),
+        createAccessPolicyConfiguration({
+          accessPolicy: {
+            allowedHosts: ['gateway.example.com'],
+            allowedOrigins: [],
+            allowLoopbackProxy: false,
+            requireTlsForNonLoopback: true,
+            trustedProxies: ['192.0.2.10'],
+          },
+        })
+      )
+
+      assert.strictEqual(decision.allowed, true)
+      assert.strictEqual(decision.clientAddress, '203.0.113.10')
+    })
+
+    await it('should match an IPv4-mapped IPv6 remote against an IPv4 trusted proxy', () => {
+      const decision = evaluate(
+        createAccessPolicyRequest({
+          headers: {
+            host: 'gateway.example.com',
+            'x-forwarded-for': '203.0.113.7',
+            'x-forwarded-proto': 'https',
+          },
+          remoteAddress: '::ffff:1.2.3.4',
+        }),
+        createAccessPolicyConfiguration({
+          accessPolicy: {
+            allowedHosts: ['gateway.example.com'],
+            allowedOrigins: [],
+            allowLoopbackProxy: false,
+            requireTlsForNonLoopback: true,
+            trustedProxies: ['1.2.3.4'],
+          },
+        })
+      )
+
+      assert.strictEqual(decision.allowed, true)
+      assert.strictEqual(decision.clientAddress, '203.0.113.7')
+    })
+
+    await it('should match the hexadecimal IPv4-mapped IPv6 form against an IPv4 trusted proxy', () => {
+      const decision = evaluate(
+        createAccessPolicyRequest({
+          headers: {
+            host: 'gateway.example.com',
+            'x-forwarded-for': '203.0.113.7',
+            'x-forwarded-proto': 'https',
+          },
+          remoteAddress: '::ffff:0102:0304',
+        }),
+        createAccessPolicyConfiguration({
+          accessPolicy: {
+            allowedHosts: ['gateway.example.com'],
+            allowedOrigins: [],
+            allowLoopbackProxy: false,
+            requireTlsForNonLoopback: true,
+            trustedProxies: ['1.2.3.4'],
+          },
+        })
+      )
+
+      assert.strictEqual(decision.allowed, true)
+      assert.strictEqual(decision.clientAddress, '203.0.113.7')
+    })
+
+    await it('should allow standard Forwarded proto-only traffic from a trusted proxy', () => {
+      const decision = evaluate(
+        createAccessPolicyRequest({
+          headers: {
+            forwarded: 'proto=https',
+            host: 'gateway.example.com',
+          },
+          remoteAddress: '192.0.2.10',
+        }),
+        createAccessPolicyConfiguration({
+          accessPolicy: {
+            allowedHosts: ['gateway.example.com'],
+            allowedOrigins: [],
+            allowLoopbackProxy: false,
+            requireTlsForNonLoopback: true,
+            trustedProxies: ['192.0.2.10'],
+          },
+        })
+      )
+
+      assert.strictEqual(decision.allowed, true)
+      assert.strictEqual(decision.clientAddress, '192.0.2.10')
+    })
+
+    await it('should allow standard Forwarded for and proto traffic from a trusted proxy', () => {
+      const decision = evaluate(
+        createAccessPolicyRequest({
+          headers: {
+            forwarded: 'for=203.0.113.10;proto=https',
+            host: 'gateway.example.com',
+          },
+          remoteAddress: '192.0.2.10',
+        }),
+        createAccessPolicyConfiguration({
+          accessPolicy: {
+            allowedHosts: ['gateway.example.com'],
+            allowedOrigins: [],
+            allowLoopbackProxy: false,
+            requireTlsForNonLoopback: true,
+            trustedProxies: ['192.0.2.10'],
+          },
+        })
+      )
+
+      assert.strictEqual(decision.allowed, true)
+      assert.strictEqual(decision.clientAddress, '203.0.113.10')
+    })
+
+    await it('should reject plaintext traffic from a trusted proxy', () => {
+      const decision = evaluate(
+        createAccessPolicyRequest({
+          headers: {
+            host: 'gateway.example.com',
+            'x-forwarded-for': '203.0.113.10',
+            'x-forwarded-proto': 'http',
+          },
+          remoteAddress: '192.0.2.10',
+        }),
+        createAccessPolicyConfiguration({
+          accessPolicy: {
+            allowedHosts: ['gateway.example.com'],
+            allowedOrigins: [],
+            allowLoopbackProxy: false,
+            requireTlsForNonLoopback: true,
+            trustedProxies: ['192.0.2.10'],
+          },
+        })
+      )
+
+      expectDenied(decision, UIServerAccessDenialReason.ProxyTlsRequired)
+    })
+
+    await it('should reject ambiguous forwarded client address lists', () => {
+      const decision = evaluate(
+        createAccessPolicyRequest({
+          headers: {
+            host: 'gateway.example.com',
+            'x-forwarded-for': '198.51.100.77, 203.0.113.10',
+            'x-forwarded-proto': 'https',
+          },
+          remoteAddress: '192.0.2.10',
+        }),
+        createAccessPolicyConfiguration({
+          accessPolicy: {
+            allowedHosts: ['gateway.example.com'],
+            allowedOrigins: [],
+            allowLoopbackProxy: false,
+            requireTlsForNonLoopback: true,
+            trustedProxies: ['192.0.2.10'],
+          },
+        })
+      )
+
+      expectDenied(decision, UIServerAccessDenialReason.AmbiguousForwardedClient)
+    })
+
+    await it('should reject ambiguous X-Forwarded-Proto multi-value lists', () => {
+      const decision = evaluate(
+        createAccessPolicyRequest({
+          headers: {
+            host: 'gateway.example.com',
+            'x-forwarded-for': '203.0.113.10',
+            'x-forwarded-proto': 'https, http',
+          },
+          remoteAddress: '192.0.2.10',
+        }),
+        createGatewayConfigWithTrustedProxy()
+      )
+
+      expectDenied(decision, UIServerAccessDenialReason.AmbiguousForwardedProtocol)
+    })
+
+    await it('should reject ambiguous X-Forwarded-Host multi-value lists', () => {
+      const decision = evaluate(
+        createAccessPolicyRequest({
+          headers: {
+            host: 'gateway.example.com',
+            'x-forwarded-for': '203.0.113.10',
+            'x-forwarded-host': 'a.example.com, b.example.com',
+            'x-forwarded-proto': 'https',
+          },
+          remoteAddress: '192.0.2.10',
+        }),
+        createGatewayConfigWithTrustedProxy()
+      )
+
+      expectDenied(decision, UIServerAccessDenialReason.AmbiguousForwardedHost)
+    })
+
+    await it('should reject Forwarded headers with multiple comma-separated entries', () => {
+      const decision = evaluate(
+        createAccessPolicyRequest({
+          headers: {
+            forwarded: 'for=203.0.113.10;proto=https, for=198.51.100.77;proto=https',
+            host: 'gateway.example.com',
+          },
+          remoteAddress: '192.0.2.10',
+        }),
+        createGatewayConfigWithTrustedProxy()
+      )
+
+      expectDenied(decision, UIServerAccessDenialReason.AmbiguousForwardedHeader)
+    })
+
+    await it('should treat an empty Forwarded header as absent', () => {
+      const decision = evaluate(
+        createAccessPolicyRequest({
+          headers: {
+            forwarded: '',
+            host: 'localhost:8080',
+          },
+        }),
+        createAccessPolicyConfiguration()
+      )
+
+      assert.strictEqual(decision.allowed, true)
+      assert.strictEqual(decision.clientAddress, '127.0.0.1')
+    })
+
+    await it('should reject Forwarded entries with duplicate parameters', () => {
+      const decision = evaluate(
+        createAccessPolicyRequest({
+          headers: {
+            forwarded: 'for=203.0.113.10;for=198.51.100.77;proto=https',
+            host: 'gateway.example.com',
+          },
+          remoteAddress: '192.0.2.10',
+        }),
+        createGatewayConfigWithTrustedProxy()
+      )
+
+      expectDenied(decision, UIServerAccessDenialReason.AmbiguousForwardedParameter)
+    })
+
+    await it('should reject non-IP, non-hidden Forwarded for parameters', () => {
+      const decision = evaluate(
+        createAccessPolicyRequest({
+          headers: {
+            forwarded: 'for=gateway.local;proto=https',
+            host: 'gateway.example.com',
+          },
+          remoteAddress: '192.0.2.10',
+        }),
+        createGatewayConfigWithTrustedProxy()
+      )
+
+      expectDenied(decision, UIServerAccessDenialReason.InvalidForwardedClient)
+    })
+
+    await it('should reject when both X-Forwarded-For and Forwarded for= are present', () => {
+      const decision = evaluate(
+        createAccessPolicyRequest({
+          headers: {
+            forwarded: 'for=unknown;proto=https',
+            host: 'gateway.example.com',
+            'x-forwarded-for': '203.0.113.10',
+          },
+          remoteAddress: '192.0.2.10',
+        }),
+        createGatewayConfigWithTrustedProxy()
+      )
+
+      expectDenied(decision, UIServerAccessDenialReason.AmbiguousForwardedClient)
+    })
+
+    await it('should detect duplicate gateway headers via headersDistinct alone', () => {
+      const decision = evaluate(
+        createAccessPolicyRequest({
+          headers: {
+            host: 'gateway.example.com',
+            'x-forwarded-for': '203.0.113.10',
+            'x-forwarded-proto': 'https',
+          },
+          headersDistinct: { 'x-forwarded-for': ['203.0.113.10', '198.51.100.77'] },
+          remoteAddress: '192.0.2.10',
+        }),
+        createGatewayConfigWithTrustedProxy()
+      )
+
+      expectDenied(decision, UIServerAccessDenialReason.DuplicateGatewayHeaders)
+    })
+
+    await it('should reject loopback proxy forwarding without explicit opt-in', () => {
+      const decision = evaluate(
+        createAccessPolicyRequest({
+          headers: {
+            host: 'localhost:8080',
+            'x-forwarded-for': '203.0.113.10',
+            'x-forwarded-proto': 'https',
+          },
+          remoteAddress: '127.0.0.1',
+        }),
+        createAccessPolicyConfiguration({
+          accessPolicy: {
+            allowedHosts: [],
+            allowedOrigins: [],
+            allowLoopbackProxy: false,
+            requireTlsForNonLoopback: true,
+            trustedProxies: ['127.0.0.1'],
+          },
+        })
+      )
+
+      expectDenied(decision, UIServerAccessDenialReason.LoopbackProxyDisabled)
+    })
+
+    await it('should accept loopback proxy forwarding when allowLoopbackProxy is enabled', () => {
+      const decision = evaluate(
+        createAccessPolicyRequest({
+          headers: {
+            host: 'localhost:8080',
+            'x-forwarded-for': '203.0.113.10',
+            'x-forwarded-proto': 'https',
+          },
+          remoteAddress: '127.0.0.1',
+        }),
+        createAccessPolicyConfiguration({
+          accessPolicy: {
+            allowedHosts: [],
+            allowedOrigins: [],
+            allowLoopbackProxy: true,
+            requireTlsForNonLoopback: true,
+            trustedProxies: ['127.0.0.1'],
+          },
+        })
+      )
+
+      assert.strictEqual(decision.allowed, true)
+      assert.strictEqual(decision.clientAddress, '203.0.113.10')
+    })
+
+    await it('should reject duplicate forwarded headers', () => {
+      const decision = evaluate(
+        createAccessPolicyRequest({
+          headers: { host: 'gateway.example.com', 'x-forwarded-proto': 'https' },
+          rawHeaders: [
+            'Host',
+            'gateway.example.com',
+            'X-Forwarded-Proto',
+            'https',
+            'X-Forwarded-Proto',
+            'https',
+          ],
+          remoteAddress: '203.0.113.10',
+        }),
+        createAccessPolicyConfiguration({
+          accessPolicy: {
+            allowedHosts: ['gateway.example.com'],
+            allowedOrigins: [],
+            allowLoopbackProxy: false,
+            requireTlsForNonLoopback: true,
+            trustedProxies: [],
+          },
+        })
+      )
+
+      expectDenied(decision, UIServerAccessDenialReason.DuplicateGatewayHeaders)
+    })
+
+    await it('should reject disallowed host headers', () => {
+      const decision = evaluate(
+        createAccessPolicyRequest({ headers: { host: 'attacker.test' } }),
+        createAccessPolicyConfiguration()
+      )
+
+      expectDenied(decision, UIServerAccessDenialReason.HostNotAllowed)
+    })
+
+    await it('should reject wildcard listen hosts without explicit allowed hosts', () => {
+      const decision = evaluate(
+        createAccessPolicyRequest({
+          encrypted: true,
+          headers: { host: 'gateway.example.com' },
+          remoteAddress: '203.0.113.10',
+        }),
+        createAccessPolicyConfiguration({
+          options: { host: '0.0.0.0', port: 8080 },
+        })
+      )
+
+      expectDenied(decision, UIServerAccessDenialReason.HostNotAllowed)
+    })
+
+    await it('should match X-Forwarded-Host against allowedHosts when the immediate peer is a trusted proxy', () => {
+      const decision = evaluate(
+        createAccessPolicyRequest({
+          headers: {
+            host: 'internal-svc',
+            'x-forwarded-for': '203.0.113.10',
+            'x-forwarded-host': 'gateway.example.com',
+            'x-forwarded-proto': 'https',
+          },
+          remoteAddress: '192.0.2.10',
+        }),
+        createAccessPolicyConfiguration({
+          accessPolicy: {
+            allowedHosts: ['gateway.example.com'],
+            allowedOrigins: [],
+            allowLoopbackProxy: false,
+            requireTlsForNonLoopback: true,
+            trustedProxies: ['192.0.2.10'],
+          },
+        })
+      )
+
+      assert.strictEqual(decision.allowed, true)
+    })
+
+    await it('should match Forwarded host parameter against allowedHosts when the immediate peer is a trusted proxy', () => {
+      const decision = evaluate(
+        createAccessPolicyRequest({
+          headers: {
+            forwarded: 'for=203.0.113.10;host=gateway.example.com;proto=https',
+            host: 'internal-svc',
+          },
+          remoteAddress: '192.0.2.10',
+        }),
+        createAccessPolicyConfiguration({
+          accessPolicy: {
+            allowedHosts: ['gateway.example.com'],
+            allowedOrigins: [],
+            allowLoopbackProxy: false,
+            requireTlsForNonLoopback: true,
+            trustedProxies: ['192.0.2.10'],
+          },
+        })
+      )
+
+      assert.strictEqual(decision.allowed, true)
+    })
+
+    await it('should reject conflicting Forwarded host and X-Forwarded-Host headers', () => {
+      const decision = evaluate(
+        createAccessPolicyRequest({
+          headers: {
+            forwarded: 'for=203.0.113.10;host=gateway.example.com;proto=https',
+            host: 'internal-svc',
+            'x-forwarded-host': 'attacker.test',
+          },
+          remoteAddress: '192.0.2.10',
+        }),
+        createAccessPolicyConfiguration({
+          accessPolicy: {
+            allowedHosts: ['gateway.example.com'],
+            allowedOrigins: [],
+            allowLoopbackProxy: false,
+            requireTlsForNonLoopback: true,
+            trustedProxies: ['192.0.2.10'],
+          },
+        })
+      )
+
+      expectDenied(decision, UIServerAccessDenialReason.AmbiguousForwardedHost)
+    })
+
+    await it('should ignore Forwarded host parameter when the immediate peer is untrusted', () => {
+      const decision = evaluate(
+        createAccessPolicyRequest({
+          headers: {
+            forwarded: 'host=gateway.example.com',
+            host: 'internal-svc',
+          },
+          remoteAddress: '203.0.113.10',
+        }),
+        createAccessPolicyConfiguration({
+          accessPolicy: {
+            allowedHosts: ['gateway.example.com'],
+            allowedOrigins: [],
+            allowLoopbackProxy: false,
+            requireTlsForNonLoopback: true,
+            trustedProxies: [],
+          },
+        })
+      )
+
+      expectDenied(decision, UIServerAccessDenialReason.ForwardedFromUntrustedPeer)
+    })
+
+    await it('should prefer untrusted peer denial over ambiguous forwarded protocol', () => {
+      const decision = evaluate(
+        createAccessPolicyRequest({
+          headers: {
+            forwarded: 'proto=https',
+            host: 'gateway.example.com',
+            'x-forwarded-proto': 'https',
+          },
+          remoteAddress: '203.0.113.10',
+        }),
+        createAccessPolicyConfiguration({
+          accessPolicy: {
+            allowedHosts: ['gateway.example.com'],
+            allowedOrigins: [],
+            allowLoopbackProxy: false,
+            requireTlsForNonLoopback: true,
+            trustedProxies: [],
+          },
+        })
+      )
+
+      expectDenied(decision, UIServerAccessDenialReason.ForwardedFromUntrustedPeer)
+    })
+
+    await it('should prefer untrusted peer denial over ambiguous forwarded host', () => {
+      const decision = evaluate(
+        createAccessPolicyRequest({
+          headers: {
+            forwarded: 'host=gateway.example.com',
+            host: 'internal-svc',
+            'x-forwarded-host': 'attacker.test',
+          },
+          remoteAddress: '203.0.113.10',
+        }),
+        createAccessPolicyConfiguration({
+          accessPolicy: {
+            allowedHosts: ['gateway.example.com'],
+            allowedOrigins: [],
+            allowLoopbackProxy: false,
+            requireTlsForNonLoopback: true,
+            trustedProxies: [],
+          },
+        })
+      )
+
+      expectDenied(decision, UIServerAccessDenialReason.ForwardedFromUntrustedPeer)
+    })
+
+    await it('should fall back to Host header when Forwarded host parameter is empty', () => {
+      const decision = evaluate(
+        createAccessPolicyRequest({
+          headers: {
+            forwarded: 'for=203.0.113.10;host=;proto=https',
+            host: 'gateway.example.com',
+          },
+          remoteAddress: '192.0.2.10',
+        }),
+        createAccessPolicyConfiguration({
+          accessPolicy: {
+            allowedHosts: ['gateway.example.com'],
+            allowedOrigins: [],
+            allowLoopbackProxy: false,
+            requireTlsForNonLoopback: true,
+            trustedProxies: ['192.0.2.10'],
+          },
+        })
+      )
+
+      assert.strictEqual(decision.allowed, true)
+      assert.strictEqual(decision.clientAddress, '203.0.113.10')
+    })
+
+    await it('should fall back to Host header when X-Forwarded-Host is empty', () => {
+      const decision = evaluate(
+        createAccessPolicyRequest({
+          headers: {
+            host: 'gateway.example.com',
+            'x-forwarded-for': '203.0.113.10',
+            'x-forwarded-host': '',
+            'x-forwarded-proto': 'https',
+          },
+          remoteAddress: '192.0.2.10',
+        }),
+        createAccessPolicyConfiguration({
+          accessPolicy: {
+            allowedHosts: ['gateway.example.com'],
+            allowedOrigins: [],
+            allowLoopbackProxy: false,
+            requireTlsForNonLoopback: true,
+            trustedProxies: ['192.0.2.10'],
+          },
+        })
+      )
+
+      assert.strictEqual(decision.allowed, true)
+      assert.strictEqual(decision.clientAddress, '203.0.113.10')
+    })
+
+    await it('should treat empty X-Forwarded-Proto as absent rather than ambiguous', () => {
+      const decision = evaluate(
+        createAccessPolicyRequest({
+          headers: {
+            host: 'gateway.example.com',
+            'x-forwarded-for': '203.0.113.10',
+            'x-forwarded-proto': '',
+          },
+          remoteAddress: '192.0.2.10',
+        }),
+        createAccessPolicyConfiguration({
+          accessPolicy: {
+            allowedHosts: ['gateway.example.com'],
+            allowedOrigins: [],
+            allowLoopbackProxy: false,
+            requireTlsForNonLoopback: true,
+            trustedProxies: ['192.0.2.10'],
+          },
+        })
+      )
+
+      expectDenied(decision, UIServerAccessDenialReason.ProxyTlsRequired)
+    })
+
+    await it('should fall back to remote address when X-Forwarded-For is empty', () => {
+      const decision = evaluate(
+        createAccessPolicyRequest({
+          headers: {
+            host: 'gateway.example.com',
+            'x-forwarded-for': '',
+            'x-forwarded-proto': 'https',
+          },
+          remoteAddress: '192.0.2.10',
+        }),
+        createAccessPolicyConfiguration({
+          accessPolicy: {
+            allowedHosts: ['gateway.example.com'],
+            allowedOrigins: [],
+            allowLoopbackProxy: false,
+            requireTlsForNonLoopback: true,
+            trustedProxies: ['192.0.2.10'],
+          },
+        })
+      )
+
+      assert.strictEqual(decision.allowed, true)
+      assert.strictEqual(decision.clientAddress, '192.0.2.10')
+    })
+
+    await it('should ignore empty X-Forwarded-For when Forwarded for parameter is present', () => {
+      const decision = evaluate(
+        createAccessPolicyRequest({
+          headers: {
+            forwarded: 'for=203.0.113.10;proto=https',
+            host: 'gateway.example.com',
+            'x-forwarded-for': '',
+          },
+          remoteAddress: '192.0.2.10',
+        }),
+        createAccessPolicyConfiguration({
+          accessPolicy: {
+            allowedHosts: ['gateway.example.com'],
+            allowedOrigins: [],
+            allowLoopbackProxy: false,
+            requireTlsForNonLoopback: true,
+            trustedProxies: ['192.0.2.10'],
+          },
+        })
+      )
+
+      assert.strictEqual(decision.allowed, true)
+      assert.strictEqual(decision.clientAddress, '203.0.113.10')
+    })
+
+    await it('should treat empty forwarded headers as absent for the trusted-peer check', () => {
+      const decision = evaluate(
+        createAccessPolicyRequest({
+          headers: {
+            host: 'gateway.example.com',
+            'x-forwarded-proto': '',
+          },
+          remoteAddress: '203.0.113.10',
+        }),
+        createAccessPolicyConfiguration({
+          accessPolicy: {
+            allowedHosts: ['gateway.example.com'],
+            allowedOrigins: [],
+            allowLoopbackProxy: false,
+            requireTlsForNonLoopback: true,
+            trustedProxies: [],
+          },
+        })
+      )
+
+      expectDenied(decision, UIServerAccessDenialReason.TlsRequired)
+    })
+
+    await it('should accept empty forwarded headers from a loopback peer', () => {
+      const decision = evaluate(
+        createAccessPolicyRequest({
+          headers: {
+            host: 'localhost:8080',
+            'x-forwarded-proto': '',
+          },
+          remoteAddress: '127.0.0.1',
+        }),
+        createAccessPolicyConfiguration()
+      )
+
+      assert.strictEqual(decision.allowed, true)
+      assert.strictEqual(decision.clientAddress, '127.0.0.1')
+    })
+
+    await it('should treat Forwarded for=unknown as identity hidden and use the trusted proxy address', () => {
+      const decision = evaluate(
+        createAccessPolicyRequest({
+          headers: {
+            forwarded: 'for=unknown;proto=https',
+            host: 'gateway.example.com',
+          },
+          remoteAddress: '192.0.2.10',
+        }),
+        createAccessPolicyConfiguration({
+          accessPolicy: {
+            allowedHosts: ['gateway.example.com'],
+            allowedOrigins: [],
+            allowLoopbackProxy: false,
+            requireTlsForNonLoopback: true,
+            trustedProxies: ['192.0.2.10'],
+          },
+        })
+      )
+
+      assert.strictEqual(decision.allowed, true)
+      assert.strictEqual(decision.clientAddress, '192.0.2.10')
+    })
+
+    await it('should treat Forwarded obfuscated for parameter as identity hidden', () => {
+      const decision = evaluate(
+        createAccessPolicyRequest({
+          headers: {
+            forwarded: 'for=_hidden;proto=https',
+            host: 'gateway.example.com',
+          },
+          remoteAddress: '192.0.2.10',
+        }),
+        createAccessPolicyConfiguration({
+          accessPolicy: {
+            allowedHosts: ['gateway.example.com'],
+            allowedOrigins: [],
+            allowLoopbackProxy: false,
+            requireTlsForNonLoopback: true,
+            trustedProxies: ['192.0.2.10'],
+          },
+        })
+      )
+
+      assert.strictEqual(decision.allowed, true)
+      assert.strictEqual(decision.clientAddress, '192.0.2.10')
+    })
+
+    await it('should treat X-Forwarded-For unknown as identity hidden', () => {
+      const decision = evaluate(
+        createAccessPolicyRequest({
+          headers: {
+            host: 'gateway.example.com',
+            'x-forwarded-for': 'unknown',
+            'x-forwarded-proto': 'https',
+          },
+          remoteAddress: '192.0.2.10',
+        }),
+        createAccessPolicyConfiguration({
+          accessPolicy: {
+            allowedHosts: ['gateway.example.com'],
+            allowedOrigins: [],
+            allowLoopbackProxy: false,
+            requireTlsForNonLoopback: true,
+            trustedProxies: ['192.0.2.10'],
+          },
+        })
+      )
+
+      assert.strictEqual(decision.allowed, true)
+      assert.strictEqual(decision.clientAddress, '192.0.2.10')
+    })
+
+    await it('should reject disallowed origin headers', () => {
+      const decision = evaluate(
+        createAccessPolicyRequest({
+          headers: { host: 'localhost:8080', origin: 'http://attacker.test' },
+        }),
+        createAccessPolicyConfiguration()
+      )
+
+      expectDenied(decision, UIServerAccessDenialReason.OriginNotAllowed)
+    })
+
+    await it('should reject malformed Origin URLs', () => {
+      const decision = evaluate(
+        createAccessPolicyRequest({
+          headers: { host: 'localhost:8080', origin: 'not-a-valid-url' },
+        }),
+        createAccessPolicyConfiguration()
+      )
+
+      expectDenied(decision, UIServerAccessDenialReason.OriginNotAllowed)
+    })
+
+    await it('should accept Origin matching allowedHosts when allowedOrigins is empty', () => {
+      const decision = evaluate(
+        createAccessPolicyRequest({
+          headers: {
+            host: 'gateway.example.com',
+            origin: 'https://gateway.example.com',
+            'x-forwarded-for': '203.0.113.10',
+            'x-forwarded-proto': 'https',
+          },
+          remoteAddress: '192.0.2.10',
+        }),
+        createGatewayConfigWithTrustedProxy()
+      )
+
+      assert.strictEqual(decision.allowed, true)
+      assert.strictEqual(decision.clientAddress, '203.0.113.10')
+    })
+
+    await it('should accept allowedOrigins entries with a trailing slash', () => {
+      const decision = evaluate(
+        createAccessPolicyRequest({
+          headers: { host: 'localhost:8080', origin: 'https://app.example.com' },
+        }),
+        createAccessPolicyConfiguration({
+          accessPolicy: {
+            allowedHosts: [],
+            allowedOrigins: ['https://app.example.com/'],
+            allowLoopbackProxy: false,
+            requireTlsForNonLoopback: true,
+            trustedProxies: [],
+          },
+        })
+      )
+
+      assert.strictEqual(decision.allowed, true)
+    })
+
+    await it('should accept allowedOrigins entries with the protocol default port', () => {
+      const decision = evaluate(
+        createAccessPolicyRequest({
+          headers: { host: 'localhost:8080', origin: 'https://app.example.com' },
+        }),
+        createAccessPolicyConfiguration({
+          accessPolicy: {
+            allowedHosts: [],
+            allowedOrigins: ['https://app.example.com:443'],
+            allowLoopbackProxy: false,
+            requireTlsForNonLoopback: true,
+            trustedProxies: [],
+          },
+        })
+      )
+
+      assert.strictEqual(decision.allowed, true)
+    })
+
+    await it('should reject origins that differ from allowedOrigins by port', () => {
+      const decision = evaluate(
+        createAccessPolicyRequest({
+          headers: { host: 'localhost:8080', origin: 'https://app.example.com:8081' },
+        }),
+        createAccessPolicyConfiguration({
+          accessPolicy: {
+            allowedHosts: [],
+            allowedOrigins: ['https://app.example.com:8080'],
+            allowLoopbackProxy: false,
+            requireTlsForNonLoopback: true,
+            trustedProxies: [],
+          },
+        })
+      )
+
+      expectDenied(decision, UIServerAccessDenialReason.OriginNotAllowed)
+    })
+
+    await it('should reject origins that differ from allowedOrigins by protocol', () => {
+      const decision = evaluate(
+        createAccessPolicyRequest({
+          headers: { host: 'localhost:8080', origin: 'http://app.example.com' },
+        }),
+        createAccessPolicyConfiguration({
+          accessPolicy: {
+            allowedHosts: [],
+            allowedOrigins: ['https://app.example.com'],
+            allowLoopbackProxy: false,
+            requireTlsForNonLoopback: true,
+            trustedProxies: [],
+          },
+        })
+      )
+
+      expectDenied(decision, UIServerAccessDenialReason.OriginNotAllowed)
+    })
+
+    await it('should extract IPv6 client address from Forwarded for parameter with port', () => {
+      const decision = evaluate(
+        createAccessPolicyRequest({
+          headers: {
+            forwarded: 'for="[2001:db8::1]:8080";proto=https',
+            host: 'gateway.example.com',
+          },
+          remoteAddress: '2001:db8::100',
+        }),
+        createAccessPolicyConfiguration({
+          accessPolicy: {
+            allowedHosts: ['gateway.example.com'],
+            allowedOrigins: [],
+            allowLoopbackProxy: false,
+            requireTlsForNonLoopback: true,
+            trustedProxies: ['2001:db8::100'],
+          },
+        })
+      )
+
+      assert.strictEqual(decision.allowed, true)
+      assert.strictEqual(decision.clientAddress, '2001:db8:0:0:0:0:0:1')
+    })
+
+    await it('should still require TLS when a trusted proxy claims a loopback client', () => {
+      const decision = evaluate(
+        createAccessPolicyRequest({
+          headers: {
+            host: 'gateway.example.com',
+            'x-forwarded-for': '127.0.0.1',
+            'x-forwarded-proto': 'http',
+          },
+          remoteAddress: '192.0.2.10',
+        }),
+        createAccessPolicyConfiguration({
+          accessPolicy: {
+            allowedHosts: ['gateway.example.com'],
+            allowedOrigins: [],
+            allowLoopbackProxy: false,
+            requireTlsForNonLoopback: true,
+            trustedProxies: ['192.0.2.10'],
+          },
+        })
+      )
+
+      expectDenied(decision, UIServerAccessDenialReason.ProxyTlsRequired)
+    })
+
+    await it('should deny non-loopback access when accessPolicy is undefined', () => {
+      const config = createAccessPolicyConfiguration()
+
+      delete (config as { accessPolicy?: unknown }).accessPolicy
+
+      const decision = evaluate(
+        createAccessPolicyRequest({
+          headers: { host: 'localhost:8080' },
+          remoteAddress: '203.0.113.10',
+        }),
+        config
+      )
+      expectDenied(decision, UIServerAccessDenialReason.TlsRequired)
+    })
+
+    await it('should allow loopback access when accessPolicy is undefined', () => {
+      const config = createAccessPolicyConfiguration()
+
+      delete (config as { accessPolicy?: unknown }).accessPolicy
+
+      const decision = evaluate(
+        createAccessPolicyRequest({ headers: { host: 'localhost:8080' } }),
+        config
+      )
+      assert.strictEqual(decision.allowed, true)
+      assert.strictEqual(decision.clientAddress, '127.0.0.1')
+    })
+
+    await it('should allow non-loopback plaintext when requireTlsForNonLoopback is false', () => {
+      const decision = evaluate(
+        createAccessPolicyRequest({
+          headers: { host: 'gateway.example.com' },
+          remoteAddress: '203.0.113.10',
+        }),
+        createAccessPolicyConfiguration({
+          accessPolicy: {
+            allowedHosts: ['gateway.example.com'],
+            allowedOrigins: [],
+            allowLoopbackProxy: false,
+            requireTlsForNonLoopback: false,
+            trustedProxies: [],
+          },
+        })
+      )
+
+      assert.strictEqual(decision.allowed, true)
+    })
+
+    await it('should reject X-Forwarded-For with no parseable addresses', () => {
+      const decision = evaluate(
+        createAccessPolicyRequest({
+          headers: {
+            host: 'gateway.example.com',
+            'x-forwarded-for': ',',
+            'x-forwarded-proto': 'https',
+          },
+          remoteAddress: '192.0.2.10',
+        }),
+        createAccessPolicyConfiguration({
+          accessPolicy: {
+            allowedHosts: ['gateway.example.com'],
+            allowedOrigins: [],
+            allowLoopbackProxy: false,
+            requireTlsForNonLoopback: true,
+            trustedProxies: ['192.0.2.10'],
+          },
+        })
+      )
+
+      expectDenied(decision, UIServerAccessDenialReason.InvalidForwardedClient)
+    })
+
+    await it('should reject literal null origin from sandboxed contexts', () => {
+      const decision = evaluate(
+        createAccessPolicyRequest({
+          headers: { host: 'localhost:8080', origin: 'null' },
+        }),
+        createAccessPolicyConfiguration()
+      )
+
+      expectDenied(decision, UIServerAccessDenialReason.OriginNotAllowed)
+    })
+
+    await it('should normalize whitespace-padded Host headers', () => {
+      const decision = evaluate(
+        createAccessPolicyRequest({
+          headers: {
+            host: '  gateway.example.com  ',
+            'x-forwarded-for': '203.0.113.10',
+            'x-forwarded-proto': 'https',
+          },
+          remoteAddress: '192.0.2.10',
+        }),
+        createGatewayConfigWithTrustedProxy()
+      )
+
+      assert.strictEqual(decision.allowed, true)
+      assert.strictEqual(decision.clientAddress, '203.0.113.10')
+    })
+
+    await it('should accept uppercase X-Forwarded-Proto values', () => {
+      const decision = evaluate(
+        createAccessPolicyRequest({
+          headers: {
+            host: 'gateway.example.com',
+            'x-forwarded-for': '203.0.113.10',
+            'x-forwarded-proto': 'HTTPS',
+          },
+          remoteAddress: '192.0.2.10',
+        }),
+        createGatewayConfigWithTrustedProxy()
+      )
+
+      assert.strictEqual(decision.allowed, true)
+      assert.strictEqual(decision.clientAddress, '203.0.113.10')
+    })
+
+    await it('should accept IPv4-mapped IPv6 loopback proxies when allowLoopbackProxy is enabled', () => {
+      const decision = evaluate(
+        createAccessPolicyRequest({
+          headers: {
+            host: 'localhost:8080',
+            'x-forwarded-for': '203.0.113.10',
+            'x-forwarded-proto': 'https',
+          },
+          remoteAddress: '::ffff:127.0.0.1',
+        }),
+        createAccessPolicyConfiguration({
+          accessPolicy: {
+            allowedHosts: [],
+            allowedOrigins: [],
+            allowLoopbackProxy: true,
+            requireTlsForNonLoopback: true,
+            trustedProxies: ['127.0.0.1'],
+          },
+        })
+      )
+
+      assert.strictEqual(decision.allowed, true)
+      assert.strictEqual(decision.clientAddress, '203.0.113.10')
+    })
+
+    await it('should require TLS even when the underlying socket is encrypted but no forwarded headers are present', () => {
+      const decision = evaluate(
+        createAccessPolicyRequest({
+          encrypted: true,
+          headers: { host: 'gateway.example.com' },
+          remoteAddress: '203.0.113.10',
+        }),
+        createGatewayConfigWithoutTrustedProxies()
+      )
+
+      expectDenied(decision, UIServerAccessDenialReason.TlsRequired)
+    })
+  })
+
+  await describe('per-request memoization', async () => {
+    await it('should evaluate the policy only once per request', () => {
+      const config = createAccessPolicyConfiguration()
+      const cache = createUIServerAccessCache()
+      const req = createAccessPolicyRequest({ headers: { host: 'localhost:8080' } })
+
+      const first = resolveUIServerAccess(req, config, cache)
+      const second = resolveUIServerAccess(req, config, cache)
+
+      assert.strictEqual(first, second)
+      assert.strictEqual(first.allowed, true)
+    })
+
+    await it('should return distinct decisions for distinct requests', () => {
+      const config = createAccessPolicyConfiguration()
+      const cache = createUIServerAccessCache()
+      const reqA = createAccessPolicyRequest({ headers: { host: 'localhost:8080' } })
+      const reqB = createAccessPolicyRequest({
+        headers: { host: 'attacker.test' },
+        remoteAddress: '127.0.0.1',
+      })
+
+      const decisionA = resolveUIServerAccess(reqA, config, cache)
+      const decisionB = resolveUIServerAccess(reqB, config, cache)
+
+      assert.strictEqual(decisionA.allowed, true)
+      expectDenied(decisionB, UIServerAccessDenialReason.HostNotAllowed)
+    })
+
+    await it('should isolate decisions across distinct caches', () => {
+      const config = createAccessPolicyConfiguration()
+      const cacheA = createUIServerAccessCache()
+      const cacheB = createUIServerAccessCache()
+      const req = createAccessPolicyRequest({ headers: { host: 'localhost:8080' } })
+
+      resolveUIServerAccess(req, config, cacheA)
+
+      assert.strictEqual(cacheA.decisions.has(req), true)
+      assert.strictEqual(cacheB.decisions.has(req), false)
+    })
+  })
+})
diff --git a/tests/charging-station/ui-server/UIServerNet.test.ts b/tests/charging-station/ui-server/UIServerNet.test.ts
new file mode 100644 (file)
index 0000000..33d6a64
--- /dev/null
@@ -0,0 +1,141 @@
+/**
+ * @file Tests for UIServerNet
+ * @description Unit tests for IP literal normalization, loopback
+ *   classification, host parsing, and quote-aware header tokenization.
+ */
+
+import assert from 'node:assert/strict'
+import { afterEach, describe, it } from 'node:test'
+
+import {
+  isLoopback,
+  normalizeHost,
+  splitHeaderList,
+} from '../../../src/charging-station/ui-server/UIServerNet.js'
+import { standardCleanup } from '../../helpers/TestLifecycleHelpers.js'
+
+await describe('UIServerNet', async () => {
+  afterEach(() => {
+    standardCleanup()
+  })
+
+  await describe('isLoopback', async () => {
+    await it('should return true for localhost', () => {
+      assert.strictEqual(isLoopback('localhost'), true)
+    })
+
+    await it('should return true for 127.0.0.1', () => {
+      assert.strictEqual(isLoopback('127.0.0.1'), true)
+    })
+
+    await it('should return true for IPv4-mapped loopback addresses', () => {
+      assert.strictEqual(isLoopback('::ffff:127.0.0.1'), true)
+      assert.strictEqual(isLoopback('::ffff:7f00:1'), true)
+    })
+
+    await it('should return true for IPv6 loopback ::1', () => {
+      assert.strictEqual(isLoopback('::1'), true)
+    })
+
+    await it('should return true for full IPv6 loopback', () => {
+      assert.strictEqual(isLoopback('0000:0000:0000:0000:0000:0000:0000:0001'), true)
+    })
+
+    await it('should return false for external IPv4 address', () => {
+      assert.strictEqual(isLoopback('192.168.1.1'), false)
+    })
+
+    await it('should return false for invalid addresses', () => {
+      assert.strictEqual(isLoopback('127.999.999.999'), false)
+      assert.strictEqual(isLoopback('1'), false)
+    })
+
+    await it('should return true for bracketed IPv6 loopback with port', () => {
+      assert.strictEqual(isLoopback('[::1]:8080'), true)
+    })
+
+    await it('should return false for empty string', () => {
+      assert.strictEqual(isLoopback(''), false)
+    })
+  })
+
+  await describe('splitHeaderList', async () => {
+    await it('should split unquoted comma-separated values', () => {
+      assert.deepStrictEqual(splitHeaderList('a, b ,c'), ['a', 'b', 'c'])
+    })
+
+    await it('should drop empty entries', () => {
+      assert.deepStrictEqual(splitHeaderList(',a,,b,'), ['a', 'b'])
+    })
+
+    await it('should preserve commas inside double-quoted values (RFC 7239)', () => {
+      assert.deepStrictEqual(splitHeaderList('for="2001:db8::1, 2001:db8::2", proto=https'), [
+        'for="2001:db8::1, 2001:db8::2"',
+        'proto=https',
+      ])
+    })
+
+    await it('should handle quoted bracketed IPv6 with port', () => {
+      assert.deepStrictEqual(splitHeaderList('for="[2001:db8::1]:8080";proto=https'), [
+        'for="[2001:db8::1]:8080";proto=https',
+      ])
+    })
+  })
+
+  await describe('normalizeHost', async () => {
+    await it('should reject inputs with too many colons', () => {
+      assert.strictEqual(normalizeHost('a:b:c'), undefined)
+    })
+
+    await it('should reject inputs with non-numeric port', () => {
+      assert.strictEqual(normalizeHost('localhost:bad'), undefined)
+    })
+
+    await it('should reject bracketed inputs with non-numeric port', () => {
+      assert.strictEqual(normalizeHost('[::1]:abc'), undefined)
+    })
+
+    await it('should reject inputs with port out of range', () => {
+      assert.strictEqual(normalizeHost('[::1]:99999'), undefined)
+    })
+
+    await it('should reject inputs with port 0 (RFC 6335 reserved)', () => {
+      assert.strictEqual(normalizeHost('localhost:0'), undefined)
+      assert.strictEqual(normalizeHost('[::1]:0'), undefined)
+    })
+
+    await it('should reject inputs with characters outside hostname charset', () => {
+      assert.strictEqual(normalizeHost('a.example.com, b.example.com'), undefined)
+      assert.strictEqual(normalizeHost('foo bar'), undefined)
+      assert.strictEqual(normalizeHost('[bad'), undefined)
+    })
+
+    await it('should reject empty input', () => {
+      assert.strictEqual(normalizeHost(''), undefined)
+      assert.strictEqual(normalizeHost('   '), undefined)
+    })
+
+    await it('should accept hostname with optional port', () => {
+      assert.strictEqual(normalizeHost('gateway.example.com'), 'gateway.example.com')
+      assert.strictEqual(normalizeHost('gateway.example.com:8080'), 'gateway.example.com')
+    })
+
+    await it('should accept IPv4 literal with optional port', () => {
+      assert.strictEqual(normalizeHost('127.0.0.1'), '127.0.0.1')
+      assert.strictEqual(normalizeHost('127.0.0.1:8080'), '127.0.0.1')
+    })
+
+    await it('should accept bracketed IPv6 literal with optional port', () => {
+      assert.strictEqual(normalizeHost('[::1]'), '::1')
+      assert.strictEqual(normalizeHost('[::1]:8080'), '::1')
+    })
+
+    await it('should drop a single trailing dot', () => {
+      assert.strictEqual(normalizeHost('gateway.example.com.'), 'gateway.example.com')
+    })
+
+    await it('should lowercase the result', () => {
+      assert.strictEqual(normalizeHost('Gateway.Example.COM'), 'gateway.example.com')
+    })
+  })
+})
index 43694a71c024364e2bf9ce9803119478ce5aa282..aaa28c7d380983a2f67ad98bca72f8af8dada152 100644 (file)
@@ -3,15 +3,19 @@
  * @description Unit tests for UI server security utilities (rate limiting, validation)
  */
 
+import type { IncomingMessage } from 'node:http'
+
 import assert from 'node:assert/strict'
+import { Readable } from 'node:stream'
 import { afterEach, describe, it } from 'node:test'
 
 import {
-  createBodySizeLimiter,
   createRateLimiter,
   DEFAULT_MAX_STATIONS,
   isValidCredential,
   isValidNumberOfStations,
+  PayloadTooLargeError,
+  readLimitedBody,
 } from '../../../src/charging-station/ui-server/UIServerSecurity.js'
 import { standardCleanup, withMockTimers } from '../../helpers/TestLifecycleHelpers.js'
 
@@ -38,25 +42,43 @@ await describe('UIServerSecurity', async () => {
     })
   })
 
-  await describe('CreateBodySizeLimiter', async () => {
-    let limiter: ReturnType<typeof createBodySizeLimiter>
+  await describe('ReadLimitedBody', async () => {
+    const mockRequest = (...chunks: Buffer[]): IncomingMessage =>
+      Readable.from(chunks) as unknown as IncomingMessage
 
-    await it('should return true when bytes under limit', () => {
-      limiter = createBodySizeLimiter(1000)
+    await it('should return concatenated body when under limit', async () => {
+      const body = await readLimitedBody(
+        mockRequest(Buffer.from('hello '), Buffer.from('world')),
+        1000
+      )
+      assert.strictEqual(body.toString('utf8'), 'hello world')
+    })
 
-      assert.strictEqual(limiter(500), true)
+    await it('should return empty buffer for empty body', async () => {
+      const body = await readLimitedBody(mockRequest(), 1000)
+      assert.strictEqual(body.length, 0)
     })
 
-    await it('should return false when accumulated bytes exceed limit', () => {
-      limiter = createBodySizeLimiter(1000)
-      limiter(600)
-      assert.strictEqual(limiter(500), false)
+    await it('should throw PayloadTooLargeError when body exceeds limit', async () => {
+      await assert.rejects(
+        readLimitedBody(mockRequest(Buffer.alloc(600), Buffer.alloc(500)), 1000),
+        PayloadTooLargeError
+      )
     })
 
-    await it('should return true at exact limit boundary', () => {
-      limiter = createBodySizeLimiter(1000)
+    await it('should accept body at exact limit boundary', async () => {
+      const body = await readLimitedBody(mockRequest(Buffer.alloc(1000)), 1000)
+      assert.strictEqual(body.length, 1000)
+    })
 
-      assert.strictEqual(limiter(1000), true)
+    await it('should propagate stream errors', async () => {
+      const error = new Error('upstream connection reset')
+      const stream = new Readable({
+        read () {
+          this.destroy(error)
+        },
+      })
+      await assert.rejects(readLimitedBody(stream as unknown as IncomingMessage, 1000), error)
     })
   })
 
index ca5605c24ae470736cba752244193c89d023a17b..d469e24898f6f1aee01d351c4299b20f5ce8f227 100644 (file)
@@ -18,10 +18,3 @@ export const TEST_PROCEDURES = {
 
 export const TEST_HASH_ID = 'test-station-001' as const
 export const TEST_HASH_ID_2 = 'test-station-002' as const
-
-/**
- * Delay for gzip stream completion in tests.
- * Gzip compression is asynchronous (stream-based), requiring a brief wait
- * for the pipe to flush before asserting on the compressed output.
- */
-export const GZIP_STREAM_FLUSH_DELAY_MS = 50
index 48f99a30c9b5711a91f1e8f25d61bf178af41b2f..f7a9f2c62c4c99d1c8c546970bd073f6d4c2f2a1 100644 (file)
@@ -3,6 +3,7 @@
  * @description Test utilities for UI server testing
  */
 import type { IncomingMessage } from 'node:http'
+import type { Duplex } from 'node:stream'
 
 import { EventEmitter } from 'node:events'
 
@@ -60,6 +61,10 @@ export class TestableUIWebSocketServer extends UIWebSocketServer {
     this.responseHandlers.set(uuid, ws as never)
   }
 
+  public emitUpgrade (req: IncomingMessage, socket: Duplex, head = Buffer.alloc(0)): void {
+    this.httpServer.emit('upgrade', req, socket, head)
+  }
+
   /**
    * Get the size of response handlers map.
    * @returns Number of response handlers currently registered
@@ -93,6 +98,15 @@ export class TestableUIWebSocketServer extends UIWebSocketServer {
   public testRegisterProtocolVersionUIService (version: ProtocolVersion): void {
     this.registerProtocolVersionUIService(version)
   }
+
+  public async waitUntilListening (): Promise<void> {
+    if (this.httpServer.listening) {
+      return
+    }
+    await new Promise<void>(resolve => {
+      this.httpServer.once('listening', resolve)
+    })
+  }
 }
 
 /**
@@ -115,6 +129,13 @@ export const createMockUIServerConfiguration = (
   overrides?: Partial<UIServerConfiguration>
 ): UIServerConfiguration => {
   return {
+    accessPolicy: {
+      allowedHosts: [],
+      allowedOrigins: [],
+      allowLoopbackProxy: false,
+      requireTlsForNonLoopback: true,
+      trustedProxies: [],
+    },
     enabled: true,
     options: {
       host: 'localhost',
@@ -126,6 +147,51 @@ export const createMockUIServerConfiguration = (
   }
 }
 
+// RFC 5737 (IPv4 TEST-NET-1) / RFC 2606 (example.com), safe for tests.
+export const TRUSTED_PROXY_IP = '192.0.2.10'
+export const GATEWAY_HOST = 'gateway.example.com'
+
+/**
+ * Create a configuration that places the request behind a single trusted
+ * proxy reaching `GATEWAY_HOST`. Used by tests that exercise the policy
+ * past the trusted-peer gate.
+ * @param overrides - Partial accessPolicy fields to merge with the defaults
+ * @returns UIServerConfiguration ready for proxy-aware policy tests
+ */
+export const createGatewayConfigWithTrustedProxy = (
+  overrides?: Partial<UIServerConfiguration['accessPolicy']>
+): UIServerConfiguration =>
+  createMockUIServerConfiguration({
+    accessPolicy: {
+      allowedHosts: [GATEWAY_HOST],
+      allowedOrigins: [],
+      allowLoopbackProxy: false,
+      requireTlsForNonLoopback: true,
+      trustedProxies: [TRUSTED_PROXY_IP],
+      ...overrides,
+    },
+  })
+
+/**
+ * Create a configuration that exposes `GATEWAY_HOST` without any trusted
+ * proxy. Used by tests that exercise the untrusted-peer gate.
+ * @param overrides - Partial accessPolicy fields to merge with the defaults
+ * @returns UIServerConfiguration with no trusted proxies
+ */
+export const createGatewayConfigWithoutTrustedProxies = (
+  overrides?: Partial<UIServerConfiguration['accessPolicy']>
+): UIServerConfiguration =>
+  createMockUIServerConfiguration({
+    accessPolicy: {
+      allowedHosts: [GATEWAY_HOST],
+      allowedOrigins: [],
+      allowLoopbackProxy: false,
+      requireTlsForNonLoopback: true,
+      trustedProxies: [],
+      ...overrides,
+    },
+  })
+
 /**
  * Create a mock UI server configuration with basic authentication enabled.
  * @param overrides - Partial configuration to merge with auth defaults
@@ -148,11 +214,17 @@ export const createMockUIServerConfigurationWithAuth = (
 export class MockServerResponse extends EventEmitter {
   public body?: string
   public bodyBuffer?: Buffer
+  public destroyed = false
   public ended = false
   public headers: Record<string, string> = {}
   public statusCode?: number
   private chunks: Buffer[] = []
 
+  public destroy (): this {
+    this.destroyed = true
+    return this
+  }
+
   public end (data?: string): this {
     if (data != null) {
       this.body = data
@@ -190,6 +262,22 @@ export class MockServerResponse extends EventEmitter {
   }
 }
 
+export class MockUpgradeSocket extends EventEmitter {
+  public destroyed = false
+  public readonly writes: string[] = []
+
+  public destroy (): this {
+    this.destroyed = true
+    return this
+  }
+
+  public write (chunk: string, callback?: () => void): boolean {
+    this.writes.push(chunk)
+    callback?.()
+    return true
+  }
+}
+
 /**
  * Create a mock HTTP IncomingMessage for testing.
  * @param overrides - Partial message properties to merge with defaults
@@ -199,8 +287,11 @@ export const createMockIncomingMessage = (
   overrides?: Partial<IncomingMessage>
 ): IncomingMessage => {
   return {
+    destroy: () => undefined,
     headers: {},
+    headersDistinct: {},
     method: HttpMethod.POST,
+    rawHeaders: [],
     url: '/ui',
     ...overrides,
   } as IncomingMessage
@@ -299,17 +390,6 @@ export const createMockUIService = (
   },
 })
 
-/**
- * Wait for stream operations to flush.
- * @param delayMs - Delay in milliseconds to wait
- * @returns Promise that resolves after the delay
- */
-export const waitForStreamFlush = async (delayMs: number): Promise<void> => {
-  await new Promise(resolve => {
-    setTimeout(resolve, delayMs)
-  })
-}
-
 /**
  * Create mock charging station data with a specific OCPP version.
  * @param hashId - Unique identifier for the charging station
index 6f40fcc5c8f28066ff1a6a9da4e0f1a2ae8a5bf2..d3a4f3809db7dba58267026b14b8adba40fb5ee7 100644 (file)
@@ -1,6 +1,7 @@
 /**
  * @file Tests for UIServerUtils
- * @description Unit tests for UI server utility functions (auth token parsing, protocol handling, loopback detection)
+ * @description Unit tests for UI server utility functions (auth token
+ *   parsing, UI subprotocol negotiation).
  */
 
 import type { IncomingMessage } from 'node:http'
@@ -12,7 +13,6 @@ import {
   getProtocolAndVersion,
   getUsernameAndPasswordFromAuthorizationToken,
   handleProtocols,
-  isLoopback,
   isProtocolAndVersionSupported,
 } from '../../../src/charging-station/ui-server/UIServerUtils.js'
 import { Protocol, ProtocolVersion } from '../../../src/types/index.js'
@@ -20,9 +20,6 @@ import { logger } from '../../../src/utils/index.js'
 import { createLoggerMocks, standardCleanup } from '../../helpers/TestLifecycleHelpers.js'
 
 await describe('UIServerUtils', async () => {
-  // eslint-disable-next-line @typescript-eslint/no-empty-function
-  const noop = (): void => {}
-
   afterEach(() => {
     standardCleanup()
   })
@@ -31,55 +28,35 @@ await describe('UIServerUtils', async () => {
     await it('should parse valid credentials', () => {
       // cspell:disable-next-line
       const token = Buffer.from('alice:s3cret').toString('base64')
-      const result = getUsernameAndPasswordFromAuthorizationToken(token, noop)
+      const result = getUsernameAndPasswordFromAuthorizationToken(token)
       // cspell:disable-next-line
       assert.deepStrictEqual(result, ['alice', 's3cret'])
     })
 
     await it('should handle password containing colons', () => {
       const token = Buffer.from('user:pass:with:colons').toString('base64')
-      const result = getUsernameAndPasswordFromAuthorizationToken(token, noop)
+      const result = getUsernameAndPasswordFromAuthorizationToken(token)
       assert.deepStrictEqual(result, ['user', 'pass:with:colons'])
     })
 
     await it('should reject token missing colon separator', () => {
       // cspell:disable-next-line
       const token = Buffer.from('nocolon').toString('base64')
-      let errorMessage: string | undefined
-      const result = getUsernameAndPasswordFromAuthorizationToken(token, err => {
-        errorMessage = err?.message
-      })
-      assert.strictEqual(result, undefined)
-      assert.match(errorMessage ?? '', /missing.*separator/i)
+      assert.strictEqual(getUsernameAndPasswordFromAuthorizationToken(token), undefined)
     })
 
     await it('should reject empty username (RFC 7613 §3.1)', () => {
       const token = Buffer.from(':password').toString('base64')
-      let errorMessage: string | undefined
-      const result = getUsernameAndPasswordFromAuthorizationToken(token, err => {
-        errorMessage = err?.message
-      })
-      assert.strictEqual(result, undefined)
-      assert.match(errorMessage ?? '', /empty username/i)
+      assert.strictEqual(getUsernameAndPasswordFromAuthorizationToken(token), undefined)
     })
 
     await it('should reject empty password (RFC 7613 §4.1)', () => {
       const token = Buffer.from('username:').toString('base64')
-      let errorMessage: string | undefined
-      const result = getUsernameAndPasswordFromAuthorizationToken(token, err => {
-        errorMessage = err?.message
-      })
-      assert.strictEqual(result, undefined)
-      assert.match(errorMessage ?? '', /empty password/i)
+      assert.strictEqual(getUsernameAndPasswordFromAuthorizationToken(token), undefined)
     })
 
     await it('should reject empty token', () => {
-      let errorMessage: string | undefined
-      const result = getUsernameAndPasswordFromAuthorizationToken('', err => {
-        errorMessage = err?.message
-      })
-      assert.strictEqual(result, undefined)
-      assert.match(errorMessage ?? '', /missing.*separator/i)
+      assert.strictEqual(getUsernameAndPasswordFromAuthorizationToken(''), undefined)
     })
   })
 
@@ -149,30 +126,4 @@ await describe('UIServerUtils', async () => {
       assert.strictEqual(handleProtocols(protocols, dummyRequest), supported)
     })
   })
-
-  await describe('isLoopback', async () => {
-    await it('should return true for localhost', () => {
-      assert.strictEqual(isLoopback('localhost'), true)
-    })
-
-    await it('should return true for 127.0.0.1', () => {
-      assert.strictEqual(isLoopback('127.0.0.1'), true)
-    })
-
-    await it('should return true for IPv6 loopback ::1', () => {
-      assert.strictEqual(isLoopback('::1'), true)
-    })
-
-    await it('should return true for full IPv6 loopback', () => {
-      assert.strictEqual(isLoopback('0000:0000:0000:0000:0000:0000:0000:0001'), true)
-    })
-
-    await it('should return false for external IPv4 address', () => {
-      assert.strictEqual(isLoopback('192.168.1.1'), false)
-    })
-
-    await it('should return false for empty string', () => {
-      assert.strictEqual(isLoopback(''), false)
-    })
-  })
 })
index 7f8e1ec4a18bd183bfee30879d1b3b8302defbd3..cccc895f050fa19d10276a7ed270ddcc97e7141c 100644 (file)
@@ -3,6 +3,8 @@
  * @description Unit tests for WebSocket-based UI server and response handling
  */
 
+import type { Duplex } from 'node:stream'
+
 import assert from 'node:assert/strict'
 import { afterEach, describe, it } from 'node:test'
 
@@ -12,10 +14,13 @@ import { ProcedureName, ResponseStatus } from '../../../src/types/index.js'
 import { standardCleanup } from '../../helpers/TestLifecycleHelpers.js'
 import { TEST_UUID } from './UIServerTestConstants.js'
 import {
+  createMockIncomingMessage,
   createMockUIServerConfiguration,
+  createMockUIServerConfigurationWithAuth,
   createMockUIService,
   createMockUIWebSocket,
   MockUIServiceMode,
+  MockUpgradeSocket,
   TestableUIWebSocketServer,
 } from './UIServerTestUtils.js'
 
@@ -184,4 +189,123 @@ await describe('UIWebSocketServer', async () => {
     const server = new TestableUIWebSocketServer(config)
     assert.notStrictEqual(server, undefined)
   })
+
+  await it('should reject non-loopback plaintext upgrades before WebSocket handling', async () => {
+    const config = createMockUIServerConfiguration({
+      accessPolicy: {
+        allowedHosts: ['gateway.example.com'],
+        requireTlsForNonLoopback: true,
+      },
+      options: {
+        host: 'localhost',
+        port: 0,
+      },
+    })
+    const server = new TestableUIWebSocketServer(config)
+    const socket = new MockUpgradeSocket()
+
+    try {
+      server.start()
+      await server.waitUntilListening()
+      server.emitUpgrade(
+        createMockIncomingMessage({
+          headers: {
+            connection: 'Upgrade',
+            host: 'gateway.example.com',
+            upgrade: 'websocket',
+          },
+          socket: { encrypted: false, remoteAddress: '203.0.113.10' } as never,
+        }),
+        socket as unknown as Duplex
+      )
+    } finally {
+      server.stop()
+    }
+
+    assert.strictEqual(socket.destroyed, true)
+    const response = socket.writes.join('')
+    assert.match(response, /403 Forbidden/)
+    assert.match(response, /Connection: close/)
+    assert.match(response, /Content-Length: 0/)
+  })
+
+  await it('should include the Retry-After header on rate-limited upgrades', async () => {
+    const config = createMockUIServerConfiguration({
+      accessPolicy: {
+        allowedHosts: [],
+        allowedOrigins: [],
+        allowLoopbackProxy: false,
+        requireTlsForNonLoopback: true,
+        trustedProxies: [],
+      },
+      options: {
+        host: 'localhost',
+        port: 0,
+      },
+    })
+    const server = new TestableUIWebSocketServer(config)
+    Reflect.set(server, 'rateLimiter', (_ip: string) => false)
+    const socket = new MockUpgradeSocket()
+
+    try {
+      server.start()
+      await server.waitUntilListening()
+      server.emitUpgrade(
+        createMockIncomingMessage({
+          headers: {
+            connection: 'Upgrade',
+            host: 'localhost',
+            upgrade: 'websocket',
+          },
+          socket: { encrypted: false, remoteAddress: '127.0.0.1' } as never,
+        }),
+        socket as unknown as Duplex
+      )
+    } finally {
+      server.stop()
+    }
+
+    const response = socket.writes.join('')
+    assert.strictEqual(socket.destroyed, true)
+    assert.match(response, /429 Too Many Requests/)
+    assert.match(response, /Retry-After: 60/)
+    assert.match(response, /Connection: close/)
+    assert.match(response, /Content-Length: 0/)
+  })
+
+  await it('should advertise WWW-Authenticate on auth-denied upgrades', async () => {
+    const config = createMockUIServerConfigurationWithAuth({
+      options: {
+        host: 'localhost',
+        port: 0,
+      },
+    })
+    const server = new TestableUIWebSocketServer(config)
+    const socket = new MockUpgradeSocket()
+
+    try {
+      server.start()
+      await server.waitUntilListening()
+      server.emitUpgrade(
+        createMockIncomingMessage({
+          headers: {
+            connection: 'Upgrade',
+            host: 'localhost',
+            upgrade: 'websocket',
+          },
+          socket: { encrypted: false, remoteAddress: '127.0.0.1' } as never,
+        }),
+        socket as unknown as Duplex
+      )
+    } finally {
+      server.stop()
+    }
+
+    const response = socket.writes.join('')
+    assert.strictEqual(socket.destroyed, true)
+    assert.match(response, /401 Unauthorized/)
+    assert.match(response, /WWW-Authenticate: Basic realm=users/)
+    assert.match(response, /Connection: close/)
+    assert.match(response, /Content-Length: 0/)
+  })
 })
index c44123f96389df15fec4deeed5e25b7a2f47915e..ef2455147cc735ff43388265ba1a0b7a9ca9a679 100644 (file)
@@ -120,6 +120,14 @@ await describe('Configuration', async () => {
     assert.notStrictEqual(uiServer.options, undefined)
     assert.strictEqual(typeof uiServer.options?.host, 'string')
     assert.strictEqual(typeof uiServer.options?.port, 'number')
+    const accessPolicy = uiServer.accessPolicy
+    assert.notStrictEqual(accessPolicy, undefined)
+    if (accessPolicy == null) {
+      assert.fail('Expected UI server access policy defaults')
+    }
+    assert.strictEqual(accessPolicy.requireTlsForNonLoopback, true)
+    assert.strictEqual(accessPolicy.allowLoopbackProxy, false)
+    assert.deepStrictEqual(accessPolicy.trustedProxies, [])
   })
 
   await it('should return performance storage configuration', () => {
index ca9b436ffc05fd97bf13b87370df5cd09313d843..c15e52bb1000f0a7e83e96d4849b1741f0f85b39 100644 (file)
@@ -11,7 +11,11 @@ import {
   CURRENT_CONFIGURATION_SCHEMA_VERSION,
   DEPRECATED_KEY_REMAPPINGS,
 } from '../../src/utils/index.js'
-import { ConfigurationSchema, WorkerConfigurationSchema } from '../../src/utils/index.js'
+import {
+  ConfigurationSchema,
+  UI_SERVER_ACCESS_POLICY_DEFAULTS,
+  WorkerConfigurationSchema,
+} from '../../src/utils/index.js'
 import { standardCleanup } from '../helpers/TestLifecycleHelpers.js'
 import {
   BAD_FIXTURES,
@@ -193,6 +197,238 @@ await describe('ConfigurationSchema', async () => {
       assert.ok(result.success)
     })
 
+    await it('should accept valid uiServer access policy', () => {
+      const result = ConfigurationSchema.safeParse(
+        buildMinimalConfiguration({
+          uiServer: {
+            accessPolicy: {
+              allowedHosts: ['gateway.example.com'],
+              allowedOrigins: ['https://gateway.example.com'],
+              allowLoopbackProxy: true,
+              requireTlsForNonLoopback: true,
+              trustedProxies: ['127.0.0.1'],
+            },
+            enabled: true,
+            type: 'ws',
+          },
+        })
+      )
+      assert.ok(result.success)
+    })
+
+    await it('should reject unknown key in uiServer access policy', () => {
+      const result = ConfigurationSchema.safeParse(
+        buildMinimalConfiguration({
+          uiServer: {
+            accessPolicy: {
+              unknownPolicyKey: true,
+            },
+          },
+        })
+      )
+      assert.ok(!result.success)
+      assert.ok(result.error.issues.some(i => i.path.join('.').includes('uiServer.accessPolicy')))
+    })
+
+    await it('should reject misplaced access policy under uiServer options', () => {
+      const result = ConfigurationSchema.safeParse(
+        buildMinimalConfiguration({
+          uiServer: {
+            options: {
+              accessPolicy: {
+                requireTlsForNonLoopback: false,
+              },
+              host: 'localhost',
+              port: 8080,
+            },
+          },
+        })
+      )
+      assert.ok(!result.success)
+      assert.ok(result.error.issues.some(i => i.path.join('.').includes('uiServer.options')))
+    })
+
+    await it('should reject hostnames in trustedProxies', () => {
+      const result = ConfigurationSchema.safeParse(
+        buildMinimalConfiguration({
+          uiServer: {
+            accessPolicy: {
+              trustedProxies: ['nginx.internal'],
+            },
+            enabled: true,
+            type: 'ws',
+          },
+        })
+      )
+      assert.ok(!result.success)
+      assert.ok(result.error.issues.some(i => i.path.join('.').includes('trustedProxies')))
+    })
+
+    await it('should reject CIDR ranges in trustedProxies', () => {
+      const result = ConfigurationSchema.safeParse(
+        buildMinimalConfiguration({
+          uiServer: {
+            accessPolicy: {
+              trustedProxies: ['10.0.0.0/8'],
+            },
+            enabled: true,
+            type: 'ws',
+          },
+        })
+      )
+      assert.ok(!result.success)
+      assert.ok(result.error.issues.some(i => i.path.join('.').includes('trustedProxies')))
+    })
+
+    await it('should accept IPv4 and IPv6 literals in trustedProxies', () => {
+      const result = ConfigurationSchema.safeParse(
+        buildMinimalConfiguration({
+          uiServer: {
+            accessPolicy: {
+              trustedProxies: ['10.0.0.1', '2001:db8::1', '::ffff:127.0.0.1'],
+            },
+            enabled: true,
+            type: 'ws',
+          },
+        })
+      )
+      assert.ok(
+        result.success,
+        `Expected IP literals to be accepted: ${result.success ? '' : JSON.stringify(result.error.issues)}`
+      )
+    })
+
+    await it('should reject malformed allowedHosts entries', () => {
+      for (const malformedHost of [
+        'a:b:c',
+        'localhost:bad',
+        '[::1]:99999',
+        '[::1]:abc',
+        '',
+        'a.example.com, b.example.com',
+        'foo bar',
+        'localhost:0',
+        '[bad',
+      ]) {
+        const result = ConfigurationSchema.safeParse(
+          buildMinimalConfiguration({
+            uiServer: {
+              accessPolicy: {
+                allowedHosts: [malformedHost],
+              },
+              enabled: true,
+              type: 'ws',
+            },
+          })
+        )
+        assert.ok(
+          !result.success,
+          `Expected '${malformedHost}' to be rejected as allowedHosts entry`
+        )
+        assert.ok(result.error.issues.some(i => i.path.join('.').includes('allowedHosts')))
+      }
+    })
+
+    await it('should accept hostnames and IP literals in allowedHosts', () => {
+      const result = ConfigurationSchema.safeParse(
+        buildMinimalConfiguration({
+          uiServer: {
+            accessPolicy: {
+              allowedHosts: [
+                'gateway.example.com',
+                '127.0.0.1',
+                '[::1]',
+                '[::1]:8080',
+                'localhost',
+              ],
+            },
+            enabled: true,
+            type: 'ws',
+          },
+        })
+      )
+      assert.ok(
+        result.success,
+        `Expected valid hosts to be accepted: ${result.success ? '' : JSON.stringify(result.error.issues)}`
+      )
+    })
+
+    await it('should expose canonical UI server access policy defaults', () => {
+      assert.deepStrictEqual(UI_SERVER_ACCESS_POLICY_DEFAULTS, {
+        allowedHosts: [],
+        allowedOrigins: [],
+        allowLoopbackProxy: false,
+        requireTlsForNonLoopback: true,
+        trustedProxies: [],
+      })
+    })
+
+    await it('should accept allowedOrigins entries with no path, query, or fragment', () => {
+      const result = ConfigurationSchema.safeParse(
+        buildMinimalConfiguration({
+          uiServer: {
+            accessPolicy: {
+              allowedOrigins: ['https://app.example.com', 'https://app.example.com/'],
+            },
+            enabled: true,
+            type: 'ws',
+          },
+        })
+      )
+      assert.ok(
+        result.success,
+        `Expected origin URLs to be accepted: ${result.success ? '' : JSON.stringify(result.error.issues)}`
+      )
+    })
+
+    await it('should reject allowedOrigins entries with a path', () => {
+      const result = ConfigurationSchema.safeParse(
+        buildMinimalConfiguration({
+          uiServer: {
+            accessPolicy: {
+              allowedOrigins: ['https://app.example.com/admin'],
+            },
+            enabled: true,
+            type: 'ws',
+          },
+        })
+      )
+      assert.ok(!result.success)
+      assert.ok(result.error.issues.some(i => i.path.join('.').includes('allowedOrigins')))
+    })
+
+    await it('should reject allowedOrigins entries with a query string', () => {
+      const result = ConfigurationSchema.safeParse(
+        buildMinimalConfiguration({
+          uiServer: {
+            accessPolicy: {
+              allowedOrigins: ['https://app.example.com?token=x'],
+            },
+            enabled: true,
+            type: 'ws',
+          },
+        })
+      )
+      assert.ok(!result.success)
+      assert.ok(result.error.issues.some(i => i.path.join('.').includes('allowedOrigins')))
+    })
+
+    await it('should reject allowedOrigins entries with a fragment', () => {
+      const result = ConfigurationSchema.safeParse(
+        buildMinimalConfiguration({
+          uiServer: {
+            accessPolicy: {
+              allowedOrigins: ['https://app.example.com#fragment'],
+            },
+            enabled: true,
+            type: 'ws',
+          },
+        })
+      )
+      assert.ok(!result.success)
+      assert.ok(result.error.issues.some(i => i.path.join('.').includes('allowedOrigins')))
+    })
+
     await it('should reject unknown key in worker section', () => {
       const result = ConfigurationSchema.safeParse(
         buildMinimalConfiguration({ worker: { unknownWorkerKey: true } })
@@ -395,6 +631,13 @@ await describe('ConfigurationSchema', async () => {
         supervisionUrlDistribution: 'round-robin',
         supervisionUrls: 'ws://localhost:8180/steve/websocket/CentralSystemService',
         uiServer: {
+          accessPolicy: {
+            allowedHosts: [],
+            allowedOrigins: [],
+            allowLoopbackProxy: false,
+            requireTlsForNonLoopback: true,
+            trustedProxies: [],
+          },
           enabled: false,
           options: {
             host: 'localhost',
index 187fd0ff59657a72756b52396055387bd8fda087..20b89fa3b40a0a174d02d7cc9d761bcf200dcb95 100644 (file)
@@ -399,6 +399,30 @@ await describe('ConfigurationValidation', async () => {
       assert.ok(result.stationTemplateUrls.length > 0)
     })
 
+    await it('should validate docker/config.json through the pipeline', t => {
+      t.mock.method(console, 'warn', () => undefined)
+      const dockerConfigPath = join(import.meta.dirname, '../../docker/config.json')
+      const parsed = JSON.parse(readFileSync(dockerConfigPath, 'utf8')) as Record<string, unknown>
+
+      const result = validateConfiguration(parsed, 'docker/config.json')
+
+      assert.ok(result, 'docker/config.json should validate successfully')
+      const { uiServer } = result
+      if (uiServer == null) {
+        assert.fail('docker/config.json should define uiServer')
+      }
+      const { accessPolicy } = uiServer
+      if (accessPolicy == null) {
+        assert.fail('docker/config.json should define uiServer.accessPolicy')
+      }
+      assert.deepStrictEqual(accessPolicy.allowedHosts, ['localhost', '127.0.0.1', '::1'])
+      assert.strictEqual(accessPolicy.requireTlsForNonLoopback, false)
+
+      const dockerComposePath = join(import.meta.dirname, '../../docker/docker-compose.yml')
+      const dockerCompose = readFileSync(dockerComposePath, 'utf8')
+      assert.match(dockerCompose, /127\.0\.0\.1:8080:8080/)
+    })
+
     await it('should validate the hardcoded fallback object from Configuration.ts', t => {
       t.mock.method(console, 'warn', () => undefined)
       // Mirror of the fallback assigned in src/utils/Configuration.ts when