From bf1cd484ef84c40214d93b21c4d6815e3cba85a7 Mon Sep 17 00:00:00 2001 From: Ioannis-Marios Tsiakoulias Date: Tue, 29 Sep 2026 04:02:07 +0300 Subject: [PATCH 1/2] 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. --- CLAUDE.md | 2 +- dbhub.toml.example | 8 +++++--- docs/config/command-line.mdx | 6 +++--- docs/config/toml.mdx | 6 ++++-- src/config/__tests__/toml-loader.test.ts | 22 +++++++++++++++++++++ src/config/toml-loader.ts | 5 +++-- src/connectors/__tests__/dsn-parser.test.ts | 8 ++++++++ src/connectors/sqlserver/index.ts | 3 +++ src/types/config.ts | 4 ++-- 9 files changed, 51 insertions(+), 13 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3ae442ea..2eae68a8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -176,7 +176,7 @@ outside TOML and follow the same order: - SQL Server (NTLM): `sqlserver://user:password@localhost:1433/dbname?authentication=ntlm&domain=MYDOMAIN` - Oracle: `oracle://user:password@localhost:1521/FREEPDB1` (path is the service name; `?sid=ORCL` for a SID; `sslmode=require`/`verify-full` switch to TCPS) - SQLite: `sqlite:///path/to/database.db` or `sqlite:///:memory:` -- 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. +- 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. ## Testing Approach diff --git a/dbhub.toml.example b/dbhub.toml.example index e8e4b4ae..187c2cf1 100644 --- a/dbhub.toml.example +++ b/dbhub.toml.example @@ -190,7 +190,7 @@ dsn = "postgres://postgres:postgres@localhost:5432/myapp" # user = "sa" # password = "YourStrong@Passw0rd" # instanceName = "SQLEXPRESS" # Optional: for named instances -# sslmode = "disable" # Optional: "disable" or "require" +# sslmode = "disable" # Optional: "disable", "require" or "verify-full" # SQL Server with Windows/NTLM authentication (DSN format) # [[sources]] @@ -423,10 +423,12 @@ dsn = "postgres://postgres:postgres@localhost:5432/myapp" # sslmode = "disable" # No SSL # sslmode = "require" # SSL without certificate verification # sslmode = "verify-ca" # SSL with CA certificate verification (PostgreSQL only) -# sslmode = "verify-full" # SSL with CA + hostname verification (PostgreSQL only) -# sslrootcert = "~/.ssl/ca.pem" # CA certificate path (requires verify-ca or verify-full) +# sslmode = "verify-full" # SSL with CA + hostname verification (PostgreSQL, SQL Server, Oracle) +# sslrootcert = "~/.ssl/ca.pem" # CA certificate path (PostgreSQL only; requires verify-ca or verify-full) # sslcert = "~/.ssl/client.crt" # PEM client certificate (PostgreSQL only; set with sslkey, needs sslmode require/verify-*) # sslkey = "~/.ssl/client.key" # Unencrypted PEM private key for sslcert (PostgreSQL only) +# 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. # # SQL Server Authentication: # authentication = "ntlm" # Windows/NTLM auth (requires domain) diff --git a/docs/config/command-line.mdx b/docs/config/command-line.mdx index 8c375376..b3573590 100644 --- a/docs/config/command-line.mdx +++ b/docs/config/command-line.mdx @@ -268,14 +268,14 @@ This page covers command-line flags and environment variables. For TOML configur | PostgreSQL | ✅ | ✅ | ✅ | ✅ | Certificate verification | | MySQL | ✅ | ✅ | ❌ | ❌ | Certificate verification | | MariaDB | ✅ | ✅ | ❌ | ❌ | Certificate verification | - | SQL Server | ✅ | ✅ | ❌ | ❌ | Certificate verification | + | SQL Server | ✅ | ✅ | ❌ | ✅ | Certificate verification | | Oracle | ✅ | ✅ | ❌ | ✅ | Plain TCP (`tcps://` only with `require`/`verify-full`) | | SQLite | ❌ | ❌ | ❌ | ❌ | N/A (file-based) | - `sslmode=disable`: All SSL/TLS encryption is turned off. Data is transmitted in plaintext. - `sslmode=require`: Connection is encrypted, but the server's certificate is not verified. - `sslmode=verify-ca`: SSL with CA certificate verification, but no hostname check. **PostgreSQL only.** Use `sslrootcert` to specify the CA certificate path. - - `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. + - `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. 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 "..." \ | `--ssh-password` | `SSH_PASSWORD` | string | SSH password | | `--ssh-key` | `SSH_KEY` | string | Path to SSH private key or base64-encoded key | | `--ssh-passphrase` | `SSH_PASSPHRASE` | string | SSH key passphrase | -| `--ssh-proxy-jump` | `SSH_PROXY_JUMP` | string | ProxyJump hosts | \ No newline at end of file +| `--ssh-proxy-jump` | `SSH_PROXY_JUMP` | string | ProxyJump hosts | diff --git a/docs/config/toml.mdx b/docs/config/toml.mdx index 5e251a81..30f87d55 100644 --- a/docs/config/toml.mdx +++ b/docs/config/toml.mdx @@ -363,9 +363,11 @@ Sources define database connections. Each source represents a database that DBHu - `disable` - No SSL/TLS encryption. Data is transmitted in plaintext. - `require` - SSL/TLS encryption enabled, but server certificate is not verified. - `verify-ca` - SSL with CA certificate verification (no hostname check). **PostgreSQL only.** - - `verify-full` - SSL with CA certificate and hostname verification. **PostgreSQL and Oracle.** + - `verify-full` - SSL with CA certificate and hostname verification. **PostgreSQL, SQL Server, and Oracle.** - 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). + 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). + + 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. ```toml # Basic SSL (all network databases) diff --git a/src/config/__tests__/toml-loader.test.ts b/src/config/__tests__/toml-loader.test.ts index 344bc399..7715ee66 100644 --- a/src/config/__tests__/toml-loader.test.ts +++ b/src/config/__tests__/toml-loader.test.ts @@ -524,6 +524,28 @@ dsn = "postgres://user:pass@localhost:5432/testdb" }); describe('sslmode validation', () => { + it('should accept and propagate sslmode=verify-full for SQL Server', () => { + const tomlContent = ` +[[sources]] +id = "test_db" +type = "sqlserver" +host = "localhost" +database = "db" +user = "user" +password = "pass" +sslmode = "verify-full" +`; + fs.writeFileSync(path.join(tempDir, 'dbhub.toml'), tomlContent); + + const result = loadTomlConfig(); + + expect(result).toBeTruthy(); + expect(result?.sources[0].sslmode).toBe('verify-full'); + expect(buildDSNFromSource(result!.sources[0])).toBe( + 'sqlserver://user:pass@localhost:1433/db?sslmode=verify-full' + ); + }); + it.each(['disable', 'require', 'verify-ca', 'verify-full'])( 'should accept sslmode = %j for PostgreSQL', (sslmode) => { diff --git a/src/config/toml-loader.ts b/src/config/toml-loader.ts index 8d285c49..3e4b7774 100644 --- a/src/config/toml-loader.ts +++ b/src/config/toml-loader.ts @@ -577,10 +577,11 @@ function validateSourceConfig(source: SourceConfig, configPath: string): void { ); } - // verify-ca is PostgreSQL-only; Oracle's TCPS also offers verify-full - // (server certificate DN matched against the host). + // verify-ca is PostgreSQL-only; SQL Server and Oracle also support + // verify-full for server certificate and hostname verification. const verifyModesByType: Record = { postgres: ["verify-ca", "verify-full"], + sqlserver: ["verify-full"], oracle: ["verify-full"], }; if ( diff --git a/src/connectors/__tests__/dsn-parser.test.ts b/src/connectors/__tests__/dsn-parser.test.ts index 01fadb6d..0d99c8e2 100644 --- a/src/connectors/__tests__/dsn-parser.test.ts +++ b/src/connectors/__tests__/dsn-parser.test.ts @@ -384,6 +384,14 @@ describe('DSN Parser - SQL Server SSL/TLS Configuration', () => { expect(config.options?.encrypt).toBe(false); expect(config.options?.trustServerCertificate).toBe(false); }); + + it('should parse sslmode=verify-full correctly', async () => { + const parser = new SQLServerConnector().dsnParser; + const config = await parser.parse('sqlserver://user:pass@localhost:1433/db?sslmode=verify-full'); + + expect(config.options?.encrypt).toBe(true); + expect(config.options?.trustServerCertificate).toBe(false); + }); }); describe('DSN Parser - SQL Server NTLM Authentication', () => { diff --git a/src/connectors/sqlserver/index.ts b/src/connectors/sqlserver/index.ts index e045dd07..a174d136 100644 --- a/src/connectors/sqlserver/index.ts +++ b/src/connectors/sqlserver/index.ts @@ -81,6 +81,9 @@ export class SQLServerDSNParser implements DSNParser { } else if (options.sslmode === "require") { options.encrypt = true; options.trustServerCertificate = true; + } else if (options.sslmode === "verify-full") { + options.encrypt = true; + options.trustServerCertificate = false; } // Default behavior (certificate verification) is handled by the default values below } diff --git a/src/types/config.ts b/src/types/config.ts index 6f2a3459..dffbde1c 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -37,8 +37,8 @@ export interface ConnectionParams { aws_region?: string; // AWS region required when aws_iam_auth is enabled aws_profile?: string; // Named AWS shared-config profile for RDS IAM auth instanceName?: string; // SQL Server named instance support - sslmode?: "disable" | "require" | "verify-ca" | "verify-full"; // SSL mode for network databases (not applicable to SQLite, verify-* only applicable for PostgreSQL) - sslrootcert?: string; // CA certificate path (requires verify-ca or verify-full) + sslmode?: "disable" | "require" | "verify-ca" | "verify-full"; // SSL mode for network databases (not SQLite; verify-ca: PostgreSQL only; verify-full: PostgreSQL, SQL Server, Oracle) + sslrootcert?: string; // CA certificate path (PostgreSQL only; requires verify-ca or verify-full) sslcert?: string; // PEM client certificate path for client certificate authentication (PostgreSQL only; requires sslkey and sslmode require/verify-ca/verify-full) sslkey?: string; // PEM private key path for sslcert (PostgreSQL only; unencrypted) // SQL Server authentication options From dd1b6bda041dd7aa4f78ea3419ca06ed66815307 Mon Sep 17 00:00:00 2001 From: Ioannis-Marios Tsiakoulias Date: Fri, 2 Oct 2026 00:41:18 +0300 Subject: [PATCH 2/2] fix(sqlserver): reject invalid and duplicate sslmode values --- src/config/__tests__/toml-loader.test.ts | 53 ++++++++++++++++++++ src/config/toml-loader.ts | 7 +++ src/connectors/__tests__/dsn-parser.test.ts | 55 +++++++++++++++++++++ src/connectors/sqlserver/index.ts | 12 +++-- 4 files changed, 124 insertions(+), 3 deletions(-) diff --git a/src/config/__tests__/toml-loader.test.ts b/src/config/__tests__/toml-loader.test.ts index 7715ee66..90d02ddb 100644 --- a/src/config/__tests__/toml-loader.test.ts +++ b/src/config/__tests__/toml-loader.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { loadTomlConfig, buildDSNFromSource, interpolateEnvVars } from '../toml-loader.js'; import type { SourceConfig } from '../../types/config.js'; import { SQLiteConnector } from '../../connectors/sqlite/index.js'; +import { SQLServerConnector } from '../../connectors/sqlserver/index.js'; import fs from 'fs'; import path from 'path'; import os from 'os'; @@ -524,6 +525,58 @@ dsn = "postgres://user:pass@localhost:5432/testdb" }); describe('sslmode validation', () => { + it('should preserve a matching encoded SQL Server sslmode', async () => { + const dsn = 'sqlserver://user:pass@localhost:1433/db?%73slmode=verify%2Dfull'; + fs.writeFileSync(path.join(tempDir, 'dbhub.toml'), ` +[[sources]] +id = "test_db" +dsn = "${dsn}" +sslmode = "verify-full" +`); + + const result = loadTomlConfig(); + const builtDSN = buildDSNFromSource(result!.sources[0]); + expect(builtDSN).toBe(dsn); + const config = await new SQLServerConnector().dsnParser.parse(builtDSN); + expect(config.options?.encrypt).toBe(true); + expect(config.options?.trustServerCertificate).toBe(false); + }); + + it('should reject a conflicting encoded SQL Server sslmode without reflecting the input', () => { + fs.writeFileSync(path.join(tempDir, 'dbhub.toml'), ` +[[sources]] +id = "test_db" +dsn = "sqlserver://user:pass@localhost:1433/db?%73slmode=disable" +sslmode = "verify-full" +`); + + expect(() => loadTomlConfig()).toThrow(new Error( + `Failed to load TOML configuration from ${path.join(tempDir, 'dbhub.toml')}: ` + + 'Conflicting SQL Server sslmode. Set sslmode in only one place, or make the two values match.' + )); + }); + + it.each(['disable', 'verify-full', ''])( + 'should reject duplicate SQL Server modes after TOML processing (%j)', + async (trailingMode) => { + const dsn = `sqlserver://user:pass@localhost:1433/db?sslmode=verify-full&sslmode=${trailingMode}`; + fs.writeFileSync(path.join(tempDir, 'dbhub.toml'), ` +[[sources]] +id = "test_db" +dsn = "${dsn}" +sslmode = "verify-full" +`); + + const result = loadTomlConfig(); + const builtDSN = buildDSNFromSource(result!.sources[0]); + expect(result?.sources[0].sslmode).toBe('verify-full'); + expect(builtDSN).toBe(dsn); + await expect(new SQLServerConnector().dsnParser.parse(builtDSN)).rejects.toMatchObject({ + message: 'Failed to parse SQL Server DSN: Invalid sslmode. Specify exactly one value: disable, require, verify-full', + }); + } + ); + it('should accept and propagate sslmode=verify-full for SQL Server', () => { const tomlContent = ` [[sources]] diff --git a/src/config/toml-loader.ts b/src/config/toml-loader.ts index 3e4b7774..87feb0b8 100644 --- a/src/config/toml-loader.ts +++ b/src/config/toml-loader.ts @@ -239,6 +239,10 @@ function getRawDSNQueryParam(dsn: string, key: string): string | null { if (queryStart === -1) { return null; } + // Match SQL Server's decoded sslmode keys so merging cannot invent a duplicate. + if (key === "sslmode" && dsn.startsWith("sqlserver://")) { + return new URLSearchParams(dsn.substring(queryStart + 1)).get(key); + } for (const pair of dsn.substring(queryStart + 1).split("&")) { if (pair === "") { continue; @@ -386,6 +390,9 @@ function validateDSNFieldConflicts(source: SourceConfig, configPath: string): vo // still treated as present and a conflicting field is rejected. const dsnSslmode = getRawDSNQueryParam(source.dsn!, "sslmode"); if (source.sslmode && dsnSslmode !== null && dsnSslmode !== source.sslmode) { + if (source.type === "sqlserver") { + throw new Error("Conflicting SQL Server sslmode. Set sslmode in only one place, or make the two values match."); + } conflict("sslmode", source.sslmode, dsnSslmode); } diff --git a/src/connectors/__tests__/dsn-parser.test.ts b/src/connectors/__tests__/dsn-parser.test.ts index 0d99c8e2..f19b198e 100644 --- a/src/connectors/__tests__/dsn-parser.test.ts +++ b/src/connectors/__tests__/dsn-parser.test.ts @@ -392,6 +392,61 @@ describe('DSN Parser - SQL Server SSL/TLS Configuration', () => { expect(config.options?.encrypt).toBe(true); expect(config.options?.trustServerCertificate).toBe(false); }); + + it.each([ + 'sslmode=verify_ful', + 'sslmode=verify-ca', + 'sslmode=%20', + 'sslmode', + 'sslmode=', + 'sslmode=verify-full=extra', + 'sslmode=verify%3Dfull', + 'sslmode=%', + 'sslmode=%C3%28', + '%73slmode=', + '%73slmode=verify_ful', + 'sslmode=verify-full&sslmode=verify-full', + 'sslmode=verify-full&sslmode=disable', + 'sslmode=verify_ful&sslmode=disable', + 'sslmode=verify-full&sslmode=', + 'sslmode=&sslmode=verify-full', + 'sslmode=verify-full&%73slmode=disable', + '%73slmode=verify%2Dfull&sslmode=disable', + ])('should reject invalid sslmode query %s with a fixed error', async (query) => { + const parser = new SQLServerConnector().dsnParser; + + await expect( + parser.parse(`sqlserver://user:pass@localhost:1433/db?${query}`) + ).rejects.toMatchObject({ + message: 'Failed to parse SQL Server DSN: Invalid sslmode. Specify exactly one value: disable, require, verify-full', + }); + }); + + it.each([ + ['p@ss#word:&=+', 'p@ss#word:&=+'], + ['p%3Fsslmode%3Ddisable%26sslmode%3Dverify_ful', 'p?sslmode=disable&sslmode=verify_ful'], + ])('should preserve password %s with an encoded sslmode', async (password, decodedPassword) => { + const parser = new SQLServerConnector().dsnParser; + const config = await parser.parse( + `sqlserver://user%40domain:${password}@localhost:1433/db?%73slmode=verify%2Dfull&instanceName=ENV1`, + { connectionTimeoutSeconds: 15, queryTimeoutSeconds: 30 } + ); + + expect(config).toMatchObject({ + user: 'user@domain', + password: decodedPassword, + server: 'localhost', + port: 1433, + database: 'db', + options: { + encrypt: true, + trustServerCertificate: false, + instanceName: 'ENV1', + connectTimeout: 15000, + requestTimeout: 30000, + }, + }); + }); }); describe('DSN Parser - SQL Server NTLM Authentication', () => { diff --git a/src/connectors/sqlserver/index.ts b/src/connectors/sqlserver/index.ts index a174d136..8ba1807a 100644 --- a/src/connectors/sqlserver/index.ts +++ b/src/connectors/sqlserver/index.ts @@ -46,18 +46,24 @@ export class SQLServerDSNParser implements DSNParser { } try { + // Inspect the same raw query as SafeURL before it drops empty/malformed + // pairs or collapses duplicates. Decode query keys as well as values. + const queryStart = dsn.indexOf("?"); + const sslmodes = new URLSearchParams(queryStart === -1 ? "" : dsn.substring(queryStart + 1)).getAll("sslmode"); + if (sslmodes.length > 1 || (sslmodes.length === 1 && !["disable", "require", "verify-full"].includes(sslmodes[0]))) { + throw new Error("Invalid sslmode. Specify exactly one value: disable, require, verify-full"); + } + // Use the SafeURL helper to parse DSNs with special characters const url = new SafeURL(dsn); // Parse additional options from query parameters - const options: Record = {}; + const options: Record = { sslmode: sslmodes[0] }; // Process query parameters url.forEachSearchParam((value, key) => { if (key === "authentication") { options.authentication = value; - } else if (key === "sslmode") { - options.sslmode = value; } else if (key === "instanceName") { options.instanceName = value; } else if (key === "domain") {