Skip to content

Commit 4281a94

Browse files
committed
feat(sea): expose TLS verification toggle + custom CA on ConnectionOptions
Surface the kernel/napi TLS knobs on the public SEA ConnectionOptions so NodeJS callers reach the connectivity-parity TLS controls: - checkServerCertificate?: boolean — default false. Matches the legacy Thrift driver's permissive rejectUnauthorized:false (accept self-signed/untrusted/expired + skip hostname check) for drop-in parity. Set true for strict validation (JDBC/ODBC parity). - customCaCert?: Buffer | string — PEM CA added to the trust store on top of system roots, for corporate TLS-inspecting proxies / on-prem internal CAs. Honoured regardless of checkServerCertificate. A PEM string is normalised to a Buffer before crossing the FFI boundary. buildSeaTlsOptions validates customCaCert (PEM header / non-empty) and SeaBackend.connect emits a one-line DEBUG note whenever verification is left disabled, so an insecure connection is discoverable in logs without being noisy on every connect (the default is deliberately permissive for thrift parity). mTLS (clientCert/clientKey) is intentionally out of scope. Pairs with the kernel napi change exposing checkServerCertificate + customCaCert. 215 -> 224 SEA unit tests pass (14 new TLS/log cases). Co-authored-by: Isaac Signed-off-by: Madhavendra Rathore <madhavendra.rathore@databricks.com>
1 parent b4ab849 commit 4281a94

6 files changed

Lines changed: 320 additions & 6 deletions

File tree

lib/contracts/IDBSQLClient.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,34 @@ export type ConnectionOptions = {
6060
* @internal Not stable; M0 stub only.
6161
*/
6262
useSEA?: boolean;
63+
/**
64+
* Whether to verify the server's TLS certificate (SEA backend only).
65+
*
66+
* Defaults to `false`, which matches the legacy NodeJS Thrift driver's
67+
* permissive behaviour (`rejectUnauthorized: false`): self-signed,
68+
* untrusted, and expired certificates are accepted and the
69+
* hostname-vs-certificate check is skipped. This is **insecure** — it
70+
* provides no protection against active man-in-the-middle attacks — but
71+
* is the historical NodeJS-driver default, so the SEA backend matches it
72+
* for drop-in compatibility.
73+
*
74+
* Set to `true` for strict validation against the system trust store
75+
* (full chain + expiry + hostname), matching the JDBC/ODBC drivers and
76+
* every modern HTTPS client. Recommended for production.
77+
*
78+
* For corporate TLS-inspecting proxies or on-prem deployments with an
79+
* internal CA, prefer `checkServerCertificate: true` together with
80+
* `customCaCert` over disabling verification entirely.
81+
*/
82+
checkServerCertificate?: boolean;
83+
/**
84+
* PEM-encoded CA certificate to add to the trust store on top of the
85+
* system roots (SEA backend only). Accepts a PEM string or its raw
86+
* `Buffer` bytes. Use this for a corporate proxy that re-signs TLS or an
87+
* on-prem Databricks deployment that uses an internal CA. Honoured
88+
* regardless of `checkServerCertificate`.
89+
*/
90+
customCaCert?: Buffer | string;
6391
} & AuthOptions;
6492

