**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 />"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 />metrics?: {<br />enabled?: boolean;<br />softSampleCap?: number;<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 /> - _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 /> - _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 /> - _allowLoopbackProxy_: accept forwarded headers when the immediate peer is loopback AND listed in _trustedProxies_ (e.g. `['127.0.0.1', '::1']`)<br /> - _allowedHosts_: explicit Host header allowlist; mitigates DNS rebinding when the UI server is exposed through a browser-facing host<br /> - _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<br />- _metrics_: opt-in Prometheus `/metrics` endpoint (served on the configured `uiServer.type` listener — `http`, `ws` or `mcp`):<br /> - _enabled_: enable the `/metrics` endpoint<br /> - _softSampleCap_: soft cardinality cap above which a single warn is logged per scrape (default 5000) |
-| 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` to a truthy value (case-insensitive `true` or `1`, optionally surrounded by whitespace) 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 />metrics?: {<br />enabled?: boolean;<br />softSampleCap?: number;<br />};<br />securityHeaders?: {<br />strictTransportSecurity?: string \| false;<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 /> - _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 /> - _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 /> - _allowLoopbackProxy_: accept forwarded headers when the immediate peer is loopback AND listed in _trustedProxies_ (e.g. `['127.0.0.1', '::1']`)<br /> - _allowedHosts_: explicit Host header allowlist; mitigates DNS rebinding when the UI server is exposed through a browser-facing host<br /> - _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<br />- _metrics_: opt-in Prometheus `/metrics` endpoint (served on the configured `uiServer.type` listener — `http`, `ws` or `mcp`):<br /> - _enabled_: enable the `/metrics` endpoint<br /> - _softSampleCap_: soft cardinality cap above which a single warn is logged per scrape (default 5000)<br />- _securityHeaders_: opt-in security response headers emitted on every UI-server HTTP response path served over secure transport, across the `http`, `ws` and `mcp` transports (denials, WebSocket upgrade rejections, `http` success responses and the `/metrics` endpoint), except MCP JSON-RPC success bodies (written by the MCP SDK transport); absent by default so the default response behavior is unchanged:<br /> - _strictTransportSecurity_: `Strict-Transport-Security` (HSTS) header value, emitted verbatim (and unvalidated) when set to a non-empty string; `false` (default) or an empty string omits the header. Emission is gated on secure transport — direct TLS or a trusted-proxy-forwarded `https`/`wss` protocol — per [RFC 6797](https://www.rfc-editor.org/rfc/rfc6797) §7.2 (HSTS must not be sent over non-secure transport), so it is omitted over plaintext (e.g. loopback development). Recommended production value behind a TLS-terminating reverse proxy or native TLS: `"max-age=31536000; includeSubDomains"`. Caution: `includeSubDomains` extends the policy to every subdomain of the served host and `preload` (with submission to hstspreload.org) is effectively irreversible (removal propagates through browser preload lists over ~6–12 months); only use `includeSubDomains`/`preload` on a dedicated hostname you fully control, never on a shared host (including `localhost`) |
+| 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` to a truthy value (case-insensitive `true` or `1`, optionally surrounded by whitespace) 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
- emerg
- REMAPPINGS
- interruptible
+ - HSTS
+ - Hsts
+ - hsts
import { UIServiceFactory } from './ui-services/UIServiceFactory.js'
import {
createUIServerAccessCache,
+ isRequestEffectivelySecure,
resolveUIServerAccess,
type UIServerAccessCache,
type UIServerAccessDecision,
return this.metricsRegistry
}
+ /**
+ * Opt-in `Strict-Transport-Security` (HSTS) response header.
+ *
+ * Emits the configured `securityHeaders.strictTransportSecurity` value only
+ * when it is a non-empty string AND the response travels over secure
+ * transport (`isSecure`); returns an empty object otherwise. Omitting it over
+ * non-secure transport satisfies RFC 6797 §7.2 (an HSTS host must not send the
+ * header over non-secure transport); omitting it when unset or `false` keeps
+ * the default response behavior unchanged (issue #1980).
+ * @param isSecure Whether the response is served over secure transport, as
+ * resolved by {@link isSecureRequest}.
+ * @returns Header object spreadable into `writeHead` (empty when insecure,
+ * unset, or `false`).
+ */
+ protected getSecurityHeaders (isSecure: boolean): Record<string, string> {
+ const { strictTransportSecurity } = this.uiServerConfiguration.securityHeaders ?? {}
+ return isSecure && isNotEmptyString(strictTransportSecurity)
+ ? { 'Strict-Transport-Security': strictTransportSecurity }
+ : {}
+ }
+
protected getUnauthorizedDenial (): {
headers: Readonly<Record<string, string>>
reasonPhrase: string
protected handleMetricsHttpRequest (req: IncomingMessage, res: ServerResponse): void {
const registry = this.metricsRegistry
if (registry === undefined) {
- this.renderDenial(res, {
+ this.renderDenial(req, res, {
reasonPhrase: getReasonPhrase(StatusCodes.NOT_FOUND),
status: StatusCodes.NOT_FOUND,
})
error
)
if (!res.headersSent && !res.writableEnded) {
- res.writeHead(StatusCodes.INTERNAL_SERVER_ERROR, { 'Content-Type': 'text/plain' }).end()
+ res
+ .writeHead(StatusCodes.INTERNAL_SERVER_ERROR, {
+ 'Content-Type': 'text/plain',
+ ...this.getSecurityHeaders(this.isSecureRequest(req)),
+ })
+ .end()
}
})
}
}
}
+ /**
+ * Whether a response to `req` travels over secure transport (direct TLS or a
+ * trusted reverse proxy that forwarded `https`/`wss`), gating HSTS emission
+ * per RFC 6797 §7.2. Reuses the per-request access cache.
+ * @param req Incoming request whose transport security is evaluated.
+ * @returns `true` when the response would be served over secure transport.
+ */
+ protected isSecureRequest (req: IncomingMessage): boolean {
+ return isRequestEffectivelySecure(req, this.uiServerConfiguration, this.accessCache)
+ }
+
protected notifyClients (): void {
// No-op by default — subclasses with push capability override this
}
}
protected renderDenial (
+ req: IncomingMessage,
res: ServerResponse,
payload: {
headers?: Readonly<Record<string, string>>
.writeHead(payload.status, {
'Content-Type': 'text/plain',
...payload.headers,
+ ...this.getSecurityHeaders(this.isSecureRequest(req)),
...this.getConnectionCloseHeader(),
})
.end(`${payload.status.toString()} ${payload.reasonPhrase}`)
* @param res Server response.
*/
protected renderNotFoundAndDestroy (req: IncomingMessage, res: ServerResponse): void {
- this.renderDenial(res, {
+ this.renderDenial(req, res, {
reasonPhrase: getReasonPhrase(StatusCodes.NOT_FOUND),
status: StatusCodes.NOT_FOUND,
})
return false
}
if (!this.authenticate(req)) {
- this.renderDenial(res, this.getUnauthorizedDenial())
+ this.renderDenial(req, res, this.getUnauthorizedDenial())
return true
}
this.handleMetricsHttpRequest(req, res)
.writeHead(StatusCodes.OK, {
'Content-Length': contentLength,
'Content-Type': registry.contentType,
+ ...this.getSecurityHeaders(this.isSecureRequest(req)),
})
.end(isHead ? undefined : rawBody)
}
private readonly acceptsGzip: Map<UUIDv4, boolean>
+ private readonly secureResponses: Map<UUIDv4, boolean>
+
public constructor (
protected override readonly uiServerConfiguration: UIServerConfiguration,
bootstrap: IBootstrap
) {
super(uiServerConfiguration, bootstrap)
this.acceptsGzip = new Map<UUIDv4, boolean>()
+ this.secureResponses = new Map<UUIDv4, boolean>()
}
public sendRequest (request: ProtocolRequest): void {
'Content-Encoding': 'gzip',
'Content-Type': 'application/json',
Vary: 'Accept-Encoding',
+ ...this.getSecurityHeaders(this.secureResponses.get(uuid) === true),
})
const gzip = this.createGzipStream()
gzip.on('error', (error: Error) => {
res
.writeHead(this.responseStatusToStatusCode(payload.status), {
'Content-Type': 'application/json',
+ ...this.getSecurityHeaders(this.secureResponses.get(uuid) === true),
})
.end(body)
}
} finally {
this.responseHandlers.delete(uuid)
this.acceptsGzip.delete(uuid)
+ this.secureResponses.delete(uuid)
}
}
private requestListener (req: IncomingMessage, res: ServerResponse): void {
const prologue = this.runRequestPrologue(req)
if (!prologue.ok) {
- this.renderDenial(res, prologue)
+ this.renderDenial(req, res, prologue)
return
}
if (this.tryServeMetrics(req, res)) {
return
}
if (!this.authenticate(req)) {
- this.renderDenial(res, this.getUnauthorizedDenial())
+ this.renderDenial(req, res, this.getUnauthorizedDenial())
return
}
this.responseHandlers.set(uuid, res)
const acceptEncoding = req.headers['accept-encoding'] ?? ''
this.acceptsGzip.set(uuid, /\bgzip\b/.test(acceptEncoding))
+ this.secureResponses.set(uuid, this.isSecureRequest(req))
res.on('close', () => {
this.responseHandlers.delete(uuid)
this.acceptsGzip.delete(uuid)
+ this.secureResponses.delete(uuid)
})
try {
this.handleRequestBody(req, res, uuid, version, procedureName).catch((error: unknown) => {
if (error instanceof PayloadTooLargeError) {
- this.renderDenial(res, {
+ this.renderDenial(req, res, {
reasonPhrase: getReasonPhrase(StatusCodes.REQUEST_TOO_LONG),
status: StatusCodes.REQUEST_TOO_LONG,
})
this.httpServer.on('request', (req: IncomingMessage, res: ServerResponse) => {
const prologue = this.runRequestPrologue(req)
if (!prologue.ok) {
- this.renderDenial(res, prologue)
+ this.renderDenial(req, res, prologue)
return
}
if (this.tryServeMetrics(req, res)) {
}
if (!this.authenticate(req)) {
- this.renderDenial(res, this.getUnauthorizedDenial())
+ this.renderDenial(req, res, this.getUnauthorizedDenial())
return
}
} catch (error: unknown) {
logger.error(`${this.logPrefix(moduleName, 'handleMcpRequest')} MCP connect error:`, error)
cleanup()
- this.sendErrorResponse(res, StatusCodes.INTERNAL_SERVER_ERROR)
+ this.sendErrorResponse(req, res, StatusCodes.INTERNAL_SERVER_ERROR)
return
}
await transport.handleRequest(req, res)
} else {
cleanup()
- this.sendErrorResponse(res, StatusCodes.METHOD_NOT_ALLOWED, {
+ this.sendErrorResponse(req, res, StatusCodes.METHOD_NOT_ALLOWED, {
Allow: 'GET, POST, DELETE',
})
}
cleanup()
const isBadRequest = error instanceof SyntaxError || error instanceof PayloadTooLargeError
this.sendErrorResponse(
+ req,
res,
isBadRequest ? StatusCodes.BAD_REQUEST : StatusCodes.INTERNAL_SERVER_ERROR
)
}
private sendErrorResponse (
+ req: IncomingMessage,
res: ServerResponse,
statusCode: StatusCodes,
headers?: Readonly<Record<string, string>>
): void {
- this.renderDenial(res, {
+ this.renderDenial(req, res, {
headers,
reasonPhrase: getReasonPhrase(statusCode),
status: statusCode,
}
return trustedProxies.has(`${normalizedRemoteAddress.family}:${normalizedRemoteAddress.value}`)
}
+
+/**
+ * Whether a response to `req` is conveyed over secure transport, as required
+ * before emitting `Strict-Transport-Security` (RFC 6797 §7.2 forbids the header
+ * over non-secure transport): direct TLS, or a trusted reverse proxy that
+ * forwarded a secure protocol (`https`/`wss`). The forwarded protocol is honored
+ * only from a trusted peer and only when unambiguous, mirroring the trust rules
+ * of {@link resolveUIServerAccess}; anything else is treated as non-secure.
+ * @param req - Incoming request whose transport security is evaluated.
+ * @param uiServerConfiguration - UI server configuration (trusted proxies).
+ * @param cache - Per-server access cache (reuses the normalized proxy set).
+ * @returns `true` when the response would travel over secure transport.
+ */
+export const isRequestEffectivelySecure = (
+ req: IncomingMessage,
+ uiServerConfiguration: UIServerConfiguration,
+ cache: UIServerAccessCache
+): boolean => {
+ if ((req.socket as { encrypted?: boolean }).encrypted === true) {
+ return true
+ }
+ const trustedProxies = getTrustedProxies(uiServerConfiguration, cache)
+ if (!isTrustedProxy(req.socket.remoteAddress ?? '', trustedProxies)) {
+ return false
+ }
+ const forwardedProtocol = getForwardedProtocol(req, parseSingleForwardedHeader(req))
+ return isSecureForwardedProtocol(
+ forwardedProtocol.kind === 'ok' ? forwardedProtocol.value : undefined
+ )
+}
this.httpServer.on('request', (req: IncomingMessage, res: ServerResponse): void => {
const prologue = this.runRequestPrologue(req)
if (!prologue.ok) {
- this.renderDenial(res, prologue)
+ this.renderDenial(req, res, prologue)
return
}
if (this.tryServeMetrics(req, res)) {
socket.write(
buildUpgradeRejectionResponse(
StatusCodes.BAD_REQUEST,
- getReasonPhrase(StatusCodes.BAD_REQUEST)
+ getReasonPhrase(StatusCodes.BAD_REQUEST),
+ this.getSecurityHeaders(this.isSecureRequest(req))
),
() => {
socket.destroy()
const prologue = this.runRequestPrologue(req)
if (!prologue.ok) {
socket.write(
- buildUpgradeRejectionResponse(prologue.status, prologue.reasonPhrase, prologue.headers),
+ buildUpgradeRejectionResponse(prologue.status, prologue.reasonPhrase, {
+ ...prologue.headers,
+ ...this.getSecurityHeaders(this.isSecureRequest(req)),
+ }),
() => {
socket.destroy()
}
if (!this.authenticate(req)) {
const unauthorized = this.getUnauthorizedDenial()
socket.write(
- buildUpgradeRejectionResponse(
- unauthorized.status,
- unauthorized.reasonPhrase,
- unauthorized.headers
- ),
+ buildUpgradeRejectionResponse(unauthorized.status, unauthorized.reasonPhrase, {
+ ...unauthorized.headers,
+ ...this.getSecurityHeaders(this.isSecureRequest(req)),
+ }),
() => {
socket.destroy()
}
StorageConfigurationSchema,
UIServerConfigurationSchema,
UIServerMetricsConfigurationSchema,
+ UIServerSecurityHeadersConfigurationSchema,
WorkerConfigurationSchema,
} from '../utils/index.js'
export type StorageConfiguration = z.infer<typeof StorageConfigurationSchema>
export type UIServerConfiguration = z.infer<typeof UIServerConfigurationSchema>
export type UIServerMetricsConfiguration = z.infer<typeof UIServerMetricsConfigurationSchema>
+export type UIServerSecurityHeadersConfiguration = z.infer<
+ typeof UIServerSecurityHeadersConfigurationSchema
+>
export type WorkerConfiguration = z.infer<typeof WorkerConfigurationSchema>
})
.strict()
+/**
+ * UIServerSecurityHeadersConfiguration — opt-in security response headers for
+ * the UI server (issue #1980).
+ *
+ * `strictTransportSecurity` (optional) is the `Strict-Transport-Security`
+ * (HSTS) header value. A non-empty string is emitted verbatim on every
+ * UI-server HTTP response path served over secure transport — HTTP-transport
+ * success responses, denials, WebSocket upgrade rejections, and the `/metrics`
+ * endpoint across the `http`, `ws` and `mcp` transports — but NOT on MCP
+ * JSON-RPC success bodies, which are written by the MCP SDK transport. `false`
+ * (the default) or an empty string omits the header, leaving the default
+ * response behavior unchanged. Emission is gated on secure transport (direct
+ * TLS or a trusted-proxy-forwarded `https`/`wss` protocol) per RFC 6797 §7.2,
+ * which forbids sending HSTS over non-secure transport. Recommended production
+ * value: `'max-age=31536000; includeSubDomains'`.
+ */
+export const UIServerSecurityHeadersConfigurationSchema = z
+ .object({
+ strictTransportSecurity: z.union([z.string(), z.literal(false)]).optional(),
+ })
+ .strict()
+
/**
* UIServerConfiguration — UI server configuration section.
* `options` is structurally typed as `ListenOptions` from node:net and
enabled: z.boolean().optional(),
metrics: UIServerMetricsConfigurationSchema.optional(),
options: UIServerListenOptionsSchema.optional(),
+ securityHeaders: UIServerSecurityHeadersConfigurationSchema.optional(),
type: z.enum(ApplicationProtocol).optional(),
version: z.enum(ApplicationProtocolVersion).optional(),
})
UI_SERVER_ACCESS_POLICY_DEFAULTS,
UIServerConfigurationSchema,
UIServerMetricsConfigurationSchema,
+ UIServerSecurityHeadersConfigurationSchema,
WorkerConfigurationSchema,
} from './ConfigurationSchema.js'
export {
import assert from 'node:assert/strict'
import { Readable } from 'node:stream'
-import { afterEach, beforeEach, describe, it } from 'node:test'
+import { afterEach, beforeEach, describe, it, type mock } from 'node:test'
import { gunzipSync, type Gzip } from 'node:zlib'
import type { AbstractUIService } from '../../../src/charging-station/ui-server/ui-services/AbstractUIService.js'
return [...this.responseHandlers.keys()]
}
+ public getSecureResponses (): Map<UUIDv4, boolean> {
+ return Reflect.get(this, 'secureResponses')
+ }
+
public getUIService (version: ProtocolVersion): AbstractUIService | undefined {
return this.uiServices.get(version)
}
this.getAcceptsGzip().set(uuid, value)
}
+ public setSecureResponse (uuid: UUIDv4, value: boolean): void {
+ this.getSecureResponses().set(uuid, value)
+ }
+
protected override createGzipStream (): Gzip {
// eslint-disable-next-line @typescript-eslint/no-deprecated
this.lastGzipStream = super.createGzipStream()
assert.strictEqual(errorMock.mock.calls.length, 1)
})
})
+
+ await describe('Strict-Transport-Security header (issue #1980)', async () => {
+ const HSTS_VALUE = 'max-age=31536000; includeSubDomains'
+
+ const createHstsServer = (strictTransportSecurity: false | string): TestableUIHttpServer =>
+ new TestableUIHttpServer(
+ createMockUIServerConfiguration({
+ securityHeaders: { strictTransportSecurity },
+ type: ApplicationProtocol.HTTP,
+ })
+ )
+
+ const emitDenial = (
+ t: { mock: { method: typeof mock.method } },
+ accessPolicy: UIServerConfiguration['accessPolicy'],
+ reqOverrides: Partial<IncomingMessage>
+ ): MockServerResponse => {
+ const gatedServer = new TestableUIHttpServer(
+ createMockUIServerConfiguration({
+ accessPolicy,
+ options: { host: 'localhost', port: 0 },
+ securityHeaders: { strictTransportSecurity: HSTS_VALUE },
+ type: ApplicationProtocol.HTTP,
+ })
+ )
+ const httpServer = Reflect.get(gatedServer, 'httpServer') as {
+ listen: (...args: unknown[]) => unknown
+ removeAllListeners: () => void
+ }
+ t.mock.method(httpServer, 'listen', () => httpServer)
+ const req = createMockIncomingMessage({
+ complete: true,
+ url: '/ui/ui0.0.1/listChargingStations',
+ ...reqOverrides,
+ })
+ const res = new MockServerResponse()
+ try {
+ gatedServer.start()
+ gatedServer.emitRequest(req, res)
+ } finally {
+ httpServer.removeAllListeners()
+ gatedServer.stop()
+ }
+ return res
+ }
+
+ await it('should emit the configured HSTS value on a secure success response', () => {
+ const hstsServer = createHstsServer(HSTS_VALUE)
+ const res = new MockServerResponse()
+
+ hstsServer.addResponseHandler(TEST_UUID, res)
+ hstsServer.setSecureResponse(TEST_UUID, true)
+ hstsServer.sendResponse([TEST_UUID, { status: ResponseStatus.SUCCESS }])
+
+ assert.strictEqual(res.headers['Strict-Transport-Security'], HSTS_VALUE)
+ })
+
+ await it('should emit the configured HSTS value on a secure gzip-compressed success response', async () => {
+ const hstsServer = createHstsServer(HSTS_VALUE)
+ const res = new MockServerResponse()
+
+ hstsServer.addResponseHandler(TEST_UUID, res)
+ hstsServer.setAcceptsGzip(TEST_UUID, true)
+ hstsServer.setSecureResponse(TEST_UUID, true)
+ hstsServer.sendResponse([TEST_UUID, createLargePayload()])
+
+ await awaitFinish(res)
+
+ assert.strictEqual(res.headers['Content-Encoding'], 'gzip')
+ assert.strictEqual(res.headers['Strict-Transport-Security'], HSTS_VALUE)
+ })
+
+ await it('should omit the HSTS header on a non-secure (plaintext) success response', () => {
+ const hstsServer = createHstsServer(HSTS_VALUE)
+ const res = new MockServerResponse()
+
+ hstsServer.addResponseHandler(TEST_UUID, res)
+ hstsServer.setSecureResponse(TEST_UUID, false)
+ hstsServer.sendResponse([TEST_UUID, { status: ResponseStatus.SUCCESS }])
+
+ assert.strictEqual(res.headers['Strict-Transport-Security'], undefined)
+ })
+
+ await it('should emit the configured HSTS value on a secure success response over a trusted-proxy https connection', async () => {
+ const proxyServer = new TestableUIHttpServer(
+ createMockUIServerConfiguration({
+ accessPolicy: {
+ allowedHosts: ['gateway.example.com'],
+ allowedOrigins: [],
+ allowLoopbackProxy: false,
+ requireTlsForNonLoopback: true,
+ trustedProxies: ['203.0.113.10'],
+ },
+ options: { host: 'localhost', port: 0 },
+ securityHeaders: { strictTransportSecurity: HSTS_VALUE },
+ type: ApplicationProtocol.HTTP,
+ })
+ )
+ proxyServer.mockListen()
+ const req = Readable.from([Buffer.from('{}')]) as unknown as IncomingMessage
+ Object.assign(req, {
+ complete: true,
+ headers: { host: 'gateway.example.com', 'x-forwarded-proto': 'https' },
+ headersDistinct: {},
+ method: 'POST',
+ rawHeaders: [],
+ socket: { encrypted: false, remoteAddress: '203.0.113.10' },
+ url: `/ui/${ProtocolVersion['0.0.1']}/listChargingStations`,
+ })
+ const res = new MockServerResponse()
+
+ try {
+ proxyServer.start()
+ proxyServer.emitRequest(req, res)
+ await awaitFinish(res)
+ } finally {
+ proxyServer.stop()
+ }
+
+ assert.strictEqual(res.statusCode, 200)
+ assert.strictEqual(res.headers['Strict-Transport-Security'], HSTS_VALUE)
+ })
+
+ await it('should emit the configured HSTS value on a denial over a direct TLS connection', t => {
+ const res = emitDenial(
+ t,
+ { allowedHosts: ['gateway.example.com'], requireTlsForNonLoopback: true },
+ {
+ headers: { host: 'not-allowed.example.com' },
+ socket: { encrypted: true, remoteAddress: '203.0.113.10' } as never,
+ }
+ )
+
+ assert.strictEqual(res.statusCode, 403)
+ assert.strictEqual(res.headers['Strict-Transport-Security'], HSTS_VALUE)
+ })
+
+ await it('should emit the configured HSTS value on a denial over a trusted-proxy https connection', t => {
+ const res = emitDenial(
+ t,
+ {
+ allowedHosts: ['gateway.example.com'],
+ requireTlsForNonLoopback: true,
+ trustedProxies: ['203.0.113.10'],
+ },
+ {
+ headers: { host: 'not-allowed.example.com', 'x-forwarded-proto': 'https' },
+ socket: { encrypted: false, remoteAddress: '203.0.113.10' } as never,
+ }
+ )
+
+ assert.strictEqual(res.statusCode, 403)
+ assert.strictEqual(res.headers['Strict-Transport-Security'], HSTS_VALUE)
+ })
+
+ await it('should omit the HSTS header on a denial over a non-secure (plaintext) connection', t => {
+ const res = emitDenial(
+ t,
+ { allowedHosts: ['gateway.example.com'], requireTlsForNonLoopback: true },
+ {
+ headers: { host: 'gateway.example.com' },
+ socket: { encrypted: false, remoteAddress: '203.0.113.10' } as never,
+ }
+ )
+
+ assert.strictEqual(res.statusCode, 403)
+ assert.strictEqual(res.headers['Strict-Transport-Security'], undefined)
+ })
+
+ await it('should omit the HSTS header by default (unset) even over secure transport', () => {
+ const res = new MockServerResponse()
+
+ server.addResponseHandler(TEST_UUID, res)
+ server.setSecureResponse(TEST_UUID, true)
+ server.sendResponse([TEST_UUID, { status: ResponseStatus.SUCCESS }])
+
+ assert.strictEqual(res.headers['Strict-Transport-Security'], undefined)
+ })
+
+ await it('should omit the HSTS header when configured false even over secure transport', () => {
+ const hstsServer = createHstsServer(false)
+ const res = new MockServerResponse()
+
+ hstsServer.addResponseHandler(TEST_UUID, res)
+ hstsServer.setSecureResponse(TEST_UUID, true)
+ hstsServer.sendResponse([TEST_UUID, { status: ResponseStatus.SUCCESS }])
+
+ assert.strictEqual(res.headers['Strict-Transport-Security'], undefined)
+ })
+
+ await it('should omit the HSTS header when configured empty string even over secure transport', () => {
+ const hstsServer = createHstsServer('')
+ const res = new MockServerResponse()
+
+ hstsServer.addResponseHandler(TEST_UUID, res)
+ hstsServer.setSecureResponse(TEST_UUID, true)
+ hstsServer.sendResponse([TEST_UUID, { status: ResponseStatus.SUCCESS }])
+
+ assert.strictEqual(res.headers['Strict-Transport-Security'], undefined)
+ })
+ })
})
}
public callSendErrorResponse (
+ req: IncomingMessage,
res: ServerResponse,
statusCode: StatusCodes,
headers?: Readonly<Record<string, string>>
): void {
;(
Reflect.get(this, 'sendErrorResponse') as (
+ req: IncomingMessage,
res: ServerResponse,
statusCode: StatusCodes,
headers?: Readonly<Record<string, string>>
) => void
- ).call(this, res, statusCode, headers)
+ ).call(this, req, res, statusCode, headers)
}
public emitRequest (req: IncomingMessage, res: MockServerResponse): void {
const res = new MockServerResponse()
gatedServer.callSendErrorResponse(
+ createMockIncomingMessage({
+ socket: { encrypted: false, remoteAddress: '127.0.0.1' } as never,
+ }),
res as unknown as ServerResponse,
StatusCodes.METHOD_NOT_ALLOWED,
{ Allow: 'GET, POST, DELETE' }
import {
createUIServerAccessCache,
+ isRequestEffectivelySecure,
resolveUIServerAccess,
type UIServerAccessDecision,
UIServerAccessDenialReason,
createGatewayConfigWithoutTrustedProxies,
createGatewayConfigWithTrustedProxy,
createMockUIServerConfiguration,
+ GATEWAY_HOST,
+ TRUSTED_PROXY_IP,
} from './UIServerTestUtils.js'
await describe('UIServerAccessPolicy', async () => {
assert.strictEqual(cacheB.decisions.has(req), false)
})
})
+
+ await describe('isRequestEffectivelySecure', async () => {
+ const isSecure = (req: IncomingMessage, config: UIServerConfiguration): boolean =>
+ isRequestEffectivelySecure(req, config, createUIServerAccessCache())
+
+ await it('should treat a direct TLS socket as secure', () => {
+ assert.strictEqual(
+ isSecure(
+ createAccessPolicyRequest({ encrypted: true, remoteAddress: '203.0.113.10' }),
+ createAccessPolicyConfiguration()
+ ),
+ true
+ )
+ })
+
+ await it('should treat trusted-proxy forwarded https as secure', () => {
+ assert.strictEqual(
+ isSecure(
+ createAccessPolicyRequest({
+ headers: { host: GATEWAY_HOST, 'x-forwarded-proto': 'https' },
+ remoteAddress: TRUSTED_PROXY_IP,
+ }),
+ createGatewayConfigWithTrustedProxy()
+ ),
+ true
+ )
+ })
+
+ await it('should treat trusted-proxy forwarded wss as secure', () => {
+ assert.strictEqual(
+ isSecure(
+ createAccessPolicyRequest({
+ headers: { host: GATEWAY_HOST, 'x-forwarded-proto': 'wss' },
+ remoteAddress: TRUSTED_PROXY_IP,
+ }),
+ createGatewayConfigWithTrustedProxy()
+ ),
+ true
+ )
+ })
+
+ await it('should treat trusted-proxy forwarded http as non-secure', () => {
+ assert.strictEqual(
+ isSecure(
+ createAccessPolicyRequest({
+ headers: { host: GATEWAY_HOST, 'x-forwarded-proto': 'http' },
+ remoteAddress: TRUSTED_PROXY_IP,
+ }),
+ createGatewayConfigWithTrustedProxy()
+ ),
+ false
+ )
+ })
+
+ await it('should ignore forwarded https from an untrusted peer', () => {
+ assert.strictEqual(
+ isSecure(
+ createAccessPolicyRequest({
+ headers: { host: GATEWAY_HOST, 'x-forwarded-proto': 'https' },
+ remoteAddress: '203.0.113.10',
+ }),
+ createGatewayConfigWithoutTrustedProxies()
+ ),
+ false
+ )
+ })
+
+ await it('should treat an ambiguous multi-value forwarded protocol from a trusted proxy as non-secure', () => {
+ assert.strictEqual(
+ isSecure(
+ createAccessPolicyRequest({
+ headers: { host: GATEWAY_HOST, 'x-forwarded-proto': 'https, http' },
+ remoteAddress: TRUSTED_PROXY_IP,
+ }),
+ createGatewayConfigWithTrustedProxy()
+ ),
+ false
+ )
+ })
+
+ await it('should treat conflicting X-Forwarded-Proto and Forwarded protocols from a trusted proxy as non-secure', () => {
+ assert.strictEqual(
+ isSecure(
+ createAccessPolicyRequest({
+ headers: {
+ forwarded: 'proto=http',
+ host: GATEWAY_HOST,
+ 'x-forwarded-proto': 'https',
+ },
+ remoteAddress: TRUSTED_PROXY_IP,
+ }),
+ createGatewayConfigWithTrustedProxy()
+ ),
+ false
+ )
+ })
+
+ await it('should treat plaintext loopback as non-secure', () => {
+ assert.strictEqual(
+ isSecure(
+ createAccessPolicyRequest({ headers: { host: 'localhost:8080' } }),
+ createAccessPolicyConfiguration()
+ ),
+ false
+ )
+ })
+
+ await it('should treat a trusted proxy without forwarded protocol as non-secure', () => {
+ assert.strictEqual(
+ isSecure(
+ createAccessPolicyRequest({
+ headers: { host: GATEWAY_HOST },
+ remoteAddress: TRUSTED_PROXY_IP,
+ }),
+ createGatewayConfigWithTrustedProxy()
+ ),
+ false
+ )
+ })
+ })
})
assert.match(response, /Content-Length: 0/)
})
+ await it('should emit the configured HSTS value on a secure (direct TLS) upgrade rejection (issue #1980)', async () => {
+ const config = createMockUIServerConfiguration({
+ accessPolicy: {
+ allowedHosts: ['gateway.example.com'],
+ requireTlsForNonLoopback: true,
+ },
+ options: {
+ host: 'localhost',
+ port: 0,
+ },
+ securityHeaders: { strictTransportSecurity: 'max-age=31536000; includeSubDomains' },
+ })
+ const server = new TestableUIWebSocketServer(config)
+ const socket = new MockUpgradeSocket()
+
+ try {
+ server.start()
+ await server.waitUntilListening()
+ server.emitUpgrade(
+ createMockIncomingMessage({
+ headers: {
+ connection: 'Upgrade',
+ host: 'not-allowed.example.com',
+ upgrade: 'websocket',
+ },
+ socket: { encrypted: true, 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, /Strict-Transport-Security: max-age=31536000; includeSubDomains/)
+ })
+
+ await it('should omit the HSTS header on a non-secure (plaintext) upgrade rejection (issue #1980)', async () => {
+ const config = createMockUIServerConfiguration({
+ accessPolicy: {
+ allowedHosts: ['gateway.example.com'],
+ requireTlsForNonLoopback: true,
+ },
+ options: {
+ host: 'localhost',
+ port: 0,
+ },
+ securityHeaders: { strictTransportSecurity: 'max-age=31536000; includeSubDomains' },
+ })
+ 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.doesNotMatch(response, /Strict-Transport-Security/)
+ })
+
await it('should include the Retry-After header on rate-limited upgrades', async () => {
const config = createMockUIServerConfiguration({
accessPolicy: {
}
})
+ await it('should emit the configured HSTS value on a secure metrics success response (issue #1980)', async t => {
+ const server = new TestableUIWebSocketServer(
+ createWsMetricsConfig({
+ securityHeaders: { strictTransportSecurity: 'max-age=31536000; includeSubDomains' },
+ })
+ )
+ enrichBootstrapForMetrics(server)
+ server.mockListen(t)
+ try {
+ server.start()
+ const res = new MockServerResponse()
+ server.emitRequest(
+ buildWsMetricsRequest({
+ socket: { encrypted: true, remoteAddress: '127.0.0.1' } as never,
+ }),
+ res
+ )
+ await awaitFinish(res)
+ assert.strictEqual(res.statusCode, 200)
+ assert.strictEqual(
+ res.headers['Strict-Transport-Security'],
+ 'max-age=31536000; includeSubDomains'
+ )
+ } finally {
+ server.stop()
+ }
+ })
+
+ await it('should emit the configured HSTS value on a secure HEAD metrics response (issue #1980)', async t => {
+ const server = new TestableUIWebSocketServer(
+ createWsMetricsConfig({
+ securityHeaders: { strictTransportSecurity: 'max-age=31536000; includeSubDomains' },
+ })
+ )
+ enrichBootstrapForMetrics(server)
+ server.mockListen(t)
+ try {
+ server.start()
+ const res = new MockServerResponse()
+ server.emitRequest(
+ buildWsMetricsRequest({
+ method: 'HEAD',
+ socket: { encrypted: true, remoteAddress: '127.0.0.1' } as never,
+ }),
+ res
+ )
+ await awaitFinish(res)
+ assert.strictEqual(res.statusCode, 200)
+ assert.strictEqual(
+ res.headers['Strict-Transport-Security'],
+ 'max-age=31536000; includeSubDomains'
+ )
+ } finally {
+ server.stop()
+ }
+ })
+
+ await it('should omit the HSTS header on a non-secure (plaintext) metrics success response (issue #1980)', async t => {
+ const server = new TestableUIWebSocketServer(
+ createWsMetricsConfig({
+ securityHeaders: { strictTransportSecurity: 'max-age=31536000; includeSubDomains' },
+ })
+ )
+ enrichBootstrapForMetrics(server)
+ server.mockListen(t)
+ try {
+ server.start()
+ const res = new MockServerResponse()
+ server.emitRequest(buildWsMetricsRequest(), res)
+ await awaitFinish(res)
+ assert.strictEqual(res.statusCode, 200)
+ assert.strictEqual(res.headers['Strict-Transport-Security'], undefined)
+ } finally {
+ server.stop()
+ }
+ })
+
+ await it('should emit the configured HSTS value on a secure metrics 500 response (issue #1980)', async t => {
+ const server = new TestableUIWebSocketServer(
+ createWsMetricsConfig({
+ securityHeaders: { strictTransportSecurity: 'max-age=31536000; includeSubDomains' },
+ })
+ )
+ enrichBootstrapForMetrics(server)
+ server.mockListen(t)
+ try {
+ server.start()
+ const registry = Reflect.get(server, 'metricsRegistry') as {
+ metrics: () => Promise<string>
+ }
+ t.mock.method(registry, 'metrics', () => Promise.reject(new Error('scrape failure')))
+ const res = new MockServerResponse()
+ server.emitRequest(
+ buildWsMetricsRequest({
+ socket: { encrypted: true, remoteAddress: '127.0.0.1' } as never,
+ }),
+ res
+ )
+ await awaitFinish(res)
+ assert.strictEqual(res.statusCode, 500)
+ assert.strictEqual(
+ res.headers['Strict-Transport-Security'],
+ 'max-age=31536000; includeSubDomains'
+ )
+ } finally {
+ server.stop()
+ }
+ })
+
+ await it('should omit the HSTS header on a non-secure (plaintext) metrics 500 response (issue #1980)', async t => {
+ const server = new TestableUIWebSocketServer(
+ createWsMetricsConfig({
+ securityHeaders: { strictTransportSecurity: 'max-age=31536000; includeSubDomains' },
+ })
+ )
+ enrichBootstrapForMetrics(server)
+ server.mockListen(t)
+ try {
+ server.start()
+ const registry = Reflect.get(server, 'metricsRegistry') as {
+ metrics: () => Promise<string>
+ }
+ t.mock.method(registry, 'metrics', () => Promise.reject(new Error('scrape failure')))
+ const res = new MockServerResponse()
+ server.emitRequest(buildWsMetricsRequest(), res)
+ await awaitFinish(res)
+ assert.strictEqual(res.statusCode, 500)
+ assert.strictEqual(res.headers['Strict-Transport-Security'], undefined)
+ } finally {
+ server.stop()
+ }
+ })
+
await it('should return 404 on GET /metrics when metrics.enabled=false (default)', t => {
const server = new TestableUIWebSocketServer(
createMockUIServerConfiguration({
assert.ok(result.error.issues.some(i => i.path.join('.').includes('uiServer.accessPolicy')))
})
+ await it('should accept valid uiServer security headers', () => {
+ for (const strictTransportSecurity of ['max-age=31536000; includeSubDomains', false]) {
+ const result = ConfigurationSchema.safeParse(
+ buildMinimalConfiguration({
+ uiServer: {
+ enabled: true,
+ securityHeaders: { strictTransportSecurity },
+ type: 'ws',
+ },
+ })
+ )
+ assert.ok(result.success)
+ }
+ })
+
+ await it('should reject unknown key in uiServer security headers', () => {
+ const result = ConfigurationSchema.safeParse(
+ buildMinimalConfiguration({
+ uiServer: {
+ securityHeaders: {
+ unknownSecurityHeadersKey: true,
+ },
+ },
+ })
+ )
+ assert.ok(!result.success)
+ assert.ok(
+ result.error.issues.some(i => i.path.join('.').includes('uiServer.securityHeaders'))
+ )
+ })
+
+ await it('should reject a non-string non-false strictTransportSecurity value', () => {
+ const result = ConfigurationSchema.safeParse(
+ buildMinimalConfiguration({
+ uiServer: {
+ securityHeaders: {
+ strictTransportSecurity: true,
+ },
+ },
+ })
+ )
+ assert.ok(!result.success)
+ assert.ok(
+ result.error.issues.some(i =>
+ i.path.join('.').includes('uiServer.securityHeaders.strictTransportSecurity')
+ )
+ )
+ })
+
await it('should reject misplaced access policy under uiServer options', () => {
const result = ConfigurationSchema.safeParse(
buildMinimalConfiguration({