ForwardedFromUntrustedPeer = 'forwarded-from-untrusted-peer',
HostNotAllowed = 'host-not-allowed',
InvalidForwardedClient = 'invalid-forwarded-client',
+ InvalidForwardedHost = 'invalid-forwarded-host',
+ InvalidForwardedProtocol = 'invalid-forwarded-protocol',
LoopbackProxyDisabled = 'loopback-proxy-disabled',
OriginNotAllowed = 'origin-not-allowed',
ProxyTlsRequired = 'proxy-tls-required',
[UIServerAccessDenialReason.HostNotAllowed]: 'Host header is not allowed',
[UIServerAccessDenialReason.InvalidForwardedClient]:
'Invalid X-Forwarded-For header is not allowed',
+ [UIServerAccessDenialReason.InvalidForwardedHost]:
+ 'Invalid X-Forwarded-Host header is not allowed',
+ [UIServerAccessDenialReason.InvalidForwardedProtocol]:
+ 'Invalid X-Forwarded-Proto header is not allowed',
[UIServerAccessDenialReason.LoopbackProxyDisabled]:
'Loopback proxy forwarding requires accessPolicy.allowLoopbackProxy=true',
[UIServerAccessDenialReason.OriginNotAllowed]: 'Origin header is not allowed',
* requests and the normalized trusted-proxy index of the active
* configuration. Both maps are weakly keyed so entries are released with
* their owning object.
+ *
+ * Cache invalidation is identity-based: mutating
+ * `accessPolicy.trustedProxies` in place will not refresh the normalized
+ * set. Reload flows must construct a new {@link UIServerConfiguration}.
*/
export interface UIServerAccessCache {
readonly decisions: WeakMap<IncomingMessage, UIServerAccessDecision>
trustedProxy: boolean,
forwarded: ParseOutcome<ForwardedParams>
): ParseOutcome<string> => {
- if (!trustedProxy) {
- return ABSENT
- }
if (forwarded.kind === 'error') {
return forwarded
}
+ if (!trustedProxy) {
+ return ABSENT
+ }
const picked = pickForwardedValue(
nonEmpty(getSingleHeaderValue(req, 'x-forwarded-for')),
forwarded.kind === 'ok' ? forwarded.value.for : undefined,
}
if (xForwardedProtocol != null) {
const protocols = splitHeaderList(xForwardedProtocol)
- if (protocols.length !== 1) {
+ if (protocols.length === 0) {
+ return { kind: 'error', reason: UIServerAccessDenialReason.InvalidForwardedProtocol }
+ }
+ if (protocols.length > 1) {
return { kind: 'error', reason: UIServerAccessDenialReason.AmbiguousForwardedProtocol }
}
return { kind: 'ok', value: protocols[0].toLowerCase() }
}
if (xForwardedHost != null) {
const hosts = splitHeaderList(xForwardedHost)
- if (hosts.length !== 1) {
+ if (hosts.length === 0) {
+ return { kind: 'error', reason: UIServerAccessDenialReason.InvalidForwardedHost }
+ }
+ if (hosts.length > 1) {
return { kind: 'error', reason: UIServerAccessDenialReason.AmbiguousForwardedHost }
}
return { kind: 'ok', value: hosts[0] }
}
const nonEmpty = (value: string | undefined): string | undefined =>
- value == null || value === '' ? undefined : value
+ value == null || value.trim() === '' ? undefined : value
// RFC 7239 §6: "unknown" and obfuscated node identifiers ("_" + token chars).
// Optional ":port" suffix is stripped before comparison.
continue
}
const key = part.slice(0, separatorIndex).trim().toLowerCase()
- const value = nonEmpty(
- part
- .slice(separatorIndex + 1)
- .trim()
- .replace(/^"(.*)"$/, '$1')
- )
+ const raw = part.slice(separatorIndex + 1).trim()
+ const quoted = /^"(.*)"$/.exec(raw)
+ const value = nonEmpty(quoted != null ? quoted[1].replace(/\\(.)/g, '$1') : raw)
if (key !== 'by' && key !== 'for' && key !== 'host' && key !== 'proto') {
continue
}
const hasForwardedHeaders = (req: IncomingMessage): boolean =>
FORWARDED_HEADER_NAMES.some(headerName =>
- getHeaderValues(req, headerName).some(value => value !== '')
+ getHeaderValues(req, headerName).some(value => nonEmpty(value) != null)
)
const isHostAllowed = (
* @returns The bare host, or `undefined` when the input is malformed.
*/
export const normalizeHost = (host: string): string | undefined => {
- const trimmedHost = host.trim().toLowerCase().replace(/\.$/, '')
+ const trimmedHost = host.trim().toLowerCase()
if (trimmedHost === '') {
return undefined
}
if (parts.length > 2 || (parts.length === 2 && !isValidPort(parts[1]))) {
return undefined
}
- return HOSTNAME_PATTERN.test(parts[0]) ? parts[0] : undefined
+ const hostPart = parts[0].replace(/\.$/, '')
+ if (hostPart === '') {
+ return undefined
+ }
+ return HOSTNAME_PATTERN.test(hostPart) ? hostPart : undefined
+}
+
+export const isHostLiteralWithoutPort = (value: string): boolean => {
+ const normalized = normalizeHost(value)
+ if (normalized == null) {
+ return false
+ }
+ const stripped = value.trim().toLowerCase().replace(/\.$/, '')
+ return stripped === normalized || stripped === `[${normalized}]`
}
const HOSTNAME_PATTERN = /^[a-z0-9._-]+$/
})
})
this.httpServer.on('upgrade', (req: IncomingMessage, socket: Duplex, head: Buffer): void => {
+ const onSocketError = (error: Error): void => {
+ logger.error(
+ `${this.logPrefix(
+ moduleName,
+ 'start.httpServer.on.upgrade'
+ )} Socket error at connection upgrade event handling:`,
+ error
+ )
+ }
+ socket.on('error', onSocketError)
+
const connectionHeader = req.headers.connection ?? ''
const upgradeHeader = req.headers.upgrade ?? ''
if (!/upgrade/i.test(connectionHeader) || !/^websocket$/i.test(upgradeHeader)) {
return
}
- const onSocketError = (error: Error): void => {
- logger.error(
- `${this.logPrefix(
- moduleName,
- 'start.httpServer.on.upgrade'
- )} Socket error at connection upgrade event handling:`,
- error
- )
- }
- socket.on('error', onSocketError)
if (!this.authenticate(req)) {
- socket.removeListener('error', onSocketError)
const unauthorized = this.getUnauthorizedDenial()
socket.write(
buildUpgradeRejectionResponse(
import { isIP } from 'node:net'
import { z } from 'zod'
-import { normalizeHost } from '../charging-station/ui-server/UIServerNet.js'
+import { isHostLiteralWithoutPort } from '../charging-station/ui-server/UIServerNet.js'
import {
ApplicationProtocol,
ApplicationProtocolVersion,
.object({
allowedHosts: z
.array(
- z.string().refine(value => normalizeHost(value) != null, {
- message: 'must be a valid host (no path, query, or fragment)',
+ z.string().refine(isHostLiteralWithoutPort, {
+ message: 'must be a host literal without port (no path, query, or fragment)',
})
)
.optional(),
})
.strict()
-const UIServerListenOptionsSchema = z.custom<ListenOptions>(value => {
- return (
- value != null &&
- typeof value === 'object' &&
- !Array.isArray(value) &&
- !Object.hasOwn(value, 'accessPolicy')
+const UIServerListenOptionsSchema = z
+ .custom<ListenOptions>(
+ value => value != null && typeof value === 'object' && !Array.isArray(value),
+ { message: 'must be a non-array object' }
)
-}, "'accessPolicy' must be configured under 'uiServer', not 'uiServer.options'")
+ .refine(value => !Object.hasOwn(value as object, 'accessPolicy'), {
+ message: "'accessPolicy' must be configured under 'uiServer', not 'uiServer.options'",
+ })
/**
* UIServerConfiguration — UI server configuration section.
expectDenied(decision, UIServerAccessDenialReason.AmbiguousForwardedHost)
})
+ await it('should reject empty X-Forwarded-Proto comma-only lists from a trusted proxy', () => {
+ const decision = evaluate(
+ createAccessPolicyRequest({
+ headers: {
+ host: 'gateway.example.com',
+ 'x-forwarded-for': '203.0.113.10',
+ 'x-forwarded-proto': ',,,',
+ },
+ remoteAddress: '192.0.2.10',
+ }),
+ createGatewayConfigWithTrustedProxy()
+ )
+
+ expectDenied(decision, UIServerAccessDenialReason.InvalidForwardedProtocol)
+ })
+
+ await it('should reject empty X-Forwarded-Host comma-only lists from a trusted proxy', () => {
+ 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',
+ }),
+ createGatewayConfigWithTrustedProxy()
+ )
+
+ expectDenied(decision, UIServerAccessDenialReason.InvalidForwardedHost)
+ })
+
await it('should reject Forwarded headers with multiple comma-separated entries', () => {
const decision = evaluate(
createAccessPolicyRequest({
expectDenied(decision, UIServerAccessDenialReason.TlsRequired)
})
+ await it('should treat whitespace-only forwarded headers as absent', () => {
+ const decision = evaluate(
+ createAccessPolicyRequest({
+ headers: {
+ host: 'localhost:8080',
+ 'x-forwarded-proto': ' ',
+ },
+ }),
+ createAccessPolicyConfiguration()
+ )
+
+ assert.strictEqual(decision.allowed, true)
+ assert.strictEqual(decision.clientAddress, '127.0.0.1')
+ })
+
await it('should accept empty forwarded headers from a loopback peer', () => {
const decision = evaluate(
createAccessPolicyRequest({
assert.strictEqual(decision.clientAddress, '2001:db8:0:0:0:0:0:1')
})
+ await it('should unescape RFC 7230 quoted-pair sequences in Forwarded parameter values', () => {
+ const decision = evaluate(
+ createAccessPolicyRequest({
+ headers: {
+ forwarded:
+ 'for="203.0.113.10";host="\\g\\a\\t\\e\\w\\a\\y\\.\\e\\x\\a\\m\\p\\l\\e\\.\\c\\o\\m";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 still require TLS when a trusted proxy claims a loopback client', () => {
const decision = evaluate(
createAccessPolicyRequest({
await it('should drop a single trailing dot', () => {
assert.strictEqual(normalizeHost('gateway.example.com.'), 'gateway.example.com')
+ assert.strictEqual(normalizeHost('localhost.:80'), 'localhost')
})
await it('should lowercase the result', () => {
import type { UUIDv4 } from '../../../src/types/index.js'
import { ProcedureName, ResponseStatus } from '../../../src/types/index.js'
-import { standardCleanup } from '../../helpers/TestLifecycleHelpers.js'
+import { logger } from '../../../src/utils/Logger.js'
+import { createLoggerMocks, standardCleanup } from '../../helpers/TestLifecycleHelpers.js'
import { TEST_UUID } from './UIServerTestConstants.js'
import {
createMockIncomingMessage,
assert.match(response, /Connection: close/)
assert.match(response, /Content-Length: 0/)
})
+
+ await it('should attach an error listener before writing upgrade rejection', async t => {
+ const { errorMock } = createLoggerMocks(t, logger)
+ const config = createMockUIServerConfiguration({
+ 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: 'keep-alive', host: 'localhost', upgrade: 'http/1.1' },
+ socket: { encrypted: false, remoteAddress: '127.0.0.1' } as never,
+ }),
+ socket as unknown as Duplex
+ )
+
+ assert.doesNotThrow(() => socket.emit('error', new Error('connection reset')))
+ assert.ok(errorMock.mock.calls.length >= 1)
+ } finally {
+ server.stop()
+ }
+ })
})
assert.ok(result.error.issues.some(i => i.path.join('.').includes('uiServer.options')))
})
+ await it('should reject uiServer options as null with object-shape message', () => {
+ const result = ConfigurationSchema.safeParse(
+ buildMinimalConfiguration({
+ uiServer: {
+ options: null,
+ },
+ })
+ )
+ assert.ok(!result.success)
+ assert.ok(
+ result.error.issues.some(
+ i =>
+ i.path.join('.').includes('uiServer.options') && i.message.includes('non-array object')
+ )
+ )
+ })
+
+ await it('should reject uiServer options as array with object-shape message', () => {
+ const result = ConfigurationSchema.safeParse(
+ buildMinimalConfiguration({
+ uiServer: {
+ options: [],
+ },
+ })
+ )
+ assert.ok(!result.success)
+ assert.ok(
+ result.error.issues.some(
+ i =>
+ i.path.join('.').includes('uiServer.options') && i.message.includes('non-array object')
+ )
+ )
+ })
+
await it('should reject hostnames in trustedProxies', () => {
const result = ConfigurationSchema.safeParse(
buildMinimalConfiguration({
}
})
+ await it('should reject allowedHosts entries with a port', () => {
+ for (const portBearingHost of [
+ 'gateway.example.com:8080',
+ '127.0.0.1:8080',
+ '127.0.0.1:08080',
+ '[::1]:8080',
+ ]) {
+ const result = ConfigurationSchema.safeParse(
+ buildMinimalConfiguration({
+ uiServer: {
+ accessPolicy: {
+ allowedHosts: [portBearingHost],
+ },
+ enabled: true,
+ type: 'ws',
+ },
+ })
+ )
+ assert.ok(
+ !result.success,
+ `Expected '${portBearingHost}' 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',
- ],
+ allowedHosts: ['gateway.example.com', '127.0.0.1', '[::1]', 'localhost'],
},
enabled: true,
type: 'ws',