From adc9db44099c0e0f35eb6366152d608af7477a17 Mon Sep 17 00:00:00 2001 From: woksin Date: Thu, 29 Oct 2026 11:26:10 +0100 Subject: [PATCH 1/3] Warn when TLS certificate validation is skipped for a remote host --- Documentation/connecting.md | 5 +- Source/README.md | 2 +- Source/connection/ChronicleConnection.ts | 21 ++++++++ .../tls_validation_warning.spec.ts | 48 +++++++++++++++++++ 4 files changed, 73 insertions(+), 3 deletions(-) create mode 100644 Source/connection/for_ChronicleConnection/when_warning_about_tls_validation/tls_validation_warning.spec.ts diff --git a/Documentation/connecting.md b/Documentation/connecting.md index 08564df0..762d7a02 100644 --- a/Documentation/connecting.md +++ b/Documentation/connecting.md @@ -71,10 +71,11 @@ A connection string without credentials does not fail. The client falls back to The client uses TLS unless the connection string sets `disableTls=true`. Chronicle serves gRPC and the token endpoint over TLS on one port. -:::danger[Validate the server certificate outside local development] -`skipTlsValidation` defaults to `true` so that the development image's self-signed certificate works without configuration. With that default, the client accepts any certificate for both gRPC calls and token requests, so anyone who can intercept the connection can read the credentials and the events. Add `skipTlsValidation=false` to every connection string that points at a shared or production server. +:::danger[Validate the server certificate for any non-local server] +Set `skipTlsValidation=false` on every connection string that points at a server other than a local development kernel. `skipTlsValidation` defaults to `true` so that the development image's self-signed certificate works without configuration. With that default, the client accepts any certificate for both gRPC calls and token requests, so anyone who can intercept the connection can read the credentials and the events. ::: +When TLS is on and `skipTlsValidation` is `true`, the client logs one warning through the configured [logger](./observability.md#application-diagnostics) when the connection starts if any target host is not a loopback host (`localhost`, `127.x.x.x`, or `::1`). The warning names the host, says that certificate validation is disabled for the gRPC channel and OAuth token requests, and says how to enable it: `skipTlsValidation=false` in the connection string, or `skipTlsValidation = false` on `ChronicleConnectionStringBuilder`. It is logged once per connection, not on each call or reconnect. The default is unchanged. With `skipTlsValidation=false`, the server certificate must be trusted by the default Node.js and gRPC trust store. The TypeScript client has no option to supply a custom certificate authority bundle or a client certificate. Use `disableTls=true` only when something else protects the traffic, for example a service mesh that terminates TLS. Credentials and events then cross the network unencrypted between the client and that proxy. diff --git a/Source/README.md b/Source/README.md index 0b460857..4c80d530 100644 --- a/Source/README.md +++ b/Source/README.md @@ -74,7 +74,7 @@ Read models are inferred from `@projection('id', Model)`, `@reducer('id', sequen Decorated artifacts register when their modules are imported. When the entry file is TypeScript (run through a loader such as `tsx`), the client also scans `**/*.ts` files by default; compiled JavaScript scans nothing unless you set `discoveryPatterns`. See [artifact discovery](https://github.com/Cratis/Chronicle.TypeScript/blob/main/Documentation/getting-started.md#artifact-discovery). -The client skips TLS certificate validation unless the connection string sets `skipTlsValidation=false`. Set it for every server other than a local development kernel; see [Connect to Chronicle](https://github.com/Cratis/Chronicle.TypeScript/blob/main/Documentation/connecting.md). +The client skips TLS certificate validation unless the connection string sets `skipTlsValidation=false`. Set it for every server other than a local development kernel; the client logs a warning when it skips validation for a non-loopback host; see [Connect to Chronicle](https://github.com/Cratis/Chronicle.TypeScript/blob/main/Documentation/connecting.md). ## Documentation diff --git a/Source/connection/ChronicleConnection.ts b/Source/connection/ChronicleConnection.ts index df8f97dc..864b47c9 100644 --- a/Source/connection/ChronicleConnection.ts +++ b/Source/connection/ChronicleConnection.ts @@ -108,6 +108,7 @@ export class ChronicleConnection implements ChronicleServices { private readonly _addressResolver: ChronicleServerAddressResolver; private readonly _loadBalancerStrategy: ILoadBalancerStrategy; private _isConnected = false; + private _warnedAboutSkippedTlsValidation = false; private _clientsReady: Promise; constructor(private readonly _options: ChronicleConnectionOptions) { @@ -297,6 +298,7 @@ export class ChronicleConnection implements ChronicleServices { // on every call, so every connect/reconnect attempt picks up membership and load // changes rather than pinning to whatever was selected at startup. const candidates = await this._addressResolver.resolve(this._connectionString); + this.warnWhenTlsValidationIsSkipped(candidates); const selected = await this._loadBalancerStrategy.select(candidates); const serverAddress = formatServerAddress(selected); const tokenProvider = this.createTokenProvider(selected); @@ -337,6 +339,20 @@ export class ChronicleConnection implements ChronicleServices { }; } + private warnWhenTlsValidationIsSkipped(candidates: ChronicleServerAddress[]): void { + if (this._warnedAboutSkippedTlsValidation) return; + if (this._connectionString.disableTls || !this._connectionString.skipTlsValidation || this._options.credentials) return; + + const remoteHosts = [...new Set(candidates.map(candidate => candidate.host).filter(host => !isLoopbackHost(host)))]; + if (remoteHosts.length === 0) return; + + this._warnedAboutSkippedTlsValidation = true; + createLogger('@cratis/chronicle/ChronicleConnection', this.logger).warn( + `TLS certificate validation is disabled for the gRPC channel and OAuth token requests to ${remoteHosts.join(', ')}. ` + + 'Set skipTlsValidation=false in the connection string, or skipTlsValidation = false on ChronicleConnectionStringBuilder, to validate the server certificate.', + { hosts: remoteHosts }); + } + private createTokenProvider(selected: ChronicleServerAddress): ITokenProvider { const hasUsername = !!this._connectionString.username; const hasApiKey = !!this._connectionString.apiKey; @@ -444,6 +460,11 @@ export class ChronicleConnection implements ChronicleServices { } } +function isLoopbackHost(host: string): boolean { + const normalized = host.toLowerCase().replace(/^\[|\]$/g, ''); + return normalized === 'localhost' || normalized === '::1' || /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(normalized); +} + /** Stops waiting for a token on cancellation without canceling the shared token request. */ async function acquireTokenUnlessAborted(acquire: () => Promise, signal?: AbortSignal): Promise { if (signal?.aborted) throw signal.reason; diff --git a/Source/connection/for_ChronicleConnection/when_warning_about_tls_validation/tls_validation_warning.spec.ts b/Source/connection/for_ChronicleConnection/when_warning_about_tls_validation/tls_validation_warning.spec.ts new file mode 100644 index 00000000..d7803358 --- /dev/null +++ b/Source/connection/for_ChronicleConnection/when_warning_about_tls_validation/tls_validation_warning.spec.ts @@ -0,0 +1,48 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { afterEach, describe, expect, it } from 'vitest'; +import { ChronicleConnection } from '../../ChronicleConnection.js'; +import type { ChronicleLogEntry } from '../../../logging/ChronicleLogEntry.js'; + +const connections: ChronicleConnection[] = []; + +async function connectionWarnings(connectionString: string, resets = 0): Promise { + const entries: ChronicleLogEntry[] = []; + const connection = new ChronicleConnection({ connectionString, logger: { log: entry => { entries.push(entry); } } }); + connections.push(connection); + await connection.resetChannel(); + for (let i = 0; i < resets; i++) await connection.resetChannel(); + return entries.filter(entry => entry.message.includes('TLS certificate validation')); +} + +afterEach(() => { + for (const connection of connections) connection.dispose(); + connections.length = 0; +}); + +describe('TLS validation warning', () => { + it('warns once, naming the host, for a remote host with validation skipped', async () => { + const warnings = await connectionWarnings('chronicle://chronicle.example.com:35000'); + expect(warnings).toHaveLength(1); + expect(warnings[0].message).toContain('chronicle.example.com'); + expect(warnings[0].message).toContain('gRPC channel and OAuth token requests'); + expect(warnings[0].message).toContain('skipTlsValidation=false'); + }); + + it.each(['localhost', '127.0.0.1', '127.1.2.3', '[::1]'])('does not warn for loopback host %s', async host => { + expect(await connectionWarnings(`chronicle://${host}:35000`)).toHaveLength(0); + }); + + it('does not warn when validation is enabled', async () => { + expect(await connectionWarnings('chronicle://chronicle.example.com:35000?skipTlsValidation=false')).toHaveLength(0); + }); + + it('does not warn when TLS is disabled', async () => { + expect(await connectionWarnings('chronicle://chronicle.example.com:35000?disableTls=true')).toHaveLength(0); + }); + + it('does not warn again on reconnect', async () => { + expect(await connectionWarnings('chronicle://chronicle.example.com:35000', 2)).toHaveLength(1); + }); +}); From eb5a9a345791536a85d69e57c233b8571bcc7abf Mon Sep 17 00:00:00 2001 From: woksin Date: Thu, 29 Oct 2026 11:27:32 +0100 Subject: [PATCH 2/3] Warn about OAuth token requests skipping TLS validation with custom credentials --- Source/connection/ChronicleConnection.ts | 18 ++++++++++++++---- .../tls_validation_warning.spec.ts | 16 ++++++++++++++-- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/Source/connection/ChronicleConnection.ts b/Source/connection/ChronicleConnection.ts index 864b47c9..5fc25aeb 100644 --- a/Source/connection/ChronicleConnection.ts +++ b/Source/connection/ChronicleConnection.ts @@ -341,14 +341,24 @@ export class ChronicleConnection implements ChronicleServices { private warnWhenTlsValidationIsSkipped(candidates: ChronicleServerAddress[]): void { if (this._warnedAboutSkippedTlsValidation) return; - if (this._connectionString.disableTls || !this._connectionString.skipTlsValidation || this._options.credentials) return; - - const remoteHosts = [...new Set(candidates.map(candidate => candidate.host).filter(host => !isLoopbackHost(host)))]; + if (this._connectionString.disableTls || !this._connectionString.skipTlsValidation) return; + + // Custom credentials replace the gRPC channel's TLS settings, but OAuth token requests + // (every mode except an API key) still skip validation. + const channelSkipsValidation = !this._options.credentials; + const tokensSkipValidation = !this._connectionString.apiKey; + if (!channelSkipsValidation && !tokensSkipValidation) return; + + const hostnames = !channelSkipsValidation && this._options.authority + ? [new URL(this._options.authority).hostname] + : candidates.map(candidate => candidate.host); + const remoteHosts = [...new Set(hostnames.filter(host => !isLoopbackHost(host)))]; if (remoteHosts.length === 0) return; this._warnedAboutSkippedTlsValidation = true; + const scope = channelSkipsValidation ? 'the gRPC channel and OAuth token requests' : 'OAuth token requests'; createLogger('@cratis/chronicle/ChronicleConnection', this.logger).warn( - `TLS certificate validation is disabled for the gRPC channel and OAuth token requests to ${remoteHosts.join(', ')}. ` + + `TLS certificate validation is disabled for ${scope} to ${remoteHosts.join(', ')}. ` + 'Set skipTlsValidation=false in the connection string, or skipTlsValidation = false on ChronicleConnectionStringBuilder, to validate the server certificate.', { hosts: remoteHosts }); } diff --git a/Source/connection/for_ChronicleConnection/when_warning_about_tls_validation/tls_validation_warning.spec.ts b/Source/connection/for_ChronicleConnection/when_warning_about_tls_validation/tls_validation_warning.spec.ts index d7803358..bb636957 100644 --- a/Source/connection/for_ChronicleConnection/when_warning_about_tls_validation/tls_validation_warning.spec.ts +++ b/Source/connection/for_ChronicleConnection/when_warning_about_tls_validation/tls_validation_warning.spec.ts @@ -2,14 +2,15 @@ // Licensed under the MIT license. See LICENSE file in the project root for full license information. import { afterEach, describe, expect, it } from 'vitest'; +import { credentials as grpcCredentials, type ChannelCredentials } from '@grpc/grpc-js'; import { ChronicleConnection } from '../../ChronicleConnection.js'; import type { ChronicleLogEntry } from '../../../logging/ChronicleLogEntry.js'; const connections: ChronicleConnection[] = []; -async function connectionWarnings(connectionString: string, resets = 0): Promise { +async function connectionWarnings(connectionString: string, resets = 0, credentials?: ChannelCredentials): Promise { const entries: ChronicleLogEntry[] = []; - const connection = new ChronicleConnection({ connectionString, logger: { log: entry => { entries.push(entry); } } }); + const connection = new ChronicleConnection({ connectionString, credentials, logger: { log: entry => { entries.push(entry); } } }); connections.push(connection); await connection.resetChannel(); for (let i = 0; i < resets; i++) await connection.resetChannel(); @@ -45,4 +46,15 @@ describe('TLS validation warning', () => { it('does not warn again on reconnect', async () => { expect(await connectionWarnings('chronicle://chronicle.example.com:35000', 2)).toHaveLength(1); }); + + it('warns about OAuth token requests only when custom credentials are used with token auth', async () => { + const warnings = await connectionWarnings('chronicle://client:secret@chronicle.example.com:35000', 0, grpcCredentials.createInsecure()); + expect(warnings).toHaveLength(1); + expect(warnings[0].message).toContain('OAuth token requests to chronicle.example.com'); + expect(warnings[0].message).not.toContain('gRPC channel'); + }); + + it('does not warn when custom credentials are used with an API key', async () => { + expect(await connectionWarnings('chronicle://chronicle.example.com:35000?apiKey=key', 0, grpcCredentials.createInsecure())).toHaveLength(0); + }); }); From 193c6c6947c2864eaff64dd5ae247da352ab7f7c Mon Sep 17 00:00:00 2001 From: woksin Date: Thu, 29 Oct 2026 11:30:40 +0100 Subject: [PATCH 3/3] Warn about remote OAuth authority skipping TLS validation --- Source/connection/ChronicleConnection.ts | 34 +++++++++++-------- .../tls_validation_warning.spec.ts | 24 +++++++++++-- 2 files changed, 42 insertions(+), 16 deletions(-) diff --git a/Source/connection/ChronicleConnection.ts b/Source/connection/ChronicleConnection.ts index 5fc25aeb..58919a31 100644 --- a/Source/connection/ChronicleConnection.ts +++ b/Source/connection/ChronicleConnection.ts @@ -343,24 +343,30 @@ export class ChronicleConnection implements ChronicleServices { if (this._warnedAboutSkippedTlsValidation) return; if (this._connectionString.disableTls || !this._connectionString.skipTlsValidation) return; - // Custom credentials replace the gRPC channel's TLS settings, but OAuth token requests - // (every mode except an API key) still skip validation. - const channelSkipsValidation = !this._options.credentials; - const tokensSkipValidation = !this._connectionString.apiKey; - if (!channelSkipsValidation && !tokensSkipValidation) return; - - const hostnames = !channelSkipsValidation && this._options.authority - ? [new URL(this._options.authority).hostname] - : candidates.map(candidate => candidate.host); - const remoteHosts = [...new Set(hostnames.filter(host => !isLoopbackHost(host)))]; - if (remoteHosts.length === 0) return; + const remoteOnly = (hosts: string[]) => [...new Set(hosts.filter(host => !isLoopbackHost(host)))]; + const candidateHosts = candidates.map(candidate => candidate.host); + + // Custom credentials replace the gRPC channel's TLS settings. + const channelHosts = this._options.credentials ? [] : remoteOnly(candidateHosts); + // OAuth token requests (every mode except an API key) always skip validation, against + // the explicit authority when set, otherwise the selected server. + const tokenHosts = this._connectionString.apiKey + ? [] + : remoteOnly(this._options.authority ? [new URL(this._options.authority).hostname] : candidateHosts); + if (channelHosts.length === 0 && tokenHosts.length === 0) return; this._warnedAboutSkippedTlsValidation = true; - const scope = channelSkipsValidation ? 'the gRPC channel and OAuth token requests' : 'OAuth token requests'; + const sameHosts = channelHosts.length === tokenHosts.length && channelHosts.every(host => tokenHosts.includes(host)); + const scope = sameHosts + ? `the gRPC channel and OAuth token requests to ${channelHosts.join(', ')}` + : [ + channelHosts.length > 0 ? `the gRPC channel to ${channelHosts.join(', ')}` : undefined, + tokenHosts.length > 0 ? `OAuth token requests to ${tokenHosts.join(', ')}` : undefined + ].filter(part => part).join(' and '); createLogger('@cratis/chronicle/ChronicleConnection', this.logger).warn( - `TLS certificate validation is disabled for ${scope} to ${remoteHosts.join(', ')}. ` + + `TLS certificate validation is disabled for ${scope}. ` + 'Set skipTlsValidation=false in the connection string, or skipTlsValidation = false on ChronicleConnectionStringBuilder, to validate the server certificate.', - { hosts: remoteHosts }); + { hosts: [...new Set([...channelHosts, ...tokenHosts])] }); } private createTokenProvider(selected: ChronicleServerAddress): ITokenProvider { diff --git a/Source/connection/for_ChronicleConnection/when_warning_about_tls_validation/tls_validation_warning.spec.ts b/Source/connection/for_ChronicleConnection/when_warning_about_tls_validation/tls_validation_warning.spec.ts index bb636957..5a2f7bfe 100644 --- a/Source/connection/for_ChronicleConnection/when_warning_about_tls_validation/tls_validation_warning.spec.ts +++ b/Source/connection/for_ChronicleConnection/when_warning_about_tls_validation/tls_validation_warning.spec.ts @@ -8,9 +8,9 @@ import type { ChronicleLogEntry } from '../../../logging/ChronicleLogEntry.js'; const connections: ChronicleConnection[] = []; -async function connectionWarnings(connectionString: string, resets = 0, credentials?: ChannelCredentials): Promise { +async function connectionWarnings(connectionString: string, resets = 0, credentials?: ChannelCredentials, authority?: string): Promise { const entries: ChronicleLogEntry[] = []; - const connection = new ChronicleConnection({ connectionString, credentials, logger: { log: entry => { entries.push(entry); } } }); + const connection = new ChronicleConnection({ connectionString, credentials, authority, logger: { log: entry => { entries.push(entry); } } }); connections.push(connection); await connection.resetChannel(); for (let i = 0; i < resets; i++) await connection.resetChannel(); @@ -57,4 +57,24 @@ describe('TLS validation warning', () => { it('does not warn when custom credentials are used with an API key', async () => { expect(await connectionWarnings('chronicle://chronicle.example.com:35000?apiKey=key', 0, grpcCredentials.createInsecure())).toHaveLength(0); }); + + it('warns about token requests only for a remote authority with a loopback server', async () => { + const warnings = await connectionWarnings('chronicle://localhost:35000', 0, undefined, 'https://auth.example.com'); + expect(warnings).toHaveLength(1); + expect(warnings[0].message).toContain('OAuth token requests to auth.example.com'); + expect(warnings[0].message).not.toContain('gRPC channel'); + }); + + it('mentions only the gRPC channel for an API key with a remote host', async () => { + const warnings = await connectionWarnings('chronicle://chronicle.example.com:35000?apiKey=key'); + expect(warnings).toHaveLength(1); + expect(warnings[0].message).toContain('the gRPC channel to chronicle.example.com'); + expect(warnings[0].message).not.toContain('OAuth'); + }); + + it('names both hosts for a remote server and a different remote authority', async () => { + const warnings = await connectionWarnings('chronicle://chronicle.example.com:35000', 0, undefined, 'https://auth.example.com'); + expect(warnings).toHaveLength(1); + expect(warnings[0].message).toContain('the gRPC channel to chronicle.example.com and OAuth token requests to auth.example.com'); + }); });