From 2ba6eaad2fa72c959d66cb7b90b87a6e2add67ef Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Sun, 22 Mar 2026 17:03:05 +0000 Subject: [PATCH 1/2] fix(db): enable TLS certificate validation by default for DB connections --- CLAUDE.md | 3 ++- src/db/client.ts | 14 +++++++++++- tests/unit/db/client.test.ts | 41 ++++++++++++++++++++++++++++++++---- 3 files changed, 52 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 196b678e..038202ac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -120,7 +120,8 @@ Required: Optional (infrastructure): - `PORT` - Server port (default: 3000) - `LOG_LEVEL` - Logging level (default: info) -- `DATABASE_SSL` - Set to `false` to disable SSL for local PostgreSQL (default: enabled) +- `DATABASE_SSL` - Set to `false` to disable SSL for local PostgreSQL (default: enabled with certificate validation) +- `DATABASE_CA_CERT` - Path to a PEM-encoded CA certificate file for managed databases that use a private CA (e.g., AWS RDS, Azure Database, GCP Cloud SQL). When set, the certificate is read and passed as the `ca` option to `pg.Pool`, enabling TLS certificate validation against the specified CA. Example: `DATABASE_CA_CERT=/etc/ssl/certs/rds-ca.pem` - `CLAUDE_CODE_OAUTH_TOKEN` - For Claude Code engine (subscription auth) - `CREDENTIAL_MASTER_KEY` - 64-char hex string (32-byte AES-256 key) for encrypting credentials at rest. Generate with `npm run credentials:generate-key`. When set, all new/updated credentials are encrypted automatically; existing plaintext credentials continue to work. - `WEBHOOK_CALLBACK_BASE_URL` - Base URL for webhook callbacks (e.g., `https://cascade.example.com`). Used by `tools/setup-webhooks.ts` and the `cascade webhooks create` CLI command to construct the full webhook URL. diff --git a/src/db/client.ts b/src/db/client.ts index 52dc1336..f4e64e76 100644 --- a/src/db/client.ts +++ b/src/db/client.ts @@ -1,3 +1,4 @@ +import fs from 'node:fs'; import { drizzle } from 'drizzle-orm/node-postgres'; import pg from 'pg'; import * as schema from './schema/index.js'; @@ -28,13 +29,24 @@ function getDatabaseUrl(): string { throw new Error('DATABASE_URL or CASCADE_POSTGRES_HOST must be set'); } +function getSslConfig(): false | { rejectUnauthorized: boolean; ca?: string } { + if (process.env.DATABASE_SSL === 'false') { + return false; + } + const sslConfig: { rejectUnauthorized: boolean; ca?: string } = { rejectUnauthorized: true }; + if (process.env.DATABASE_CA_CERT) { + sslConfig.ca = fs.readFileSync(process.env.DATABASE_CA_CERT, 'utf8'); + } + return sslConfig; +} + export function getDb(): ReturnType> { if (_testDbOverride) return _testDbOverride; if (!db) { pool = new pg.Pool({ connectionString: getDatabaseUrl(), max: 5, - ssl: process.env.DATABASE_SSL === 'false' ? false : { rejectUnauthorized: false }, + ssl: getSslConfig(), }); db = drizzle(pool, { schema }); } diff --git a/tests/unit/db/client.test.ts b/tests/unit/db/client.test.ts index f95d26cb..a8785a18 100644 --- a/tests/unit/db/client.test.ts +++ b/tests/unit/db/client.test.ts @@ -4,10 +4,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; // vi.mock factories are hoisted to the top of the file, so any variables they // reference must also be hoisted via vi.hoisted(). -const { mockPoolEnd, mockPoolConstructor } = vi.hoisted(() => { +const { mockPoolEnd, mockPoolConstructor, mockReadFileSync } = vi.hoisted(() => { const mockPoolEnd = vi.fn().mockResolvedValue(undefined); const mockPoolConstructor = vi.fn().mockImplementation(() => ({ end: mockPoolEnd })); - return { mockPoolEnd, mockPoolConstructor }; + const mockReadFileSync = vi.fn().mockReturnValue('mock-ca-cert-content'); + return { mockPoolEnd, mockPoolConstructor, mockReadFileSync }; }); vi.mock('pg', () => ({ @@ -20,6 +21,12 @@ vi.mock('drizzle-orm/node-postgres', () => ({ drizzle: vi.fn().mockReturnValue({ __isMockDrizzle: true }), })); +vi.mock('node:fs', () => ({ + default: { + readFileSync: mockReadFileSync, + }, +})); + // ── Imports (after mocks) ───────────────────────────────────────────────────── import { _setTestDb, closeDb, getDb } from '../../../src/db/client.js'; @@ -150,22 +157,48 @@ describe('getDb', () => { it('creates pool with SSL disabled when DATABASE_SSL=false', () => { vi.stubEnv('DATABASE_SSL', 'false'); + vi.stubEnv('DATABASE_CA_CERT', ''); getDb(); expect(mockPoolConstructor).toHaveBeenCalledWith(expect.objectContaining({ ssl: false })); }); - it('creates pool with rejectUnauthorized:false by default (DATABASE_SSL not set)', () => { + it('creates pool with rejectUnauthorized:true by default (DATABASE_SSL not set)', () => { vi.stubEnv('DATABASE_SSL', ''); + vi.stubEnv('DATABASE_CA_CERT', ''); getDb(); expect(mockPoolConstructor).toHaveBeenCalledWith( - expect.objectContaining({ ssl: { rejectUnauthorized: false } }), + expect.objectContaining({ ssl: { rejectUnauthorized: true } }), ); }); + it('creates pool with custom CA cert when DATABASE_CA_CERT is set', () => { + vi.stubEnv('DATABASE_SSL', ''); + vi.stubEnv('DATABASE_CA_CERT', '/path/to/ca.pem'); + + getDb(); + + expect(mockReadFileSync).toHaveBeenCalledWith('/path/to/ca.pem', 'utf8'); + expect(mockPoolConstructor).toHaveBeenCalledWith( + expect.objectContaining({ + ssl: { rejectUnauthorized: true, ca: 'mock-ca-cert-content' }, + }), + ); + }); + + it('DATABASE_CA_CERT is ignored when DATABASE_SSL=false', () => { + vi.stubEnv('DATABASE_SSL', 'false'); + vi.stubEnv('DATABASE_CA_CERT', '/path/to/ca.pem'); + + getDb(); + + expect(mockReadFileSync).not.toHaveBeenCalled(); + expect(mockPoolConstructor).toHaveBeenCalledWith(expect.objectContaining({ ssl: false })); + }); + it('returns singleton — second call returns same instance', () => { const first = getDb(); const second = getDb(); From 154f7165a27fb474312491301d3d9568f8217070 Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Sun, 22 Mar 2026 17:48:18 +0000 Subject: [PATCH 2/2] fix(db): add existsSync guard for DATABASE_CA_CERT and fix migrate-hooks TLS - Add existsSync check before readFileSync in getSslConfig() with a descriptive error message to help operators diagnose misconfiguration - Fix tools/migrate-hooks.ts to use rejectUnauthorized: true (same as src/db/client.ts) by extracting an identical getSslConfig() helper, also with the existsSync guard for DATABASE_CA_CERT - Add test coverage for the new error case when DATABASE_CA_CERT path does not exist Co-Authored-By: Claude Opus 4.6 --- src/db/client.ts | 8 ++++++-- tests/unit/db/client.test.ts | 14 ++++++++++++-- tools/migrate-hooks.ts | 18 +++++++++++++++++- 3 files changed, 35 insertions(+), 5 deletions(-) diff --git a/src/db/client.ts b/src/db/client.ts index f4e64e76..b4aea43a 100644 --- a/src/db/client.ts +++ b/src/db/client.ts @@ -1,4 +1,4 @@ -import fs from 'node:fs'; +import fs, { existsSync } from 'node:fs'; import { drizzle } from 'drizzle-orm/node-postgres'; import pg from 'pg'; import * as schema from './schema/index.js'; @@ -35,7 +35,11 @@ function getSslConfig(): false | { rejectUnauthorized: boolean; ca?: string } { } const sslConfig: { rejectUnauthorized: boolean; ca?: string } = { rejectUnauthorized: true }; if (process.env.DATABASE_CA_CERT) { - sslConfig.ca = fs.readFileSync(process.env.DATABASE_CA_CERT, 'utf8'); + const certPath = process.env.DATABASE_CA_CERT; + if (!existsSync(certPath)) { + throw new Error(`DATABASE_CA_CERT file not found: ${certPath}`); + } + sslConfig.ca = fs.readFileSync(certPath, 'utf8'); } return sslConfig; } diff --git a/tests/unit/db/client.test.ts b/tests/unit/db/client.test.ts index a8785a18..f0196f56 100644 --- a/tests/unit/db/client.test.ts +++ b/tests/unit/db/client.test.ts @@ -4,11 +4,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; // vi.mock factories are hoisted to the top of the file, so any variables they // reference must also be hoisted via vi.hoisted(). -const { mockPoolEnd, mockPoolConstructor, mockReadFileSync } = vi.hoisted(() => { +const { mockPoolEnd, mockPoolConstructor, mockReadFileSync, mockExistsSync } = vi.hoisted(() => { const mockPoolEnd = vi.fn().mockResolvedValue(undefined); const mockPoolConstructor = vi.fn().mockImplementation(() => ({ end: mockPoolEnd })); const mockReadFileSync = vi.fn().mockReturnValue('mock-ca-cert-content'); - return { mockPoolEnd, mockPoolConstructor, mockReadFileSync }; + const mockExistsSync = vi.fn().mockReturnValue(true); + return { mockPoolEnd, mockPoolConstructor, mockReadFileSync, mockExistsSync }; }); vi.mock('pg', () => ({ @@ -25,6 +26,7 @@ vi.mock('node:fs', () => ({ default: { readFileSync: mockReadFileSync, }, + existsSync: mockExistsSync, })); // ── Imports (after mocks) ───────────────────────────────────────────────────── @@ -189,6 +191,14 @@ describe('getDb', () => { ); }); + it('throws a descriptive error when DATABASE_CA_CERT path does not exist', () => { + vi.stubEnv('DATABASE_SSL', ''); + vi.stubEnv('DATABASE_CA_CERT', '/nonexistent/ca.pem'); + mockExistsSync.mockReturnValueOnce(false); + + expect(() => getDb()).toThrow('DATABASE_CA_CERT file not found: /nonexistent/ca.pem'); + }); + it('DATABASE_CA_CERT is ignored when DATABASE_SSL=false', () => { vi.stubEnv('DATABASE_SSL', 'false'); vi.stubEnv('DATABASE_CA_CERT', '/path/to/ca.pem'); diff --git a/tools/migrate-hooks.ts b/tools/migrate-hooks.ts index 8e754c94..6d35549c 100644 --- a/tools/migrate-hooks.ts +++ b/tools/migrate-hooks.ts @@ -8,6 +8,7 @@ * npx tsx tools/migrate-hooks.ts --apply # Apply changes */ +import { existsSync, readFileSync } from 'node:fs'; import pg from 'pg'; const DATABASE_URL = process.env.DATABASE_URL ?? ''; @@ -16,6 +17,21 @@ if (!DATABASE_URL) { process.exit(1); } +function getSslConfig(): false | { rejectUnauthorized: boolean; ca?: string } { + if (process.env.DATABASE_SSL === 'false') { + return false; + } + const sslConfig: { rejectUnauthorized: boolean; ca?: string } = { rejectUnauthorized: true }; + if (process.env.DATABASE_CA_CERT) { + const certPath = process.env.DATABASE_CA_CERT; + if (!existsSync(certPath)) { + throw new Error(`DATABASE_CA_CERT file not found: ${certPath}`); + } + sslConfig.ca = readFileSync(certPath, 'utf8'); + } + return sslConfig; +} + const dryRun = !process.argv.includes('--apply'); interface LegacyBackend { @@ -124,7 +140,7 @@ async function main() { const pool = new pg.Pool({ connectionString: DATABASE_URL, max: 2, - ssl: process.env.DATABASE_SSL === 'false' ? false : { rejectUnauthorized: false }, + ssl: getSslConfig(), }); try {