From 74fae39fd81f138dd9b68c31157018b7813b527c Mon Sep 17 00:00:00 2001 From: =?utf8?q?J=C3=A9r=C3=B4me=20Benoit?= Date: Mon, 20 Jul 2026 12:52:13 +0200 Subject: [PATCH] feat(ui-server): add opt-in HSTS response header (#1980) (#2039) MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit 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. --- README.md | 22 +- cspell.config.yaml | 3 + .../ui-server/AbstractUIServer.ts | 49 +++- .../ui-server/UIHttpServer.ts | 14 +- src/charging-station/ui-server/UIMCPServer.ts | 12 +- .../ui-server/UIServerAccessPolicy.ts | 30 +++ .../ui-server/UIWebSocketServer.ts | 19 +- src/types/ConfigurationData.ts | 4 + src/utils/ConfigurationSchema.ts | 23 ++ src/utils/index.ts | 1 + .../ui-server/UIHttpServer.test.ts | 211 +++++++++++++++++- .../ui-server/UIMCPServer.test.ts | 7 +- .../ui-server/UIServerAccessPolicy.test.ts | 123 ++++++++++ .../ui-server/UIWebSocketServer.test.ts | 211 ++++++++++++++++++ tests/utils/ConfigurationSchema.test.ts | 49 ++++ 15 files changed, 745 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index ff21eae3..e4744b35 100644 --- a/README.md +++ b/README.md @@ -173,17 +173,17 @@ But the modifications to test have to be done to the files in the build target d **src/assets/config.json**: -| Key | Value(s) | Default Value | Value type | Description | -| -------------------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| $schemaVersion | 1 | 1 | integer | Configuration schema version. Set to 1. Files without this field are migrated from v0; deprecated keys are remapped on every load. | -| supervisionUrls | | [] | string \| string[] | string or strings array containing global connection URIs to OCPP-J servers | -| supervisionUrlDistribution | round-robin/random/charging-station-affinity | charging-station-affinity | string | supervision urls distribution policy to simulated charging stations | -| log | | {
"enabled": true,
"file": "logs/combined.log",
"errorFile": "logs/error.log",
"statisticsInterval": 60,
"level": "info",
"console": false,
"format": "simple",
"rotate": true
} | {
enabled?: boolean;
file?: string;
errorFile?: string;
statisticsInterval?: number;
level?: string;
console?: boolean;
format?: string;
rotate?: boolean;
maxFiles?: string \| number;
maxSize?: string \| number;
} | Log configuration section:
- _enabled_: enable logging
- _file_: log file relative path
- _errorFile_: error log file relative path
- _statisticsInterval_: seconds between charging stations statistics output in the logs
- _level_: emerg/alert/crit/error/warning/notice/info/debug [winston](https://github.com/winstonjs/winston) logging level
- _console_: output logs on the console
- _format_: [winston](https://github.com/winstonjs/winston) log format
- _rotate_: enable daily log files rotation
- _maxFiles_: maximum number of log files: https://github.com/winstonjs/winston-daily-rotate-file#options
- _maxSize_: maximum size of log files in bytes, or units of kb, mb, and gb: https://github.com/winstonjs/winston-daily-rotate-file#options | -| worker | | {
"processType": "workerSet",
"startDelay": 500,
"elementAddDelay": 0,
"elementsPerWorker": 'auto',
"poolMinSize": 4,
"poolMaxSize": 16
} | {
processType?: WorkerProcessType;
startDelay?: number;
elementAddDelay?: number;
elementsPerWorker?: number \| 'auto' \| 'all';
poolMinSize?: number;
poolMaxSize?: number;
resourceLimits?: ResourceLimits;
} | Worker configuration section:
- _processType_: worker threads process type (`workerSet`/`fixedPool`/`dynamicPool`)
- _startDelay_: milliseconds to wait at worker threads startup (only for `workerSet` worker threads process type)
- _elementAddDelay_: milliseconds to wait between charging station add
- _elementsPerWorker_: number of charging stations per worker threads for the `workerSet` process type (`auto` means (number of stations) / (number of CPUs) \* 1.5 if (number of stations) > (number of CPUs), otherwise 1; `all` means a unique worker will run all charging stations)
- _poolMinSize_: worker threads pool minimum number of threads
- _poolMaxSize_: worker threads pool maximum number of threads
- _resourceLimits_: worker threads [resource limits](https://nodejs.org/api/worker_threads.html#new-workerfilename-options) object option | -| uiServer | | {
"enabled": false,
"type": "ws",
"version": "1.1",
"accessPolicy": {
"requireTlsForNonLoopback": true,
"trustedProxies": [],
"allowLoopbackProxy": false,
"allowedHosts": [],
"allowedOrigins": []
},
"options": {
"host": "localhost",
"port": 8080
}
} | {
enabled?: boolean;
type?: ApplicationProtocol;
version?: ApplicationProtocolVersion;
accessPolicy?: {
requireTlsForNonLoopback?: boolean;
trustedProxies?: string[];
allowLoopbackProxy?: boolean;
allowedHosts?: string[];
allowedOrigins?: string[];
};
options?: ServerOptions;
authentication?: {
enabled: boolean;
type: AuthenticationType;
username?: string;
password?: string;
};
metrics?: {
enabled?: boolean;
softSampleCap?: number;
};
} | UI server configuration section:
- _enabled_: enable UI server
- _type_: 'ws', 'mcp' or 'http' (deprecated)
- _version_: HTTP version '1.1' or '2.0' (ws and mcp transports only support '1.1')
- _accessPolicy_: gateway access policy. Loopback request sources are allowed in plaintext; non-loopback sources require TLS termination by a reverse proxy:
  - _requireTlsForNonLoopback_: reject non-loopback plaintext requests; the check honors `X-Forwarded-Proto` or `Forwarded: proto=` from a trusted proxy, non-loopback requests without forwarded protocol headers are denied as `tls-required`
  - _trustedProxies_: IPv4 or IPv6 literals of the immediate reverse proxies whose forwarded headers are honored (hostnames and CIDR ranges are not accepted; only single-hop forwarded chains are honored); a compromised entry can bypass per-client rate limiting by varying `X-Forwarded-For`
  - _allowLoopbackProxy_: accept forwarded headers when the immediate peer is loopback AND listed in _trustedProxies_ (e.g. `['127.0.0.1', '::1']`)
  - _allowedHosts_: explicit Host header allowlist; mitigates DNS rebinding when the UI server is exposed through a browser-facing host
  - _allowedOrigins_: explicit Origin header allowlist; when empty, the request Origin's URL hostname falls back to matching against _allowedHosts_
- _options_: node.js net module [listen options](https://nodejs.org/api/net.html#serverlistenoptions-callback)
- _authentication_: authentication type configuration section
- _metrics_: opt-in Prometheus `/metrics` endpoint (served on the configured `uiServer.type` listener — `http`, `ws` or `mcp`):
  - _enabled_: enable the `/metrics` endpoint
  - _softSampleCap_: soft cardinality cap above which a single warn is logged per scrape (default 5000) | -| performanceStorage | | {
"enabled": true,
"type": "none",
} | {
enabled?: boolean;
type?: string;
uri?: string;
} | Performance storage configuration section:
- _enabled_: enable performance storage
- _type_: 'jsonfile', 'mongodb' or 'none'
- _uri_: storage URI | -| stationTemplateUrls | | {}[] | {
file: string;
numberOfStations: number;
provisionedNumberOfStations?: number;
}[] | array of charging station templates URIs configuration section:
- _file_: charging station configuration template file relative path
- _numberOfStations_: template number of stations at startup
- _provisionedNumberOfStations_: template provisioned number of stations after startup | -| persistState | true/false | true | boolean | persist the simulator stopped state to `dist/assets/configurations/.simulator-state.json`. On the next process startup, if the simulator was stopped via the UI procedure `stopSimulator`, charging stations are not auto-spawned and the user can recover via the `startSimulator` procedure. Signal-driven shutdowns (SIGINT/SIGTERM/SIGQUIT) and configuration-reload restarts do not modify the persisted state. The feature requires `uiServer.enabled` to be `true`; otherwise it is silently ignored (no recovery channel without UI). Set the environment variable `SIMULATOR_COLD_START` 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 | | {
"enabled": true,
"file": "logs/combined.log",
"errorFile": "logs/error.log",
"statisticsInterval": 60,
"level": "info",
"console": false,
"format": "simple",
"rotate": true
} | {
enabled?: boolean;
file?: string;
errorFile?: string;
statisticsInterval?: number;
level?: string;
console?: boolean;
format?: string;
rotate?: boolean;
maxFiles?: string \| number;
maxSize?: string \| number;
} | Log configuration section:
- _enabled_: enable logging
- _file_: log file relative path
- _errorFile_: error log file relative path
- _statisticsInterval_: seconds between charging stations statistics output in the logs
- _level_: emerg/alert/crit/error/warning/notice/info/debug [winston](https://github.com/winstonjs/winston) logging level
- _console_: output logs on the console
- _format_: [winston](https://github.com/winstonjs/winston) log format
- _rotate_: enable daily log files rotation
- _maxFiles_: maximum number of log files: https://github.com/winstonjs/winston-daily-rotate-file#options
- _maxSize_: maximum size of log files in bytes, or units of kb, mb, and gb: https://github.com/winstonjs/winston-daily-rotate-file#options | +| worker | | {
"processType": "workerSet",
"startDelay": 500,
"elementAddDelay": 0,
"elementsPerWorker": 'auto',
"poolMinSize": 4,
"poolMaxSize": 16
} | {
processType?: WorkerProcessType;
startDelay?: number;
elementAddDelay?: number;
elementsPerWorker?: number \| 'auto' \| 'all';
poolMinSize?: number;
poolMaxSize?: number;
resourceLimits?: ResourceLimits;
} | Worker configuration section:
- _processType_: worker threads process type (`workerSet`/`fixedPool`/`dynamicPool`)
- _startDelay_: milliseconds to wait at worker threads startup (only for `workerSet` worker threads process type)
- _elementAddDelay_: milliseconds to wait between charging station add
- _elementsPerWorker_: number of charging stations per worker threads for the `workerSet` process type (`auto` means (number of stations) / (number of CPUs) \* 1.5 if (number of stations) > (number of CPUs), otherwise 1; `all` means a unique worker will run all charging stations)
- _poolMinSize_: worker threads pool minimum number of threads
- _poolMaxSize_: worker threads pool maximum number of threads
- _resourceLimits_: worker threads [resource limits](https://nodejs.org/api/worker_threads.html#new-workerfilename-options) object option | +| uiServer | | {
"enabled": false,
"type": "ws",
"version": "1.1",
"accessPolicy": {
"requireTlsForNonLoopback": true,
"trustedProxies": [],
"allowLoopbackProxy": false,
"allowedHosts": [],
"allowedOrigins": []
},
"options": {
"host": "localhost",
"port": 8080
}
} | {
enabled?: boolean;
type?: ApplicationProtocol;
version?: ApplicationProtocolVersion;
accessPolicy?: {
requireTlsForNonLoopback?: boolean;
trustedProxies?: string[];
allowLoopbackProxy?: boolean;
allowedHosts?: string[];
allowedOrigins?: string[];
};
options?: ServerOptions;
authentication?: {
enabled: boolean;
type: AuthenticationType;
username?: string;
password?: string;
};
metrics?: {
enabled?: boolean;
softSampleCap?: number;
};
securityHeaders?: {
strictTransportSecurity?: string \| false;
};
} | UI server configuration section:
- _enabled_: enable UI server
- _type_: 'ws', 'mcp' or 'http' (deprecated)
- _version_: HTTP version '1.1' or '2.0' (ws and mcp transports only support '1.1')
- _accessPolicy_: gateway access policy. Loopback request sources are allowed in plaintext; non-loopback sources require TLS termination by a reverse proxy:
  - _requireTlsForNonLoopback_: reject non-loopback plaintext requests; the check honors `X-Forwarded-Proto` or `Forwarded: proto=` from a trusted proxy, non-loopback requests without forwarded protocol headers are denied as `tls-required`
  - _trustedProxies_: IPv4 or IPv6 literals of the immediate reverse proxies whose forwarded headers are honored (hostnames and CIDR ranges are not accepted; only single-hop forwarded chains are honored); a compromised entry can bypass per-client rate limiting by varying `X-Forwarded-For`
  - _allowLoopbackProxy_: accept forwarded headers when the immediate peer is loopback AND listed in _trustedProxies_ (e.g. `['127.0.0.1', '::1']`)
  - _allowedHosts_: explicit Host header allowlist; mitigates DNS rebinding when the UI server is exposed through a browser-facing host
  - _allowedOrigins_: explicit Origin header allowlist; when empty, the request Origin's URL hostname falls back to matching against _allowedHosts_
- _options_: node.js net module [listen options](https://nodejs.org/api/net.html#serverlistenoptions-callback)
- _authentication_: authentication type configuration section
- _metrics_: opt-in Prometheus `/metrics` endpoint (served on the configured `uiServer.type` listener — `http`, `ws` or `mcp`):
  - _enabled_: enable the `/metrics` endpoint
  - _softSampleCap_: soft cardinality cap above which a single warn is logged per scrape (default 5000)
- _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:
  - _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 | | {
"enabled": true,
"type": "none",
} | {
enabled?: boolean;
type?: string;
uri?: string;
} | Performance storage configuration section:
- _enabled_: enable performance storage
- _type_: 'jsonfile', 'mongodb' or 'none'
- _uri_: storage URI | +| stationTemplateUrls | | {}[] | {
file: string;
numberOfStations: number;
provisionedNumberOfStations?: number;
}[] | array of charging station templates URIs configuration section:
- _file_: charging station configuration template file relative path
- _numberOfStations_: template number of stations at startup
- _provisionedNumberOfStations_: template provisioned number of stations after startup | +| persistState | true/false | true | boolean | persist the simulator stopped state to `dist/assets/configurations/.simulator-state.json`. On the next process startup, if the simulator was stopped via the UI procedure `stopSimulator`, charging stations are not auto-spawned and the user can recover via the `startSimulator` procedure. Signal-driven shutdowns (SIGINT/SIGTERM/SIGQUIT) and configuration-reload restarts do not modify the persisted state. The feature requires `uiServer.enabled` to be `true`; otherwise it is silently ignored (no recovery channel without UI). Set the environment variable `SIMULATOR_COLD_START` 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 diff --git a/cspell.config.yaml b/cspell.config.yaml index 5db4c063..a56b7c4d 100644 --- a/cspell.config.yaml +++ b/cspell.config.yaml @@ -104,3 +104,6 @@ words: - emerg - REMAPPINGS - interruptible + - HSTS + - Hsts + - hsts diff --git a/src/charging-station/ui-server/AbstractUIServer.ts b/src/charging-station/ui-server/AbstractUIServer.ts index ba651581..635f6ac6 100644 --- a/src/charging-station/ui-server/AbstractUIServer.ts +++ b/src/charging-station/ui-server/AbstractUIServer.ts @@ -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 { + const { strictTransportSecurity } = this.uiServerConfiguration.securityHeaders ?? {} + return isSecure && isNotEmptyString(strictTransportSecurity) + ? { 'Strict-Transport-Security': strictTransportSecurity } + : {} + } + protected getUnauthorizedDenial (): { headers: Readonly> 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> @@ -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) } diff --git a/src/charging-station/ui-server/UIHttpServer.ts b/src/charging-station/ui-server/UIHttpServer.ts index ca119b5f..3ec2ea9e 100644 --- a/src/charging-station/ui-server/UIHttpServer.ts +++ b/src/charging-station/ui-server/UIHttpServer.ts @@ -43,12 +43,15 @@ export class UIHttpServer extends AbstractUIServer { private readonly acceptsGzip: Map + private readonly secureResponses: Map + public constructor ( protected override readonly uiServerConfiguration: UIServerConfiguration, bootstrap: IBootstrap ) { super(uiServerConfiguration, bootstrap) this.acceptsGzip = new Map() + this.secureResponses = new Map() } 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, }) diff --git a/src/charging-station/ui-server/UIMCPServer.ts b/src/charging-station/ui-server/UIMCPServer.ts index 71168e5e..bde72eac 100644 --- a/src/charging-station/ui-server/UIMCPServer.ts +++ b/src/charging-station/ui-server/UIMCPServer.ts @@ -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> ): void { - this.renderDenial(res, { + this.renderDenial(req, res, { headers, reasonPhrase: getReasonPhrase(statusCode), status: statusCode, diff --git a/src/charging-station/ui-server/UIServerAccessPolicy.ts b/src/charging-station/ui-server/UIServerAccessPolicy.ts index 39414951..03f47ca0 100644 --- a/src/charging-station/ui-server/UIServerAccessPolicy.ts +++ b/src/charging-station/ui-server/UIServerAccessPolicy.ts @@ -517,3 +517,33 @@ const isTrustedProxy = (remoteAddress: string, trustedProxies: ReadonlySet { + 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 + ) +} diff --git a/src/charging-station/ui-server/UIWebSocketServer.ts b/src/charging-station/ui-server/UIWebSocketServer.ts index b33790a6..70ab1840 100644 --- a/src/charging-station/ui-server/UIWebSocketServer.ts +++ b/src/charging-station/ui-server/UIWebSocketServer.ts @@ -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() } diff --git a/src/types/ConfigurationData.ts b/src/types/ConfigurationData.ts index 425f57c3..e1638a8c 100644 --- a/src/types/ConfigurationData.ts +++ b/src/types/ConfigurationData.ts @@ -7,6 +7,7 @@ import type { StorageConfigurationSchema, UIServerConfigurationSchema, UIServerMetricsConfigurationSchema, + UIServerSecurityHeadersConfigurationSchema, WorkerConfigurationSchema, } from '../utils/index.js' @@ -34,4 +35,7 @@ export type StationTemplateUrl = z.infer export type StorageConfiguration = z.infer export type UIServerConfiguration = z.infer export type UIServerMetricsConfiguration = z.infer +export type UIServerSecurityHeadersConfiguration = z.infer< + typeof UIServerSecurityHeadersConfigurationSchema +> export type WorkerConfiguration = z.infer diff --git a/src/utils/ConfigurationSchema.ts b/src/utils/ConfigurationSchema.ts index 040ec6d6..4a03d73d 100644 --- a/src/utils/ConfigurationSchema.ts +++ b/src/utils/ConfigurationSchema.ts @@ -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(), }) diff --git a/src/utils/index.ts b/src/utils/index.ts index 02d1f1cd..eba731ef 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -21,6 +21,7 @@ export { UI_SERVER_ACCESS_POLICY_DEFAULTS, UIServerConfigurationSchema, UIServerMetricsConfigurationSchema, + UIServerSecurityHeadersConfigurationSchema, WorkerConfigurationSchema, } from './ConfigurationSchema.js' export { diff --git a/tests/charging-station/ui-server/UIHttpServer.test.ts b/tests/charging-station/ui-server/UIHttpServer.test.ts index f4e4f979..0874a3f8 100644 --- a/tests/charging-station/ui-server/UIHttpServer.test.ts +++ b/tests/charging-station/ui-server/UIHttpServer.test.ts @@ -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 { + 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 + ): 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) + }) + }) }) diff --git a/tests/charging-station/ui-server/UIMCPServer.test.ts b/tests/charging-station/ui-server/UIMCPServer.test.ts index bc5493fa..cff3c094 100644 --- a/tests/charging-station/ui-server/UIMCPServer.test.ts +++ b/tests/charging-station/ui-server/UIMCPServer.test.ts @@ -112,17 +112,19 @@ class TestableUIMCPServer extends UIMCPServer { } public callSendErrorResponse ( + req: IncomingMessage, res: ServerResponse, statusCode: StatusCodes, headers?: Readonly> ): void { ;( Reflect.get(this, 'sendErrorResponse') as ( + req: IncomingMessage, res: ServerResponse, statusCode: StatusCodes, headers?: Readonly> ) => 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' } diff --git a/tests/charging-station/ui-server/UIServerAccessPolicy.test.ts b/tests/charging-station/ui-server/UIServerAccessPolicy.test.ts index dcf26fdc..fe6add80 100644 --- a/tests/charging-station/ui-server/UIServerAccessPolicy.test.ts +++ b/tests/charging-station/ui-server/UIServerAccessPolicy.test.ts @@ -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 + ) + }) + }) }) diff --git a/tests/charging-station/ui-server/UIWebSocketServer.test.ts b/tests/charging-station/ui-server/UIWebSocketServer.test.ts index 3504cc97..fc06e7d7 100644 --- a/tests/charging-station/ui-server/UIWebSocketServer.test.ts +++ b/tests/charging-station/ui-server/UIWebSocketServer.test.ts @@ -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 + } + 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 + } + 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({ diff --git a/tests/utils/ConfigurationSchema.test.ts b/tests/utils/ConfigurationSchema.test.ts index fc9047a2..82959275 100644 --- a/tests/utils/ConfigurationSchema.test.ts +++ b/tests/utils/ConfigurationSchema.test.ts @@ -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({ -- 2.53.0