Skip to content

Commit f13fad4

Browse files
authored
feat: support sslmode=verify-ca and sslmode=verify-full with sslrootcert for PostgreSQL (#294)
* Add support for verify-ca * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip
1 parent e5b5b92 commit f13fad4

8 files changed

Lines changed: 468 additions & 30 deletions

File tree

dbhub.toml.example

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,18 @@ dsn = "postgres://postgres:postgres@localhost:5432/myapp"
5656
# aws_region = "eu-west-1"
5757
# sslmode = "require"
5858

59+
# PostgreSQL with certificate verification (e.g., AWS RDS)
60+
# [[sources]]
61+
# id = "rds_pg_verified"
62+
# type = "postgres"
63+
# host = "mydb.abc123.eu-west-1.rds.amazonaws.com"
64+
# port = 5432
65+
# database = "myapp"
66+
# user = "app_user"
67+
# password = "secure_password"
68+
# sslmode = "verify-ca"
69+
# sslrootcert = "~/.ssl/rds-combined-ca-bundle.pem"
70+
5971
# Production PostgreSQL (behind SSH bastion, lazy connection)
6072
# [[sources]]
6173
# id = "prod_pg"
@@ -323,8 +335,11 @@ dsn = "postgres://postgres:postgres@localhost:5432/myapp"
323335
# ssh_keepalive_count_max (max missed keepalive responses, default: 3)
324336
#
325337
# SSL Mode (for network databases, not SQLite):
326-
# sslmode = "disable" # No SSL
327-
# sslmode = "require" # SSL without certificate verification
338+
# sslmode = "disable" # No SSL
339+
# sslmode = "require" # SSL without certificate verification
340+
# sslmode = "verify-ca" # SSL with CA certificate verification (PostgreSQL only)
341+
# sslmode = "verify-full" # SSL with CA + hostname verification (PostgreSQL only)
342+
# sslrootcert = "~/.ssl/ca.pem" # CA certificate path (requires verify-ca or verify-full)
328343
#
329344
# SQL Server Authentication:
330345
# authentication = "ntlm" # Windows/NTLM auth (requires domain)

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

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -509,6 +509,172 @@ dsn = "postgres://user:pass@localhost:5432/testdb"
509509
expect(result).toBeTruthy();
510510
expect(result?.sources[0].sslmode).toBeUndefined();
511511
});
512+
513+
it('should accept sslmode = "verify-ca" for PostgreSQL', () => {
514+
const tomlContent = `
515+
[[sources]]
516+
id = "test_db"
517+
type = "postgres"
518+
host = "localhost"
519+
database = "testdb"
520+
user = "user"
521+
password = "pass"
522+
sslmode = "verify-ca"
523+
`;
524+
fs.writeFileSync(path.join(tempDir, 'dbhub.toml'), tomlContent);
525+
526+
const result = loadTomlConfig();
527+
528+
expect(result).toBeTruthy();
529+
expect(result?.sources[0].sslmode).toBe('verify-ca');
530+
});
531+
532+
it('should accept sslmode = "verify-full" for PostgreSQL', () => {
533+
const tomlContent = `
534+
[[sources]]
535+
id = "test_db"
536+
type = "postgres"
537+
host = "localhost"
538+
database = "testdb"
539+
user = "user"
540+
password = "pass"
541+
sslmode = "verify-full"
542+
`;
543+
fs.writeFileSync(path.join(tempDir, 'dbhub.toml'), tomlContent);
544+
545+
const result = loadTomlConfig();
546+
547+
expect(result).toBeTruthy();
548+
expect(result?.sources[0].sslmode).toBe('verify-full');
549+
});
550+
551+
it('should reject sslmode = "verify-ca" for MySQL', () => {
552+
const tomlContent = `
553+
[[sources]]
554+
id = "test_db"
555+
type = "mysql"
556+
host = "localhost"
557+
database = "testdb"
558+
user = "user"
559+
password = "pass"
560+
sslmode = "verify-ca"
561+
`;
562+
fs.writeFileSync(path.join(tempDir, 'dbhub.toml'), tomlContent);
563+
564+
expect(() => loadTomlConfig()).toThrow("sslmode 'verify-ca' which is only supported for PostgreSQL");
565+
});
566+
567+
it('should reject sslmode = "verify-full" for MariaDB', () => {
568+
const tomlContent = `
569+
[[sources]]
570+
id = "test_db"
571+
type = "mariadb"
572+
host = "localhost"
573+
database = "testdb"
574+
user = "user"
575+
password = "pass"
576+
sslmode = "verify-full"
577+
`;
578+
fs.writeFileSync(path.join(tempDir, 'dbhub.toml'), tomlContent);
579+
580+
expect(() => loadTomlConfig()).toThrow("sslmode 'verify-full' which is only supported for PostgreSQL");
581+
});
582+
583+
it('should reject sslmode = "verify-ca" for SQL Server', () => {
584+
const tomlContent = `
585+
[[sources]]
586+
id = "test_db"
587+
type = "sqlserver"
588+
host = "localhost"
589+
database = "testdb"
590+
user = "user"
591+
password = "pass"
592+
sslmode = "verify-ca"
593+
`;
594+
fs.writeFileSync(path.join(tempDir, 'dbhub.toml'), tomlContent);
595+
596+
expect(() => loadTomlConfig()).toThrow("sslmode 'verify-ca' which is only supported for PostgreSQL");
597+
});
598+
599+
it('should reject sslrootcert when sslmode is "require"', () => {
600+
const certPath = path.join(tempDir, 'ca.pem');
601+
fs.writeFileSync(certPath, 'cert-content');
602+
603+
const tomlContent = `
604+
[[sources]]
605+
id = "test_db"
606+
type = "postgres"
607+
host = "localhost"
608+
database = "testdb"
609+
user = "user"
610+
password = "pass"
611+
sslmode = "require"
612+
sslrootcert = '${certPath}'
613+
`;
614+
fs.writeFileSync(path.join(tempDir, 'dbhub.toml'), tomlContent);
615+
616+
expect(() => loadTomlConfig()).toThrow("sslrootcert requires sslmode 'verify-ca' or 'verify-full'");
617+
});
618+
619+
it('should reject sslrootcert when sslmode is not set', () => {
620+
const certPath = path.join(tempDir, 'ca.pem');
621+
fs.writeFileSync(certPath, 'cert-content');
622+
623+
const tomlContent = `
624+
[[sources]]
625+
id = "test_db"
626+
type = "postgres"
627+
host = "localhost"
628+
database = "testdb"
629+
user = "user"
630+
password = "pass"
631+
sslrootcert = '${certPath}'
632+
`;
633+
fs.writeFileSync(path.join(tempDir, 'dbhub.toml'), tomlContent);
634+
635+
expect(() => loadTomlConfig()).toThrow("sslrootcert requires sslmode 'verify-ca' or 'verify-full'");
636+
});
637+
638+
it('should accept sslrootcert with sslmode = "verify-ca" when file exists', () => {
639+
const certPath = path.join(tempDir, 'ca.pem');
640+
fs.writeFileSync(certPath, 'cert-content');
641+
642+
const tomlContent = `
643+
[[sources]]
644+
id = "test_db"
645+
type = "postgres"
646+
host = "localhost"
647+
database = "testdb"
648+
user = "user"
649+
password = "pass"
650+
sslmode = "verify-ca"
651+
sslrootcert = '${certPath}'
652+
`;
653+
fs.writeFileSync(path.join(tempDir, 'dbhub.toml'), tomlContent);
654+
655+
const result = loadTomlConfig();
656+
657+
expect(result).toBeTruthy();
658+
expect(result?.sources[0].sslmode).toBe('verify-ca');
659+
expect(result?.sources[0].sslrootcert).toBe(certPath);
660+
});
661+
662+
it('should reject sslrootcert when file does not exist', () => {
663+
const tomlContent = `
664+
[[sources]]
665+
id = "test_db"
666+
type = "postgres"
667+
host = "localhost"
668+
database = "testdb"
669+
user = "user"
670+
password = "pass"
671+
sslmode = "verify-ca"
672+
sslrootcert = "/nonexistent/ca.pem"
673+
`;
674+
fs.writeFileSync(path.join(tempDir, 'dbhub.toml'), tomlContent);
675+
676+
expect(() => loadTomlConfig()).toThrow("sslrootcert file not found or not accessible: '/nonexistent/ca.pem'");
677+
});
512678
});
513679

514680
describe('SQL Server authentication validation', () => {
@@ -978,6 +1144,41 @@ dsn = "postgres://user:pass@localhost:5432/testdb"
9781144
expect(dsn).toBe('postgres://user:pass@localhost:5432/testdb?sslmode=require');
9791145
});
9801146

1147+
it('should build PostgreSQL DSN with verify-ca and sslrootcert', () => {
1148+
const source: SourceConfig = {
1149+
id: 'pg_verify',
1150+
type: 'postgres',
1151+
host: 'rds.amazonaws.com',
1152+
port: 5432,
1153+
database: 'testdb',
1154+
user: 'user',
1155+
password: 'pass',
1156+
sslmode: 'verify-ca',
1157+
sslrootcert: '/path/to/ca-bundle.pem'
1158+
};
1159+
1160+
const dsn = buildDSNFromSource(source);
1161+
1162+
expect(dsn).toBe('postgres://user:pass@rds.amazonaws.com:5432/testdb?sslmode=verify-ca&sslrootcert=%2Fpath%2Fto%2Fca-bundle.pem');
1163+
});
1164+
1165+
it('should build PostgreSQL DSN with verify-full without sslrootcert', () => {
1166+
const source: SourceConfig = {
1167+
id: 'pg_verify_full',
1168+
type: 'postgres',
1169+
host: 'localhost',
1170+
port: 5432,
1171+
database: 'testdb',
1172+
user: 'user',
1173+
password: 'pass',
1174+
sslmode: 'verify-full'
1175+
};
1176+
1177+
const dsn = buildDSNFromSource(source);
1178+
1179+
expect(dsn).toBe('postgres://user:pass@localhost:5432/testdb?sslmode=verify-full');
1180+
});
1181+
9811182
it('should build MySQL DSN with sslmode', () => {
9821183
const source: SourceConfig = {
9831184
id: 'mysql_ssl',

src/config/toml-loader.ts

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import toml from "@iarna/toml";
55
import type { SourceConfig, TomlConfig, ToolConfig } from "../types/config.js";
66
import { parseCommandLineArgs } from "./env.js";
77
import { parseConnectionInfoFromDSN, getDefaultPortForType } from "../utils/dsn-obfuscate.js";
8+
import { SafeURL } from "../utils/safe-url.js";
89
import { BUILTIN_TOOLS, BUILTIN_TOOL_EXECUTE_SQL, BUILTIN_TOOL_SEARCH_OBJECTS } from "../tools/builtin-tools.js";
910

1011
/**
@@ -332,13 +333,55 @@ function validateSourceConfig(source: SourceConfig, configPath: string): void {
332333
);
333334
}
334335

335-
const validSslModes = ["disable", "require"];
336+
const validSslModes = ["disable", "require", "verify-ca", "verify-full"];
336337
if (!validSslModes.includes(source.sslmode)) {
337338
throw new Error(
338339
`Configuration file ${configPath}: source '${source.id}' has invalid sslmode '${source.sslmode}'. ` +
339340
`Valid values: ${validSslModes.join(", ")}`
340341
);
341342
}
343+
344+
if (
345+
(source.sslmode === "verify-ca" || source.sslmode === "verify-full") &&
346+
source.type !== "postgres"
347+
) {
348+
throw new Error(
349+
`Configuration file ${configPath}: source '${source.id}' has sslmode '${source.sslmode}' which is only supported for PostgreSQL. ` +
350+
`Valid values for ${source.type}: disable, require`
351+
);
352+
}
353+
}
354+
355+
// Validate sslrootcert if provided
356+
if (source.sslrootcert !== undefined) {
357+
if (source.sslmode !== "verify-ca" && source.sslmode !== "verify-full") {
358+
throw new Error(
359+
`Configuration file ${configPath}: source '${source.id}' has sslrootcert but sslmode is '${source.sslmode ?? "not set"}'. ` +
360+
`sslrootcert requires sslmode 'verify-ca' or 'verify-full'`
361+
);
362+
}
363+
364+
const expandedPath = expandHomeDir(source.sslrootcert);
365+
let stats: fs.Stats;
366+
try {
367+
stats = fs.statSync(expandedPath);
368+
} catch {
369+
throw new Error(
370+
`Configuration file ${configPath}: source '${source.id}' sslrootcert file not found or not accessible: '${expandedPath}'`
371+
);
372+
}
373+
if (!stats.isFile()) {
374+
throw new Error(
375+
`Configuration file ${configPath}: source '${source.id}' sslrootcert path is not a regular file: '${expandedPath}'`
376+
);
377+
}
378+
try {
379+
fs.accessSync(expandedPath, fs.constants.R_OK);
380+
} catch {
381+
throw new Error(
382+
`Configuration file ${configPath}: source '${source.id}' sslrootcert file is not readable: '${expandedPath}'`
383+
);
384+
}
342385
}
343386

344387
// Validate SQL Server authentication options
@@ -437,6 +480,11 @@ function processSourceConfigs(
437480
processed.ssh_key = expandHomeDir(processed.ssh_key);
438481
}
439482

483+
// Expand ~ in sslrootcert path
484+
if (processed.sslrootcert) {
485+
processed.sslrootcert = expandHomeDir(processed.sslrootcert);
486+
}
487+
440488
// Expand ~ in SQLite database path (if relative)
441489
if (processed.type === "sqlite" && processed.database) {
442490
processed.database = expandHomeDir(processed.database);
@@ -469,6 +517,20 @@ function processSourceConfigs(
469517
processed.user = connectionInfo.user;
470518
}
471519
}
520+
521+
try {
522+
const url = new SafeURL(processed.dsn);
523+
const dsnSslmode = url.getSearchParam("sslmode");
524+
if (!processed.sslmode && dsnSslmode) {
525+
processed.sslmode = dsnSslmode as SourceConfig["sslmode"];
526+
}
527+
const dsnSslrootcert = url.getSearchParam("sslrootcert");
528+
if (!processed.sslrootcert && dsnSslrootcert) {
529+
processed.sslrootcert = dsnSslrootcert;
530+
}
531+
} catch {
532+
// DSN parsing for query params is best-effort; connector will handle errors
533+
}
472534
}
473535

474536
return processed;
@@ -596,6 +658,15 @@ export function buildDSNFromSource(source: SourceConfig): string {
596658
queryParams.push(`sslmode=${source.sslmode}`);
597659
}
598660

661+
if (
662+
source.sslrootcert &&
663+
source.type === "postgres" &&
664+
(source.sslmode === "verify-ca" || source.sslmode === "verify-full")
665+
) {
666+
const expandedCertPath = expandHomeDir(source.sslrootcert);
667+
queryParams.push(`sslrootcert=${encodeURIComponent(expandedCertPath)}`);
668+
}
669+
599670
// Append query string if any params exist
600671
if (queryParams.length > 0) {
601672
dsn += `?${queryParams.join("&")}`;

0 commit comments

Comments
 (0)