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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 5 additions & 3 deletions dbhub.toml.example
Original file line number Diff line number Diff line change
Expand Up @@ -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]]
Expand Down Expand Up @@ -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)
Expand Down
6 changes: 3 additions & 3 deletions docs/config/command-line.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

<Note>
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.
Expand Down Expand Up @@ -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 |
| `--ssh-proxy-jump` | `SSH_PROXY_JUMP` | string | ProxyJump hosts |
6 changes: 4 additions & 2 deletions docs/config/toml.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
75 changes: 75 additions & 0 deletions src/config/__tests__/toml-loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -524,6 +525,80 @@ 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]]
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) => {
Expand Down
12 changes: 10 additions & 2 deletions src/config/toml-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -577,10 +584,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<string, string[]> = {
postgres: ["verify-ca", "verify-full"],
sqlserver: ["verify-full"],
oracle: ["verify-full"],
};
if (
Expand Down
63 changes: 63 additions & 0 deletions src/connectors/__tests__/dsn-parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,69 @@ 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);
});

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', () => {
Expand Down
15 changes: 12 additions & 3 deletions src/connectors/sqlserver/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any> = {};
const options: Record<string, any> = { 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") {
Expand All @@ -81,6 +87,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
}
Expand Down
4 changes: 2 additions & 2 deletions src/types/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading