]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
feat(ui-server): add opt-in HSTS response header (#1980) (#2039)
authorJérôme Benoit <jerome.benoit@piment-noir.org>
Mon, 20 Jul 2026 10:52:13 +0000 (12:52 +0200)
committerGitHub <noreply@github.com>
Mon, 20 Jul 2026 10:52:13 +0000 (12:52 +0200)
Add an opt-in `uiServer.securityHeaders.strictTransportSecurity` config
knob (`string | false`) that, when set to a non-empty string, emits a
`Strict-Transport-Security` (HSTS) response header on the UI server.

Emission is gated on secure transport per RFC 6797 §7.2 (which forbids
sending HSTS over non-secure transport): the header is emitted only when
the response travels over direct TLS (`req.socket.encrypted`) or a trusted
reverse proxy that forwarded a secure protocol (`https`/`wss`), resolved by
a new `isRequestEffectivelySecure` predicate that reuses the access-policy
trusted-proxy/forwarded-protocol machinery. Over plaintext (e.g. loopback
development) the header is omitted.

The header is spread — via `getSecurityHeaders(isSecure)` and, for the
async HTTP success path, a per-uuid `secureResponses` map mirroring
`acceptsGzip` — across every UI-server HTTP response path that this code
writes: shared denials (`renderDenial`, now request-aware), HTTP success
responses (gzip and non-gzip), WebSocket upgrade rejections, and the
`/metrics` success and error responses, on the http/ws/mcp transports. MCP
JSON-RPC success bodies are written by the MCP SDK transport and are not
covered.

Mirrors the `metrics` opt-in sub-schema: one `.strict()` leaf schema, the
derived `z.infer` type, and header spreads. No new dependency; no OCPP
PDU/message-format change (HTTP transport header only).

The header is absent by default (knob unset, `false`, or empty string) and
over non-secure transport, so the default response behavior is unchanged.
Recommended production value behind a TLS-terminating reverse proxy or
native TLS: "max-age=31536000; includeSubDomains".

Closes only slice C of #1980; the identity-aware proxy mode, local-interface
spoofing guard, security audit CLI, and dangerously* naming slices remain open.

15 files changed:
README.md
cspell.config.yaml
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
src/charging-station/ui-server/UIWebSocketServer.ts
src/types/ConfigurationData.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
tests/charging-station/ui-server/UIWebSocketServer.test.ts
tests/utils/ConfigurationSchema.test.ts

index ff21eae36a52bb30cc6d2d0fd8977e9a62f8711e..e4744b351676f96537867ec61a03449cce6e0ab2 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 />"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 />&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<br />- _metrics_: opt-in Prometheus `/metrics` endpoint (served on the configured `uiServer.type` listener — `http`, `ws` or `mcp`):<br />&nbsp;&nbsp;- _enabled_: enable the `/metrics` endpoint<br />&nbsp;&nbsp;- _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 />&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<br />- _metrics_: opt-in Prometheus `/metrics` endpoint (served on the configured `uiServer.type` listener — `http`, `ws` or `mcp`):<br />&nbsp;&nbsp;- _enabled_: enable the `/metrics` endpoint<br />&nbsp;&nbsp;- _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 />&nbsp;&nbsp;- _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
 
index 5db4c0631d14040dbcf6ae30ddc54bc2cacfcb0a..a56b7c4db14f37b92a73a10746c96c49a8423950 100644 (file)
@@ -104,3 +104,6 @@ words:
   - emerg
   - REMAPPINGS
   - interruptible
+  - HSTS
+  - Hsts
+  - hsts
index ba651581597fb32426138bac8e6e39d2126518ca..635f6ac67d51d550ae15b5814430ac26398f2cd5 100644 (file)
@@ -38,6 +38,7 @@ import {
 import { UIServiceFactory } from './ui-services/UIServiceFactory.js'
 import {
   createUIServerAccessCache,
+  isRequestEffectivelySecure,
   resolveUIServerAccess,
   type UIServerAccessCache,
   type UIServerAccessDecision,
@@ -592,6 +593,27 @@ export abstract class AbstractUIServer {
     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
@@ -627,7 +649,7 @@ export abstract class AbstractUIServer {
   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,
       })
@@ -639,7 +661,12 @@ export abstract class AbstractUIServer {
         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()
       }
     })
   }
@@ -657,6 +684,17 @@ export abstract class AbstractUIServer {
     }
   }
 
