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