Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions Documentation/connecting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion Source/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
37 changes: 37 additions & 0 deletions Source/connection/ChronicleConnection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;

constructor(private readonly _options: ChronicleConnectionOptions) {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -337,6 +339,36 @@ export class ChronicleConnection implements ChronicleServices {
};
}

private warnWhenTlsValidationIsSkipped(candidates: ChronicleServerAddress[]): void {
if (this._warnedAboutSkippedTlsValidation) return;
if (this._connectionString.disableTls || !this._connectionString.skipTlsValidation) 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 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}. ` +
'Set skipTlsValidation=false in the connection string, or skipTlsValidation = false on ChronicleConnectionStringBuilder, to validate the server certificate.',
{ hosts: [...new Set([...channelHosts, ...tokenHosts])] });
}

private createTokenProvider(selected: ChronicleServerAddress): ITokenProvider {
const hasUsername = !!this._connectionString.username;
const hasApiKey = !!this._connectionString.apiKey;
Expand Down Expand Up @@ -444,6 +476,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<string | undefined>, signal?: AbortSignal): Promise<string | undefined> {
if (signal?.aborted) throw signal.reason;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// 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 { 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, credentials?: ChannelCredentials, authority?: string): Promise<ChronicleLogEntry[]> {
const entries: ChronicleLogEntry[] = [];
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();
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);
});

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);
});

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');
});
});
Loading