6593
export interface OpenSessionRequest {

lib/sea/SeaAuth.ts

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,28 @@ export interface SeaSessionDefaults {
8686
complexTypesAsJson?: boolean;
8787
}
8888

89+
/**
90+
* TLS options shared across all auth-mode variants. Mirror the napi
91+
* binding's `ConnectionOptions.checkServerCertificate` / `.customCaCert`
92+
* (kernel `Session::builder().tls(TlsConfig)`).
93+
*
94+
* The napi shape takes `customCaCert` as a `Buffer` only; the public
95+
* `ConnectionOptions` additionally accepts a PEM string, which
96+
* `buildSeaConnectionOptions` normalises to a `Buffer` before crossing
97+
* the FFI boundary.
98+
*/
99+
export interface SeaTlsOptions {
100+
/**
101+
* Verify the server cert. Omitted ⇒ napi default (permissive,
102+
* thrift-compatible). See `ConnectionOptions.checkServerCertificate`.
103+
*/
104+
checkServerCertificate?: boolean;
105+
/** PEM-encoded CA bytes to add to the trust store. */
106+
customCaCert?: Buffer;
107+
}
108+
89109
export type SeaNativeConnectionOptions = SeaSessionDefaults &
110+
SeaTlsOptions &
90111
(
91112
| {
92113
hostName: string;
@@ -132,6 +153,55 @@ export function isBlankOrReserved(s: string): boolean {
132153
return normalized.length === 0 || normalized === 'undefined' || normalized === 'null';
133154
}
134155

156+
/**
157+
* Normalise the public TLS options (`checkServerCertificate` /
158+
* `customCaCert`) into the napi shape.
159+
*
160+
* - `checkServerCertificate` passes through verbatim (only when set; an
161+
* absent value leaves the napi default, which is permissive /
162+
* thrift-compatible).
163+
* - `customCaCert` accepts a PEM string or `Buffer` on the public
164+
* surface; we convert a string to a `Buffer` here and do a light PEM
165+
* sanity check. The bytes are NOT parsed in JS — the kernel returns a
166+
* meaningful error if the PEM is malformed.
167+
*
168+
* Throws `HiveDriverError` when `customCaCert` is supplied but empty or
169+
* (for strings) lacks a PEM certificate header.
170+
*/
171+
export function buildSeaTlsOptions(options: ConnectionOptions): SeaTlsOptions {
172+
const { checkServerCertificate, customCaCert } = options as {
173+
checkServerCertificate?: boolean;
174+
customCaCert?: Buffer | string;
175+
};
176+
177+
const tls: SeaTlsOptions = {};
178+
179+
if (checkServerCertificate !== undefined) {
180+
tls.checkServerCertificate = checkServerCertificate;
181+
}
182+
183+
if (customCaCert !== undefined) {
184+
if (typeof customCaCert === 'string') {
185+
if (!customCaCert.includes('-----BEGIN CERTIFICATE-----')) {
186+
throw new HiveDriverError(
187+
'SEA backend: `customCaCert` string does not look like a PEM certificate ' +
188+
"(missing '-----BEGIN CERTIFICATE-----'). Pass PEM text or a Buffer of PEM bytes.",
189+
);
190+
}
191+
tls.customCaCert = Buffer.from(customCaCert, 'utf8');
192+
} else if (Buffer.isBuffer(customCaCert)) {
193+
if (customCaCert.length === 0) {
194+
throw new HiveDriverError('SEA backend: `customCaCert` Buffer is empty.');
195+
}
196+
tls.customCaCert = customCaCert;
197+
} else {
198+
throw new HiveDriverError('SEA backend: `customCaCert` must be a PEM string or a Buffer.');
199+
}
200+
}
201+
202+
return tls;
203+
}
204+
135205
/**
136206
* Validate the user-supplied `ConnectionOptions` and build the
137207
* napi-binding's connection-options shape.
@@ -186,6 +256,10 @@ export function buildSeaConnectionOptions(options: ConnectionOptions): SeaNative
186256
// the kernel default (native Arrow) — they already decode identically
187257
// to Thrift via the shared Arrow converter.
188258
intervalsAsString: true,
259+
// TLS knobs (server-cert verification toggle + custom CA). Validated
260+
// and normalised (string PEM → Buffer) here so the napi shape only
261+
// ever sees a Buffer.
262+
...buildSeaTlsOptions(options),
189263
};
190264

191265
const oauth = options as {
@@ -200,7 +274,7 @@ export function buildSeaConnectionOptions(options: ConnectionOptions): SeaNative
200274
const { token } = options as { token?: string };
201275
if (typeof token !== 'string' || isBlankOrReserved(token)) {
202276
throw new AuthenticationError(
203-
'SEA backend: a non-empty PAT must be supplied via `token` when using `authType: \'access-token\'`.',
277+
"SEA backend: a non-empty PAT must be supplied via `token` when using `authType: 'access-token'`.",
204278
);
205279
}
206280
if (oauth.oauthClientId !== undefined || oauth.oauthClientSecret !== undefined) {

lib/sea/SeaBackend.ts

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,9 @@ import IBackend from '../contracts/IBackend';
1616
import ISessionBackend from '../contracts/ISessionBackend';
1717
import IClientContext from '../contracts/IClientContext';
1818
import { ConnectionOptions, OpenSessionRequest } from '../contracts/IDBSQLClient';
19+
import { LogLevel } from '../contracts/IDBSQLLogger';
1920
import HiveDriverError from '../errors/HiveDriverError';
20-
import {
21-
getSeaNative,
22-
SeaNativeBinding,
23-
SeaNativeConnection,
24-
} from './SeaNativeLoader';
21+
import { getSeaNative, SeaNativeBinding, SeaNativeConnection } from './SeaNativeLoader';
2522
import { decodeNapiKernelError } from './SeaErrorMapping';
2623
import { buildSeaConnectionOptions, SeaNativeConnectionOptions } from './SeaAuth';
2724
import SeaSessionBackend from './SeaSessionBackend';
@@ -82,6 +79,25 @@ export default class SeaBackend implements IBackend {
8279
// Any non-PAT mode (or a missing/empty token) throws here, before
8380
// we ever touch the native binding.
8481
this.nativeOptions = buildSeaConnectionOptions(options);
82+
83+
// Server-cert verification defaults to OFF (thrift-compatible). When
84+
// it's not explicitly enabled, emit a one-line DEBUG note so an
85+
// insecure connection is discoverable in logs without being noisy on
86+
// every connect — the default is deliberately permissive for thrift
87+
// parity, so this stays at debug level rather than warn.
88+
if (this.nativeOptions.checkServerCertificate !== true) {
89+
this.context
90+
?.getLogger()
91+
.log(
92+
LogLevel.debug,
93+
'SEA backend: TLS server-certificate verification is DISABLED ' +
94+
'(checkServerCertificate is not set to true). The connection accepts ' +
95+
'self-signed/untrusted/expired certs and skips the hostname check — this ' +
96+
'matches the legacy Thrift driver but offers no protection against active ' +
97+
'man-in-the-middle attacks. Set `checkServerCertificate: true` for strict ' +
98+
'validation, optionally with `customCaCert` for corporate/on-prem CAs.',
99+
);
100+
}
85101
}
86102

87103
public async openSession(request: OpenSessionRequest): Promise<ISessionBackend> {

native/sea/index.d.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,32 @@ export interface ConnectionOptions {
233233
* already renders identically to the Thrift path).
234234
*/
235235
complexTypesAsJson?: boolean
236+
/**
237+
* Whether to verify the server's TLS certificate.
238+
*
239+
* Omitted / `false` ⇒ the driver matches the legacy NodeJS Thrift
240+
* driver's permissive behaviour (`rejectUnauthorized: false`): it
241+
* accepts self-signed / untrusted / expired certs AND skips the
242+
* hostname-vs-SNI check. This is **insecure** (no protection
243+
* against active MITM) but is the historical NodeJS-driver default
244+
* since 2020, so the SEA backend matches it for drop-in parity.
245+
*
246+
* `true` ⇒ strict validation against the system / Mozilla trust
247+
* store (full chain + expiry + hostname), matching JDBC / ODBC and
248+
* every modern HTTPS client. Recommended.
249+
*
250+
* Maps onto the kernel [`TlsConfig::accept_self_signed`] +
251+
* [`TlsConfig::skip_hostname_verification`] (both = `!check`).
252+
*/
253+
checkServerCertificate?: boolean
254+
/**
255+
* PEM-encoded CA certificate bytes to add to the trust store on
256+
* top of the system roots. Use for corporate TLS-inspecting
257+
* proxies that re-sign TLS, or on-prem deployments with an
258+
* internal CA. Honoured regardless of `check_server_certificate`.
259+
* Maps onto the kernel [`TlsConfig::custom_ca_cert`].
260+
*/
261+
customCaCert?: Buffer
236262
}
237263
/**
238264
* Open a Databricks SQL session and return an opaque `Connection`
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
// Copyright (c) 2026 Databricks, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
import { expect } from 'chai';
16+
import SeaBackend from '../../../lib/sea/SeaBackend';
17+
import { ConnectionOptions } from '../../../lib/contracts/IDBSQLClient';
18+
import { LogLevel } from '../../../lib/contracts/IDBSQLLogger';
19+
20+
interface LoggedLine {
21+
level: LogLevel;
22+
message: unknown;
23+
}
24+
25+
/** Minimal IClientContext stub that records logger calls. */
26+
function fakeContext(sink: LoggedLine[]) {
27+
const logger = {
28+
log(level: LogLevel, message: unknown) {
29+
sink.push({ level, message });
30+
},
31+
};
32+
return { getLogger: () => logger } as any;
33+
}
34+
35+
function patOpts(extra: Partial<ConnectionOptions> = {}): ConnectionOptions {
36+
return {
37+
host: 'example.cloud.databricks.com',
38+
path: '/sql/1.0/warehouses/abc',
39+
token: 'dapi-fake-pat',
40+
...extra,
41+
} as ConnectionOptions;
42+
}
43+
44+
describe('SeaBackend — TLS verification log', () => {
45+
it('logs a debug note when server-cert verification is left at the default (disabled)', async () => {
46+
const lines: LoggedLine[] = [];
47+
const backend = new SeaBackend({ context: fakeContext(lines), nativeBinding: {} as any });
48+
49+
await backend.connect(patOpts());
50+
51+
const notes = lines.filter((l) => l.level === LogLevel.debug);
52+
expect(notes).to.have.lengthOf(1);
53+
expect(String(notes[0].message)).to.match(/verification is DISABLED/);
54+
});
55+
56+
it('logs a debug note when checkServerCertificate is explicitly false', async () => {
57+
const lines: LoggedLine[] = [];
58+
const backend = new SeaBackend({ context: fakeContext(lines), nativeBinding: {} as any });
59+
60+
await backend.connect(patOpts({ checkServerCertificate: false }));
61+
62+
expect(lines.filter((l) => l.level === LogLevel.debug)).to.have.lengthOf(1);
63+
});
64+
65+
it('does NOT log when checkServerCertificate is true', async () => {
66+
const lines: LoggedLine[] = [];
67+
const backend = new SeaBackend({ context: fakeContext(lines), nativeBinding: {} as any });
68+
69+
await backend.connect(patOpts({ checkServerCertificate: true }));
70+
71+
expect(lines.filter((l) => l.level === LogLevel.debug)).to.have.lengthOf(0);
72+
});
73+
});

tests/unit/sea/tls-options.test.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
// Copyright (c) 2026 Databricks, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
import { expect } from 'chai';
16+
import { buildSeaConnectionOptions, buildSeaTlsOptions } from '../../../lib/sea/SeaAuth';
17+
import { ConnectionOptions } from '../../../lib/contracts/IDBSQLClient';
18+
import HiveDriverError from '../../../lib/errors/HiveDriverError';
19+
20+
const PEM = '-----BEGIN CERTIFICATE-----\nMIIBfakebase64\n-----END CERTIFICATE-----\n';
21+
22+
function patOpts(extra: Partial<ConnectionOptions> = {}): ConnectionOptions {
23+
return {
24+
host: 'example.cloud.databricks.com',
25+
path: '/sql/1.0/warehouses/abc',
26+
token: 'dapi-fake-pat',
27+
...extra,
28+
} as ConnectionOptions;
29+
}
30+
31+
describe('SeaAuth — TLS options', () => {
32+
describe('buildSeaTlsOptions', () => {
33+
it('returns an empty object when no TLS options are set (thrift-compatible default)', () => {
34+
expect(buildSeaTlsOptions(patOpts())).to.deep.equal({});
35+
});
36+
37+
it('passes checkServerCertificate: true through', () => {
38+
expect(buildSeaTlsOptions(patOpts({ checkServerCertificate: true }))).to.deep.equal({
39+
checkServerCertificate: true,
40+
});
41+
});
42+
43+
it('passes checkServerCertificate: false through explicitly', () => {
44+
expect(buildSeaTlsOptions(patOpts({ checkServerCertificate: false }))).to.deep.equal({
45+
checkServerCertificate: false,
46+
});
47+
});
48+
49+
it('converts a PEM string customCaCert to a Buffer', () => {
50+
const tls = buildSeaTlsOptions(patOpts({ customCaCert: PEM }));
51+
expect(Buffer.isBuffer(tls.customCaCert)).to.equal(true);
52+
expect(tls.customCaCert!.toString('utf8')).to.equal(PEM);
53+
});
54+
55+
it('passes a Buffer customCaCert through unchanged', () => {
56+
const buf = Buffer.from(PEM, 'utf8');
57+
const tls = buildSeaTlsOptions(patOpts({ customCaCert: buf }));
58+
expect(tls.customCaCert).to.equal(buf);
59+
});
60+
61+
it('honours customCaCert regardless of checkServerCertificate', () => {
62+
const tls = buildSeaTlsOptions(patOpts({ checkServerCertificate: true, customCaCert: PEM }));
63+
expect(tls.checkServerCertificate).to.equal(true);
64+
expect(Buffer.isBuffer(tls.customCaCert)).to.equal(true);
65+
});
66+
67+
it('throws on a customCaCert string without a PEM header', () => {
68+
expect(() => buildSeaTlsOptions(patOpts({ customCaCert: 'not-a-pem' }))).to.throw(
69+
HiveDriverError,
70+
/does not look like a PEM certificate/,
71+
);
72+
});
73+
74+
it('throws on an empty customCaCert Buffer', () => {
75+
expect(() => buildSeaTlsOptions(patOpts({ customCaCert: Buffer.alloc(0) }))).to.throw(HiveDriverError, /empty/);
76+
});
77+
});
78+
79+
describe('buildSeaConnectionOptions integration', () => {
80+
it('omits TLS keys entirely when not supplied', () => {
81+
const native = buildSeaConnectionOptions(patOpts());
82+
expect(native).to.not.have.property('checkServerCertificate');
83+
expect(native).to.not.have.property('customCaCert');
84+
});
85+
86+
it('threads TLS options onto the napi shape alongside auth', () => {
87+
const native = buildSeaConnectionOptions(patOpts({ checkServerCertificate: true, customCaCert: PEM }));
88+
expect(native.authMode).to.equal('Pat');
89+
expect(native.checkServerCertificate).to.equal(true);
90+
expect(native.customCaCert!.toString('utf8')).to.equal(PEM);
91+
});
92+
93+
it('propagates customCaCert validation errors', () => {
94+
expect(() => buildSeaConnectionOptions(patOpts({ customCaCert: 'garbage' }))).to.throw(HiveDriverError);
95+
});
96+
});
97+
});

0 commit comments

Comments
 (0)