+  /**
+   * 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
   }
@@ -668,6 +706,7 @@ export abstract class AbstractUIServer {
   }
 
   protected renderDenial (
+    req: IncomingMessage,
     res: ServerResponse,
     payload: {
       headers?: Readonly<Record<string, string>>
@@ -680,6 +719,7 @@ export abstract class AbstractUIServer {
       .writeHead(payload.status, {
         'Content-Type': 'text/plain',
         ...payload.headers,
+        ...this.getSecurityHeaders(this.isSecureRequest(req)),
         ...this.getConnectionCloseHeader(),
       })
       .end(`${payload.status.toString()} ${payload.reasonPhrase}`)
@@ -696,7 +736,7 @@ export abstract class AbstractUIServer {
    * @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,
     })
@@ -785,7 +825,7 @@ export abstract class AbstractUIServer {
       return false
     }
     if (!this.authenticate(req)) {
-      this.renderDenial(res, this.getUnauthorizedDenial())
+      this.renderDenial(req, res, this.getUnauthorizedDenial())
       return true
     }
     this.handleMetricsHttpRequest(req, res)
@@ -1346,6 +1386,7 @@ export abstract class AbstractUIServer {
             .writeHead(StatusCodes.OK, {
               'Content-Length': contentLength,
               'Content-Type': registry.contentType,
+              ...this.getSecurityHeaders(this.isSecureRequest(req)),
             })
             .end(isHead ? undefined : rawBody)
         }
index ca119b5f52c856393c983afbd069cdf6bf1a04e9..3ec2ea9e235c7bb83022f243e74710614530d9da 100644 (file)
@@ -43,12 +43,15 @@ export class UIHttpServer extends AbstractUIServer {
 
   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 {
@@ -74,6 +77,7 @@ export class UIHttpServer extends AbstractUIServer {
             '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) => {
@@ -93,6 +97,7 @@ export class UIHttpServer extends AbstractUIServer {
           res
             .writeHead(this.responseStatusToStatusCode(payload.status), {
               'Content-Type': 'application/json',
+              ...this.getSecurityHeaders(this.secureResponses.get(uuid) === true),
             })
             .end(body)
         }
@@ -109,6 +114,7 @@ export class UIHttpServer extends AbstractUIServer {
     } finally {
       this.responseHandlers.delete(uuid)
       this.acceptsGzip.delete(uuid)
+      this.secureResponses.delete(uuid)
     }
   }
 
@@ -170,14 +176,14 @@ export class UIHttpServer extends AbstractUIServer {
   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
     }
 
@@ -185,9 +191,11 @@ export class UIHttpServer extends AbstractUIServer {
     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 {
@@ -226,7 +234,7 @@ export class UIHttpServer extends AbstractUIServer {
 
       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,
           })
index 71168e5e862a7a6d75dbf15a20e112f472b9386c..bde72eaceeed9c5a8c967f6cabe8736f2a723a7e 100644 (file)
@@ -129,7 +129,7 @@ export class UIMCPServer extends AbstractUIServer {
     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)) {
@@ -142,7 +142,7 @@ export class UIMCPServer extends AbstractUIServer {
       }
 
       if (!this.authenticate(req)) {
-        this.renderDenial(res, this.getUnauthorizedDenial())
+        this.renderDenial(req, res, this.getUnauthorizedDenial())
         return
       }
 
@@ -263,7 +263,7 @@ export class UIMCPServer extends AbstractUIServer {
     } 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
     }
 
@@ -275,7 +275,7 @@ export class UIMCPServer extends AbstractUIServer {
         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',
         })
       }
@@ -284,6 +284,7 @@ export class UIMCPServer extends AbstractUIServer {
       cleanup()
       const isBadRequest = error instanceof SyntaxError || error instanceof PayloadTooLargeError
       this.sendErrorResponse(
+        req,
         res,
         isBadRequest ? StatusCodes.BAD_REQUEST : StatusCodes.INTERNAL_SERVER_ERROR
       )
@@ -459,11 +460,12 @@ export class UIMCPServer extends AbstractUIServer {
   }
 
   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,
index 39414951138f7b30245ea74e37626a4e8b086f7a..03f47ca09df7869e558066e8b0127da2a8f5c3d1 100644 (file)
@@ -517,3 +517,33 @@ const isTrustedProxy = (remoteAddress: string, trustedProxies: ReadonlySet<strin
   }
   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
+  )
+}
index b33790a664343459cce3eafcedfbd2a163ed7abc..70ab18406f72b0a06f6c7c065deb2844eabd0b18 100644 (file)
@@ -203,7 +203,7 @@ export class UIWebSocketServer extends AbstractUIServer {
     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)) {
@@ -229,7 +229,8 @@ export class UIWebSocketServer extends AbstractUIServer {
         socket.write(
           buildUpgradeRejectionResponse(
             StatusCodes.BAD_REQUEST,
-            getReasonPhrase(StatusCodes.BAD_REQUEST)
+            getReasonPhrase(StatusCodes.BAD_REQUEST),
+            this.getSecurityHeaders(this.isSecureRequest(req))
           ),
           () => {
             socket.destroy()
@@ -241,7 +242,10 @@ export class UIWebSocketServer extends AbstractUIServer {
       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()
           }
@@ -252,11 +256,10 @@ export class UIWebSocketServer extends AbstractUIServer {
       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()
           }
index 425f57c34229ef611b3eb7c2f492a44c07dbac27..e1638a8c6afda582d7d46d99a081958a5c90b47b 100644 (file)
@@ -7,6 +7,7 @@ import type {
   StorageConfigurationSchema,
   UIServerConfigurationSchema,
   UIServerMetricsConfigurationSchema,
+  UIServerSecurityHeadersConfigurationSchema,
   WorkerConfigurationSchema,
 } from '../utils/index.js'
 
@@ -34,4 +35,7 @@ export type StationTemplateUrl = z.infer<typeof StationTemplateUrlSchema>
 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>
index 040ec6d6499c60bfaa6e7d3a0a52385a5cbab7a7..4a03d73d7f736d5424f647c60ee2222f19ae3657 100644 (file)
@@ -225,6 +225,28 @@ export const UIServerMetricsConfigurationSchema = z
   })
   .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
@@ -238,6 +260,7 @@ export const UIServerConfigurationSchema = z
     enabled: z.boolean().optional(),
     metrics: UIServerMetricsConfigurationSchema.optional(),
     options: UIServerListenOptionsSchema.optional(),
+    securityHeaders: UIServerSecurityHeadersConfigurationSchema.optional(),
     type: z.enum(ApplicationProtocol).optional(),
     version: z.enum(ApplicationProtocolVersion).optional(),
   })
index 02d1f1cdba94f707c6c9af8a8ba5a6afeec62265..eba731ef408737bfba2108f90c3cf3293fd6194c 100644 (file)
@@ -21,6 +21,7 @@ export {
   UI_SERVER_ACCESS_POLICY_DEFAULTS,
   UIServerConfigurationSchema,
   UIServerMetricsConfigurationSchema,
+  UIServerSecurityHeadersConfigurationSchema,
   WorkerConfigurationSchema,
 } from './ConfigurationSchema.js'
 export {
index f4e4f979781b6adddb06b7405b57ca66efc0b360..0874a3f83e74ee5e84fc9b76434f06c4a972fa76 100644 (file)
@@ -7,7 +7,7 @@ import type { IncomingMessage } from 'node:http'
 
 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'
@@ -75,6 +75,10 @@ class TestableUIHttpServer extends UIHttpServer {
     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)
   }
@@ -90,6 +94,10 @@ class TestableUIHttpServer extends UIHttpServer {
     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()
@@ -725,4 +733,205 @@ await describe('UIHttpServer', async () => {
       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)
+    })
+  })
 })
index bc5493fa7a949d768724d86436835104e8bca54b..cff3c0945a12a93965602d0cf72bda0cfb345ae3 100644 (file)
@@ -112,17 +112,19 @@ class TestableUIMCPServer extends UIMCPServer {
   }
 
   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 {
@@ -338,6 +340,9 @@ await describe('UIMCPServer', async () => {
       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' }
index dcf26fdc701e1e03aa48db0e23bde358a299b1ea..fe6add8000019654f2e29d867617b03b4b568ffe 100644 (file)
@@ -12,6 +12,7 @@ import { afterEach, describe, it } from 'node:test'
 
 import {
   createUIServerAccessCache,
+  isRequestEffectivelySecure,
   resolveUIServerAccess,
   type UIServerAccessDecision,
   UIServerAccessDenialReason,
@@ -22,6 +23,8 @@ import {
   createGatewayConfigWithoutTrustedProxies,
   createGatewayConfigWithTrustedProxy,
   createMockUIServerConfiguration,
+  GATEWAY_HOST,
+  TRUSTED_PROXY_IP,
 } from './UIServerTestUtils.js'
 
 await describe('UIServerAccessPolicy', async () => {
@@ -1341,4 +1344,124 @@ 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
+      )
+    })
+  })
 })
index 3504cc97184c6d5622deaea8e48f81226c01db1d..fc06e7d7e14c829b4a068e444a0860999f104775 100644 (file)
@@ -367,6 +367,84 @@ await describe('UIWebSocketServer', async () => {
     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: {
@@ -661,6 +739,139 @@ await describe('UIWebSocketServer', async () => {
       }
     })
 
+    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({
index fc9047a2dc81a4fa099a8d58eac42f0f0b8a8a5c..8295927579ece14e726b4d4cf69b37faa2de6ef6 100644 (file)
@@ -231,6 +231,55 @@ await describe('ConfigurationSchema', async () => {
       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({