From 45624f2acecdaf37a67963a45f8ffa7816fbcfdd Mon Sep 17 00:00:00 2001 From: =?utf8?q?J=C3=A9r=C3=B4me=20Benoit?= Date: Wed, 22 Apr 2026 17:07:41 +0200 Subject: [PATCH] =?utf8?q?fix(ui-server):=20reject=20empty=20password=20in?= =?utf8?q?=20basic=20auth=20token=20validation=20(RFC=207613=20=C2=A74.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit Align UIServer basic auth with RFC 7617/7613: empty passwords are now rejected alongside the existing empty username check. Add comprehensive unit tests for all UIServerUtils exported functions (24 tests). --- .../ui-server/UIServerUtils.ts | 4 + .../ui-server/UIServerUtils.test.ts | 178 ++++++++++++++++++ 2 files changed, 182 insertions(+) create mode 100644 tests/charging-station/ui-server/UIServerUtils.test.ts diff --git a/src/charging-station/ui-server/UIServerUtils.ts b/src/charging-station/ui-server/UIServerUtils.ts index b789823c..f56f6457 100644 --- a/src/charging-station/ui-server/UIServerUtils.ts +++ b/src/charging-station/ui-server/UIServerUtils.ts @@ -29,6 +29,10 @@ export const getUsernameAndPasswordFromAuthorizationToken = ( 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')) + return undefined + } return [username, password] } catch (error) { next(new BaseError(`Invalid basic authentication token format: ${getErrorMessage(error)}`)) diff --git a/tests/charging-station/ui-server/UIServerUtils.test.ts b/tests/charging-station/ui-server/UIServerUtils.test.ts new file mode 100644 index 00000000..6f40fcc5 --- /dev/null +++ b/tests/charging-station/ui-server/UIServerUtils.test.ts @@ -0,0 +1,178 @@ +/** + * @file Tests for UIServerUtils + * @description Unit tests for UI server utility functions (auth token parsing, protocol handling, loopback detection) + */ + +import type { IncomingMessage } from 'node:http' + +import assert from 'node:assert/strict' +import { afterEach, describe, it } from 'node:test' + +import { + getProtocolAndVersion, + getUsernameAndPasswordFromAuthorizationToken, + handleProtocols, + isLoopback, + isProtocolAndVersionSupported, +} from '../../../src/charging-station/ui-server/UIServerUtils.js' +import { Protocol, ProtocolVersion } from '../../../src/types/index.js' +import { logger } from '../../../src/utils/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 describe('getUsernameAndPasswordFromAuthorizationToken', async () => { + await it('should parse valid credentials', () => { + // cspell:disable-next-line + const token = Buffer.from('alice:s3cret').toString('base64') + const result = getUsernameAndPasswordFromAuthorizationToken(token, noop) + // 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) + 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) + }) + + 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) + }) + + 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) + }) + + 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) + }) + }) + + await describe('getProtocolAndVersion', async () => { + await it('should parse valid protocol string', () => { + const result = getProtocolAndVersion(`${Protocol.UI}${ProtocolVersion['0.0.1']}`) + assert.deepStrictEqual(result, [Protocol.UI, ProtocolVersion['0.0.1']]) + }) + + await it('should return undefined for empty string', () => { + assert.strictEqual(getProtocolAndVersion(''), undefined) + }) + + await it('should return undefined for string not starting with protocol prefix', () => { + assert.strictEqual(getProtocolAndVersion('http0.0.1'), undefined) + }) + + await it('should return undefined for protocol prefix without version', () => { + assert.strictEqual(getProtocolAndVersion(Protocol.UI), undefined) + }) + }) + + await describe('isProtocolAndVersionSupported', async () => { + await it('should return true for supported protocol and version', () => { + assert.strictEqual( + isProtocolAndVersionSupported(`${Protocol.UI}${ProtocolVersion['0.0.1']}`), + true + ) + }) + + await it('should return false for unsupported version', () => { + assert.strictEqual(isProtocolAndVersionSupported(`${Protocol.UI}9.9.9`), false) + }) + + await it('should return false for unsupported protocol prefix', () => { + assert.strictEqual(isProtocolAndVersionSupported('ws0.0.1'), false) + }) + + await it('should return false for empty string', () => { + assert.strictEqual(isProtocolAndVersionSupported(''), false) + }) + }) + + await describe('handleProtocols', async () => { + const dummyRequest = {} as IncomingMessage + + await it('should return matching protocol from set', () => { + const protocols = new Set([`${Protocol.UI}${ProtocolVersion['0.0.1']}`]) + const result = handleProtocols(protocols, dummyRequest) + assert.strictEqual(result, `${Protocol.UI}${ProtocolVersion['0.0.1']}`) + }) + + await it('should return false for empty set', () => { + assert.strictEqual(handleProtocols(new Set(), dummyRequest), false) + }) + + await it('should return false and log error when no protocol is supported', t => { + const { errorMock } = createLoggerMocks(t, logger) + const protocols = new Set(['unsupported1', 'unsupported2']) + assert.strictEqual(handleProtocols(protocols, dummyRequest), false) + assert.strictEqual(errorMock.mock.calls.length, 1) + }) + + await it('should return first supported protocol when multiple provided', () => { + const supported = `${Protocol.UI}${ProtocolVersion['0.0.1']}` + const protocols = new Set([supported, 'unsupported']) + 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) + }) + }) +}) -- 2.43.0