Skip to content

Commit 4ddb26e

Browse files
authored
fix(sqlserver): support verify-full TLS mode (#443)
* fix(sqlserver): support verify-full TLS mode Enable certificate and hostname verification when sslmode=verify-full is explicit. Accept the mode in TOML, document Node trust configuration, and add focused parser and configuration tests. Preserve existing defaults and parsing behavior. * fix(sqlserver): reject invalid and duplicate sslmode values
1 parent 4f31da0 commit 4ddb26e

9 files changed

Lines changed: 175 additions & 16 deletions

File tree

‎CLAUDE.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ outside TOML and follow the same order:
176176
- SQL Server (NTLM): `sqlserver://user:password@localhost:1433/dbname?authentication=ntlm&domain=MYDOMAIN`
177177
- Oracle: `oracle://user:password@localhost:1521/FREEPDB1` (path is the service name; `?sid=ORCL` for a SID; `sslmode=require`/`verify-full` switch to TCPS)
178178
- SQLite: `sqlite:///path/to/database.db` or `sqlite:///:memory:`
179-
- SSL modes: `sslmode=disable` (no SSL), `sslmode=require` (SSL without cert verification), `sslmode=verify-ca` (PostgreSQL only, CA verification), `sslmode=verify-full` (PostgreSQL and Oracle, CA + hostname verification). Use `sslrootcert` to specify CA certificate path for verify modes. PostgreSQL client certificate auth: `sslcert` + `sslkey` (PEM, unencrypted, both required; `sslmode` must be `require`, `verify-ca` or `verify-full` — `disable` or unset is rejected) in the DSN or TOML source.
179+
- SSL modes: `sslmode=disable` (no SSL), `sslmode=require` (SSL without cert verification), `sslmode=verify-ca` (PostgreSQL only, CA verification), `sslmode=verify-full` (PostgreSQL, SQL Server and Oracle, CA + hostname verification). On PostgreSQL, use `sslrootcert` to specify CA certificate path for verify modes. PostgreSQL client certificate auth: `sslcert` + `sslkey` (PEM, unencrypted, both required; `sslmode` must be `require`, `verify-ca` or `verify-full` — `disable` or unset is rejected) in the DSN or TOML source.
180180

181181
## Testing Approach
182182

‎dbhub.toml.example‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,7 @@ dsn = "postgres://postgres:postgres@localhost:5432/myapp"
190190
# user = "sa"
191191
# password = "YourStrong@Passw0rd"
192192
# instanceName = "SQLEXPRESS" # Optional: for named instances
193-
# sslmode = "disable" # Optional: "disable" or "require"
193+
# sslmode = "disable" # Optional: "disable", "require" or "verify-full"
194194

195195
# SQL Server with Windows/NTLM authentication (DSN format)
196196
# [[sources]]
@@ -423,10 +423,12 @@ dsn = "postgres://postgres:postgres@localhost:5432/myapp"
423423
# sslmode = "disable" # No SSL
424424
# sslmode = "require" # SSL without certificate verification
425425
# sslmode = "verify-ca" # SSL with CA certificate verification (PostgreSQL only)
426-
# sslmode = "verify-full" # SSL with CA + hostname verification (PostgreSQL only)
427-
# sslrootcert = "~/.ssl/ca.pem" # CA certificate path (requires verify-ca or verify-full)
426+
# sslmode = "verify-full" # SSL with CA + hostname verification (PostgreSQL, SQL Server, Oracle)
427+
# sslrootcert = "~/.ssl/ca.pem" # CA certificate path (PostgreSQL only; requires verify-ca or verify-full)
428428
# sslcert = "~/.ssl/client.crt" # PEM client certificate (PostgreSQL only; set with sslkey, needs sslmode require/verify-*)
429429
# sslkey = "~/.ssl/client.key" # Unencrypted PEM private key for sslcert (PostgreSQL only)
430+
# SQL Server verify-full uses Node's trust roots; set NODE_EXTRA_CA_CERTS
431+
# to a PEM CA bundle before process startup if an additional CA is needed.
430432
#
431433
# SQL Server Authentication:
432434
# authentication = "ntlm" # Windows/NTLM auth (requires domain)

‎docs/config/command-line.mdx‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -268,14 +268,14 @@ This page covers command-line flags and environment variables. For TOML configur
268268
| PostgreSQL | ✅ | ✅ | ✅ | ✅ | Certificate verification |
269269
| MySQL | ✅ | ✅ | ❌ | ❌ | Certificate verification |
270270
| MariaDB | ✅ | ✅ | ❌ | ❌ | Certificate verification |
271-
| SQL Server | ✅ | ✅ | ❌ | ❌ | Certificate verification |
271+
| SQL Server | ✅ | ✅ | ❌ | ✅ | Certificate verification |
272272
| Oracle | ✅ | ✅ | ❌ | ✅ | Plain TCP (`tcps://` only with `require`/`verify-full`) |
273273
| SQLite | ❌ | ❌ | ❌ | ❌ | N/A (file-based) |
274274

275275
- `sslmode=disable`: All SSL/TLS encryption is turned off. Data is transmitted in plaintext.
276276
- `sslmode=require`: Connection is encrypted, but the server's certificate is not verified.
277277
- `sslmode=verify-ca`: SSL with CA certificate verification, but no hostname check. **PostgreSQL only.** Use `sslrootcert` to specify the CA certificate path.
278-
- `sslmode=verify-full`: SSL with CA certificate and hostname verification. **PostgreSQL and Oracle.** On PostgreSQL use `sslrootcert` to specify the CA certificate path; on Oracle the server certificate is validated against the system trust store.
278+
- `sslmode=verify-full`: SSL with CA certificate and hostname verification. **PostgreSQL, SQL Server, and Oracle.** On PostgreSQL use `sslrootcert` to specify the CA certificate path. SQL Server uses Node's configured trust roots; set `NODE_EXTRA_CA_CERTS` to a PEM CA bundle before process startup if an additional CA is needed. Oracle uses the system trust store.
279279

280280
<Note>
281281
Unlike libpq, DBHub does not upgrade `sslmode=require` to `verify-ca` when `sslrootcert` is present: that combination is rejected at startup, so set `sslmode=verify-ca` explicitly. libpq's `allow` and `prefer` modes are not supported for PostgreSQL either; use `require` (or a verify mode) instead.
@@ -437,4 +437,4 @@ npx @bytebase/dbhub@latest --dsn "..." \
437437
| `--ssh-password` | `SSH_PASSWORD` | string | SSH password |
438438
| `--ssh-key` | `SSH_KEY` | string | Path to SSH private key or base64-encoded key |
439439
| `--ssh-passphrase` | `SSH_PASSPHRASE` | string | SSH key passphrase |
440-
| `--ssh-proxy-jump` | `SSH_PROXY_JUMP` | string | ProxyJump hosts |
440+
| `--ssh-proxy-jump` | `SSH_PROXY_JUMP` | string | ProxyJump hosts |

‎docs/config/toml.mdx‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -363,9 +363,11 @@ Sources define database connections. Each source represents a database that DBHu
363363
- `disable` - No SSL/TLS encryption. Data is transmitted in plaintext.
364364
- `require` - SSL/TLS encryption enabled, but server certificate is not verified.
365365
- `verify-ca` - SSL with CA certificate verification (no hostname check). **PostgreSQL only.**
366-
- `verify-full` - SSL with CA certificate and hostname verification. **PostgreSQL and Oracle.**
366+
- `verify-full` - SSL with CA certificate and hostname verification. **PostgreSQL, SQL Server, and Oracle.**
367367

368-
Supported databases: PostgreSQL, MySQL, MariaDB, SQL Server, Oracle (not applicable to SQLite). `verify-ca` is PostgreSQL only; `verify-full` is supported on PostgreSQL and Oracle (Oracle validates the server certificate against the system trust store, and `sslrootcert` is PostgreSQL only).
368+
Supported databases: PostgreSQL, MySQL, MariaDB, SQL Server, Oracle (not applicable to SQLite). `verify-ca` is PostgreSQL only; `verify-full` is supported on PostgreSQL, SQL Server and Oracle (Oracle validates the server certificate against the system trust store, and `sslrootcert` is PostgreSQL only).
369+
370+
SQL Server `verify-full` uses Node's trust roots. Set `NODE_EXTRA_CA_CERTS` to a PEM CA bundle before process startup if an additional CA is needed.
369371

370372
```toml
371373
# Basic SSL (all network databases)

‎src/config/__tests__/toml-loader.test.ts‎

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
22
import { loadTomlConfig, buildDSNFromSource, interpolateEnvVars } from '../toml-loader.js';
33
import type { SourceConfig } from '../../types/config.js';
44
import { SQLiteConnector } from '../../connectors/sqlite/index.js';
5+
import { SQLServerConnector } from '../../connectors/sqlserver/index.js';
56
import fs from 'fs';
67
import path from 'path';
78
import os from 'os';
@@ -525,6 +526,80 @@ dsn = "postgres://user:pass@localhost:5432/testdb"
525526
});
526527

527528
describe('sslmode validation', () => {
529+
it('should preserve a matching encoded SQL Server sslmode', async () => {
530+
const dsn = 'sqlserver://user:pass@localhost:1433/db?%73slmode=verify%2Dfull';
531+
fs.writeFileSync(path.join(tempDir, 'dbhub.toml'), `
532+
[[sources]]
533+
id = "test_db"
534+
dsn = "${dsn}"
535+
sslmode = "verify-full"
536+
`);
537+
538+
const result = loadTomlConfig();
539+
const builtDSN = buildDSNFromSource(result!.sources[0]);
540+
expect(builtDSN).toBe(dsn);
541+
const config = await new SQLServerConnector().dsnParser.parse(builtDSN);
542+
expect(config.options?.encrypt).toBe(true);
543+
expect(config.options?.trustServerCertificate).toBe(false);
544+
});
545+
546+
it('should reject a conflicting encoded SQL Server sslmode without reflecting the input', () => {
547+
fs.writeFileSync(path.join(tempDir, 'dbhub.toml'), `
548+
[[sources]]
549+
id = "test_db"
550+
dsn = "sqlserver://user:pass@localhost:1433/db?%73slmode=disable"
551+
sslmode = "verify-full"
552+
`);
553+
554+
expect(() => loadTomlConfig()).toThrow(new Error(
555+
`Failed to load TOML configuration from ${path.join(tempDir, 'dbhub.toml')}: ` +
556+
'Conflicting SQL Server sslmode. Set sslmode in only one place, or make the two values match.'
557+
));
558+
});
559+
560+
it.each(['disable', 'verify-full', ''])(
561+
'should reject duplicate SQL Server modes after TOML processing (%j)',
562+
async (trailingMode) => {
563+
const dsn = `sqlserver://user:pass@localhost:1433/db?sslmode=verify-full&sslmode=${trailingMode}`;
564+
fs.writeFileSync(path.join(tempDir, 'dbhub.toml'), `
565+
[[sources]]
566+
id = "test_db"
567+
dsn = "${dsn}"
568+
sslmode = "verify-full"
569+
`);
570+
571+
const result = loadTomlConfig();
572+
const builtDSN = buildDSNFromSource(result!.sources[0]);
573+
expect(result?.sources[0].sslmode).toBe('verify-full');
574+
expect(builtDSN).toBe(dsn);
575+
await expect(new SQLServerConnector().dsnParser.parse(builtDSN)).rejects.toMatchObject({
576+
message: 'Failed to parse SQL Server DSN: Invalid sslmode. Specify exactly one value: disable, require, verify-full',
577+
});
578+
}
579+
);
580+
581+
it('should accept and propagate sslmode=verify-full for SQL Server', () => {
582+
const tomlContent = `
583+
[[sources]]
584+
id = "test_db"
585+
type = "sqlserver"
586+
host = "localhost"
587+
database = "db"
588+
user = "user"
589+
password = "pass"
590+
sslmode = "verify-full"
591+
`;
592+
fs.writeFileSync(path.join(tempDir, 'dbhub.toml'), tomlContent);
593+
594+
const result = loadTomlConfig();
595+
596+
expect(result).toBeTruthy();
597+
expect(result?.sources[0].sslmode).toBe('verify-full');
598+
expect(buildDSNFromSource(result!.sources[0])).toBe(
599+
'sqlserver://user:pass@localhost:1433/db?sslmode=verify-full'
600+
);
601+
});
602+
528603
it.each(['disable', 'require', 'verify-ca', 'verify-full'])(
529604
'should accept sslmode = %j for PostgreSQL',
530605
(sslmode) => {

‎src/config/toml-loader.ts‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,10 @@ function getRawDSNQueryParam(dsn: string, key: string): string | null {
239239
if (queryStart === -1) {
240240
return null;
241241
}
242+
// Match SQL Server's decoded sslmode keys so merging cannot invent a duplicate.
243+
if (key === "sslmode" && dsn.startsWith("sqlserver://")) {
244+
return new URLSearchParams(dsn.substring(queryStart + 1)).get(key);
245+
}
242246
for (const pair of dsn.substring(queryStart + 1).split("&")) {
243247
if (pair === "") {
244248
continue;
@@ -386,6 +390,9 @@ function validateDSNFieldConflicts(source: SourceConfig, configPath: string): vo
386390
// still treated as present and a conflicting field is rejected.
387391
const dsnSslmode = getRawDSNQueryParam(source.dsn!, "sslmode");
388392
if (source.sslmode && dsnSslmode !== null && dsnSslmode !== source.sslmode) {
393+
if (source.type === "sqlserver") {
394+
throw new Error("Conflicting SQL Server sslmode. Set sslmode in only one place, or make the two values match.");
395+
}
389396
conflict("sslmode", source.sslmode, dsnSslmode);
390397
}
391398

@@ -577,10 +584,11 @@ function validateSourceConfig(source: SourceConfig, configPath: string): void {
577584
);
578585
}
579586

580-
// verify-ca is PostgreSQL-only; Oracle's TCPS also offers verify-full
581-
// (server certificate DN matched against the host).
587+
// verify-ca is PostgreSQL-only; SQL Server and Oracle also support
588+
// verify-full for server certificate and hostname verification.
582589
const verifyModesByType: Record<string, string[]> = {
583590
postgres: ["verify-ca", "verify-full"],
591+
sqlserver: ["verify-full"],
584592
oracle: ["verify-full"],
585593
};
586594
if (

‎src/connectors/__tests__/dsn-parser.test.ts‎

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -384,6 +384,69 @@ describe('DSN Parser - SQL Server SSL/TLS Configuration', () => {
384384
expect(config.options?.encrypt).toBe(false);
385385
expect(config.options?.trustServerCertificate).toBe(false);
386386
});
387+
388+
it('should parse sslmode=verify-full correctly', async () => {
389+
const parser = new SQLServerConnector().dsnParser;
390+
const config = await parser.parse('sqlserver://user:pass@localhost:1433/db?sslmode=verify-full');
391+
392+
expect(config.options?.encrypt).toBe(true);
393+
expect(config.options?.trustServerCertificate).toBe(false);
394+
});
395+
396+
it.each([
397+
'sslmode=verify_ful',
398+
'sslmode=verify-ca',
399+
'sslmode=%20',
400+
'sslmode',
401+
'sslmode=',
402+
'sslmode=verify-full=extra',
403+
'sslmode=verify%3Dfull',
404+
'sslmode=%',
405+
'sslmode=%C3%28',
406+
'%73slmode=',
407+
'%73slmode=verify_ful',
408+
'sslmode=verify-full&sslmode=verify-full',
409+
'sslmode=verify-full&sslmode=disable',
410+
'sslmode=verify_ful&sslmode=disable',
411+
'sslmode=verify-full&sslmode=',
412+
'sslmode=&sslmode=verify-full',
413+
'sslmode=verify-full&%73slmode=disable',
414+
'%73slmode=verify%2Dfull&sslmode=disable',
415+
])('should reject invalid sslmode query %s with a fixed error', async (query) => {
416+
const parser = new SQLServerConnector().dsnParser;
417+
418+
await expect(
419+
parser.parse(`sqlserver://user:pass@localhost:1433/db?${query}`)
420+
).rejects.toMatchObject({
421+
message: 'Failed to parse SQL Server DSN: Invalid sslmode. Specify exactly one value: disable, require, verify-full',
422+
});
423+
});
424+
425+
it.each([
426+
['p@ss#word:&=+', 'p@ss#word:&=+'],
427+
['p%3Fsslmode%3Ddisable%26sslmode%3Dverify_ful', 'p?sslmode=disable&sslmode=verify_ful'],
428+
])('should preserve password %s with an encoded sslmode', async (password, decodedPassword) => {
429+
const parser = new SQLServerConnector().dsnParser;
430+
const config = await parser.parse(
431+
`sqlserver://user%40domain:${password}@localhost:1433/db?%73slmode=verify%2Dfull&instanceName=ENV1`,
432+
{ connectionTimeoutSeconds: 15, queryTimeoutSeconds: 30 }
433+
);
434+
435+
expect(config).toMatchObject({
436+
user: 'user@domain',
437+
password: decodedPassword,
438+
server: 'localhost',
439+
port: 1433,
440+
database: 'db',
441+
options: {
442+
encrypt: true,
443+
trustServerCertificate: false,
444+
instanceName: 'ENV1',
445+
connectTimeout: 15000,
446+
requestTimeout: 30000,
447+
},
448+
});
449+
});
387450
});
388451

389452
describe('DSN Parser - SQL Server NTLM Authentication', () => {

‎src/connectors/sqlserver/index.ts‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,18 +46,24 @@ export class SQLServerDSNParser implements DSNParser {
4646
}
4747

4848
try {
49+
// Inspect the same raw query as SafeURL before it drops empty/malformed
50+
// pairs or collapses duplicates. Decode query keys as well as values.
51+
const queryStart = dsn.indexOf("?");
52+
const sslmodes = new URLSearchParams(queryStart === -1 ? "" : dsn.substring(queryStart + 1)).getAll("sslmode");
53+
if (sslmodes.length > 1 || (sslmodes.length === 1 && !["disable", "require", "verify-full"].includes(sslmodes[0]))) {
54+
throw new Error("Invalid sslmode. Specify exactly one value: disable, require, verify-full");
55+
}
56+
4957
// Use the SafeURL helper to parse DSNs with special characters
5058
const url = new SafeURL(dsn);
5159

5260
// Parse additional options from query parameters
53-
const options: Record<string, any> = {};
61+
const options: Record<string, any> = { sslmode: sslmodes[0] };
5462

5563
// Process query parameters
5664
url.forEachSearchParam((value, key) => {
5765
if (key === "authentication") {
5866
options.authentication = value;
59-
} else if (key === "sslmode") {
60-
options.sslmode = value;
6167
} else if (key === "instanceName") {
6268
options.instanceName = value;
6369
} else if (key === "domain") {
@@ -81,6 +87,9 @@ export class SQLServerDSNParser implements DSNParser {
8187
} else if (options.sslmode === "require") {
8288
options.encrypt = true;
8389
options.trustServerCertificate = true;
90+
} else if (options.sslmode === "verify-full") {
91+
options.encrypt = true;
92+
options.trustServerCertificate = false;
8493
}
8594
// Default behavior (certificate verification) is handled by the default values below
8695
}

‎src/types/config.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,8 @@ export interface ConnectionParams {
3737
aws_region?: string; // AWS region required when aws_iam_auth is enabled
3838
aws_profile?: string; // Named AWS shared-config profile for RDS IAM auth
3939
instanceName?: string; // SQL Server named instance support
40-
sslmode?: "disable" | "require" | "verify-ca" | "verify-full"; // SSL mode for network databases (not applicable to SQLite, verify-* only applicable for PostgreSQL)
41-
sslrootcert?: string; // CA certificate path (requires verify-ca or verify-full)
40+
sslmode?: "disable" | "require" | "verify-ca" | "verify-full"; // SSL mode for network databases (not SQLite; verify-ca: PostgreSQL only; verify-full: PostgreSQL, SQL Server, Oracle)
41+
sslrootcert?: string; // CA certificate path (PostgreSQL only; requires verify-ca or verify-full)
4242
sslcert?: string; // PEM client certificate path for client certificate authentication (PostgreSQL only; requires sslkey and sslmode require/verify-ca/verify-full)
4343
sslkey?: string; // PEM private key path for sslcert (PostgreSQL only; unencrypted)
4444
// SQL Server authentication options

0 commit comments

Comments
 (0)