]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
fix(ui-server): reject empty password in basic auth token validation (RFC 7613 §4.1)
authorJérôme Benoit <jerome.benoit@sap.com>
Wed, 22 Apr 2026 15:07:41 +0000 (17:07 +0200)
committerJérôme Benoit <jerome.benoit@sap.com>
Wed, 22 Apr 2026 15:08:06 +0000 (17:08 +0200)
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).

src/charging-station/ui-server/UIServerUtils.ts
tests/charging-station/ui-server/UIServerUtils.test.ts [new file with mode: 0644]

index b789823c31d6a9c624755b29af0cc8a5745381c2..f56f6457959f70a96146c2ac456a71a36b48cbe8 100644 (file)
@@ -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 (file)
index 0000000..6f40fcc
--- /dev/null
@@ -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)
+    })
+  })
+})