From: Jérôme Benoit Date: Mon, 15 Jun 2026 16:40:57 +0000 (+0200) Subject: fix(config): tighten Zod validation on uiServer.options.port (#1874) (#1901) X-Git-Tag: cli@v4.9.0~5 X-Git-Url: https://git.piment-noir.org/?a=commitdiff_plain;h=19afcb2852ccff5e18d11b8522d77f7c5ff80976;p=e-mobility-charging-stations-simulator.git fix(config): tighten Zod validation on uiServer.options.port (#1874) (#1901) * fix(config): tighten Zod validation on uiServer.options.port (#1874) Replace the permissive z.custom bridge under uiServer.options with a typed object schema validating known primitive fields, so bad transport-level values (e.g. port: "not-a-number") fail at boot time in ConfigurationSchema.safeParse instead of later inside node:net.Server.listen with ERR_SOCKET_BAD_PORT. The existing accessPolicy refinement is preserved via .pipe(); unknown keys are still passed through (.loose()) to keep the ListenOptions extension surface (e.g. signal: AbortSignal) usable. Validates: - port: integer in [0, 65535] - host: non-empty string - backlog: non-negative integer - path: non-empty string - exclusive / ipv6Only / readableAll / writableAll: boolean Tests: 10 new cases in tests/utils/ConfigurationSchema.test.ts and 1 integration assertion in tests/utils/ConfigurationValidation.test.ts covering the structured Zod error path uiServer.options.port routed through ConfigurationValidationError. * docs(config): align JSDoc with composite uiServer.options schema Address review comment on #1901: the JSDoc above UIServerListenOptionsObjectSchema was named after the composite UIServerListenOptionsSchema, leaving the helper described under the wrong name and the composite undocumented. UIServerConfigurationSchema also still referenced the now-removed permissive z.custom bridge. - UIServerListenOptionsObjectSchema gets its own JSDoc describing the typed object layer (.loose() + primitive constraints). - UIServerListenOptionsSchema gets a JSDoc describing the full chain: object guard → accessPolicy refinement → pipe to typed object. - UIServerConfigurationSchema JSDoc updated to reference the new composite schema instead of the obsolete custom bridge. No behavior change, no test change. --- diff --git a/src/utils/ConfigurationSchema.ts b/src/utils/ConfigurationSchema.ts index a9131e3b..cabf7ef6 100644 --- a/src/utils/ConfigurationSchema.ts +++ b/src/utils/ConfigurationSchema.ts @@ -139,6 +139,32 @@ export const UIServerAccessPolicySchema = z }) .strict() +/** + * UIServerListenOptionsObjectSchema — typed object layer for `node:net` + * `ListenOptions`. Validates known primitive fields (port, host, backlog, ...) + * at boot time so that bad transport-level values (e.g. `port: "not-a-number"`) + * fail in `ConfigurationSchema.safeParse` rather than later in `Server.listen`. + * Unknown keys are passed through (`.loose()`) to preserve the `ListenOptions` + * extension surface (e.g. `signal: AbortSignal`). + */ +const UIServerListenOptionsObjectSchema = z + .object({ + backlog: z.number().int().nonnegative().optional(), + exclusive: z.boolean().optional(), + host: z.string().min(1).optional(), + ipv6Only: z.boolean().optional(), + path: z.string().min(1).optional(), + port: z.number().int().min(0).max(65535).optional(), + readableAll: z.boolean().optional(), + writableAll: z.boolean().optional(), + }) + .loose() + +/** + * UIServerListenOptionsSchema — composite schema for `uiServer.options`: + * non-array object guard → `accessPolicy` misplacement refinement → typed + * field validation via `UIServerListenOptionsObjectSchema`. + */ const UIServerListenOptionsSchema = z .custom( value => value != null && typeof value === 'object' && !Array.isArray(value), @@ -147,11 +173,13 @@ const UIServerListenOptionsSchema = z .refine(value => !Object.hasOwn(value as object, 'accessPolicy'), { message: "'accessPolicy' must be configured under 'uiServer', not 'uiServer.options'", }) + .pipe(UIServerListenOptionsObjectSchema) /** * UIServerConfiguration — UI server configuration section. - * `options` is structurally typed as `ListenOptions` from node:net; the schema - * uses `z.custom()` to bridge the external surface. + * `options` is structurally typed as `ListenOptions` from node:net and + * validated by `UIServerListenOptionsSchema` (object guard → `accessPolicy` + * refinement → typed field validation). */ export const UIServerConfigurationSchema = z .object({ diff --git a/tests/utils/ConfigurationSchema.test.ts b/tests/utils/ConfigurationSchema.test.ts index 6c0a4096..b73f9883 100644 --- a/tests/utils/ConfigurationSchema.test.ts +++ b/tests/utils/ConfigurationSchema.test.ts @@ -282,6 +282,126 @@ await describe('ConfigurationSchema', async () => { ) }) + await describe('uiServer.options.port', async () => { + await it('should reject non-numeric string port "not-a-number"', () => { + const result = ConfigurationSchema.safeParse( + buildMinimalConfiguration({ + uiServer: { options: { host: 'localhost', port: 'not-a-number' } }, + }) + ) + assert.ok(!result.success) + const paths = result.error.issues.map(i => i.path.join('.')) + assert.ok( + paths.includes('uiServer.options.port'), + `Expected error path 'uiServer.options.port' in ${JSON.stringify(paths)}` + ) + }) + + await it('should reject negative port (-1)', () => { + const result = ConfigurationSchema.safeParse( + buildMinimalConfiguration({ + uiServer: { options: { host: 'localhost', port: -1 } }, + }) + ) + assert.ok(!result.success) + assert.ok(result.error.issues.some(i => i.path.join('.') === 'uiServer.options.port')) + }) + + await it('should reject port 65536 (out of range)', () => { + const result = ConfigurationSchema.safeParse( + buildMinimalConfiguration({ + uiServer: { options: { host: 'localhost', port: 65536 } }, + }) + ) + assert.ok(!result.success) + assert.ok(result.error.issues.some(i => i.path.join('.') === 'uiServer.options.port')) + }) + + await it('should reject non-integer port 3.14', () => { + const result = ConfigurationSchema.safeParse( + buildMinimalConfiguration({ + uiServer: { options: { host: 'localhost', port: 3.14 } }, + }) + ) + assert.ok(!result.success) + assert.ok(result.error.issues.some(i => i.path.join('.') === 'uiServer.options.port')) + }) + + await it('should accept port 8080', () => { + const result = ConfigurationSchema.safeParse( + buildMinimalConfiguration({ + uiServer: { options: { host: 'localhost', port: 8080 } }, + }) + ) + assert.ok( + result.success, + `Expected port 8080 to be accepted: ${result.success ? '' : JSON.stringify(result.error.issues)}` + ) + }) + + await it('should accept port 0 (OS-picked port)', () => { + const result = ConfigurationSchema.safeParse( + buildMinimalConfiguration({ + uiServer: { options: { host: 'localhost', port: 0 } }, + }) + ) + assert.ok( + result.success, + `Expected port 0 to be accepted: ${result.success ? '' : JSON.stringify(result.error.issues)}` + ) + }) + + await it('should accept port 65535 (max valid)', () => { + const result = ConfigurationSchema.safeParse( + buildMinimalConfiguration({ + uiServer: { options: { host: 'localhost', port: 65535 } }, + }) + ) + assert.ok( + result.success, + `Expected port 65535 to be accepted: ${result.success ? '' : JSON.stringify(result.error.issues)}` + ) + }) + }) + + await describe('uiServer.options.host', async () => { + await it('should reject empty host string', () => { + const result = ConfigurationSchema.safeParse( + buildMinimalConfiguration({ + uiServer: { options: { host: '', port: 8080 } }, + }) + ) + assert.ok(!result.success) + const paths = result.error.issues.map(i => i.path.join('.')) + assert.ok( + paths.includes('uiServer.options.host'), + `Expected error path 'uiServer.options.host' in ${JSON.stringify(paths)}` + ) + }) + + await it('should accept host "localhost"', () => { + const result = ConfigurationSchema.safeParse( + buildMinimalConfiguration({ + uiServer: { options: { host: 'localhost', port: 8080 } }, + }) + ) + assert.ok( + result.success, + `Expected host 'localhost' to be accepted: ${result.success ? '' : JSON.stringify(result.error.issues)}` + ) + }) + + await it('should reject non-string host', () => { + const result = ConfigurationSchema.safeParse( + buildMinimalConfiguration({ + uiServer: { options: { host: 1234, port: 8080 } }, + }) + ) + assert.ok(!result.success) + assert.ok(result.error.issues.some(i => i.path.join('.') === 'uiServer.options.host')) + }) + }) + await it('should reject hostnames in trustedProxies', () => { const result = ConfigurationSchema.safeParse( buildMinimalConfiguration({ diff --git a/tests/utils/ConfigurationValidation.test.ts b/tests/utils/ConfigurationValidation.test.ts index 20b89fa3..7f203756 100644 --- a/tests/utils/ConfigurationValidation.test.ts +++ b/tests/utils/ConfigurationValidation.test.ts @@ -488,5 +488,30 @@ await describe('ConfigurationValidation', async () => { assert.strictEqual(error.message, EXPECTED_SNAPSHOT) } }) + + await it('should fail-fast with structured uiServer.options.port path on invalid port', () => { + const parsed = buildMinimalConfiguration({ + uiServer: { + enabled: true, + options: { host: 'localhost', port: 'not-a-number' }, + type: 'ws', + }, + }) + try { + validateConfiguration(parsed, 'bad-port.json') + assert.fail('Expected ConfigurationValidationError') + } catch (error) { + assert.ok(error instanceof ConfigurationValidationError) + assert.strictEqual(error.phase, 'schema') + const portErrors = error.fieldErrors.filter(e => e.path === 'uiServer.options.port') + assert.strictEqual( + portErrors.length, + 1, + `Expected one fieldError on 'uiServer.options.port', got ${JSON.stringify(error.fieldErrors)}` + ) + assert.match(error.message, /uiServer\.options\.port/) + assert.match(error.message, /\[schema\]/) + } + }) }) })