fix(ui-server): post-#1891 access policy and WS upgrade hardening (#1898)
* fix(ui-server): reject ports in allowedHosts, fix host normalization edges
- ConfigurationSchema: reject allowedHosts entries that carry a port
(e.g., 'gateway.example.com:8080', '[::1]:8080'). Runtime host
matching strips ports, so a port-bearing entry was silently
equivalent to its host-only form. Operators are now forced to
spell out host-only entries at config load time, matching how
matching actually works.
- UIServerNet: normalizeHost strips a trailing dot after the colon
split rather than from the whole input, so a Host header like
'localhost.:80' now resolves to 'localhost' instead of leaving
the trailing dot intact and failing the allowlist match.
- UIServerAccessPolicy: nonEmpty treats whitespace-only forwarded
values as absent, and hasForwardedHeaders consumes nonEmpty so
the two helpers agree. A whitespace-only X-Forwarded-Proto no
longer flips forwardedHeadersPresent to true and triggers an
Ambiguous* denial.
The onSocketError listener was previously attached only after the
prologue and bad-header guards had already written their rejection
responses, and was removed before the auth-failure rejection write.
A client TCP-reset between an upgrade event and the destroy callback
could fire 'error' on the Duplex socket with no listener, which Node
escalates to an unhandled exception and crashes the process.
The listener is now attached at the top of the upgrade handler and
only removed once webSocketServer.handleUpgrade takes ownership of
the socket. All three rejection paths (bad upgrade headers, prologue
failure, auth failure) keep the listener until socket.destroy(),
neutralising the DoS vector.
- Schema: split UIServerListenOptions validation so non-object values
surface 'must be a non-array object' instead of the misleading
'accessPolicy must be configured under uiServer' message.
- Forwarded parser: unescape RFC 7230 quoted-pair sequences inside
double-quoted Forwarded parameter values.
- getForwardedClientAddress: propagate forwarded.kind === 'error' before
the trustedProxy gate, matching getForwardedProtocol and
getForwardedHost (no behavioral change today; closes a latent
asymmetry if the upstream untrusted-peer gate is reordered).
- UIServerAccessCache: document identity-based cache invalidation for
trustedProxies; reload flows must construct a new configuration.
- getForwardedProtocol / getForwardedHost: distinguish length === 0
(new InvalidForwardedProtocol / InvalidForwardedHost reasons) from
length > 1 (existing Ambiguous reasons), matching the existing
InvalidForwardedClient / AmbiguousForwardedClient pattern.