diff --git a/apps/api/prisma/client.ts b/apps/api/prisma/client.ts index 5f3c1738d2..499dfc306d 100644 --- a/apps/api/prisma/client.ts +++ b/apps/api/prisma/client.ts @@ -1,10 +1,29 @@ import { PrismaClient } from '@prisma/client'; import { PrismaPg } from '@prisma/adapter-pg'; +import type { PeerCertificate } from 'node:tls'; const globalForPrisma = global as unknown as { prisma?: PrismaClient }; const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1']); +const isRdsHostname = (n: string): boolean => + n.endsWith('.rds.amazonaws.com') || n.endsWith('.rds.amazonaws.com.cn'); + +// Connections traverse an AWS NLB → RDS Proxy. The cert presented is the +// Proxy's, whose SAN list contains the proxy hostname but NOT the NLB hostname +// we dialed. Default hostname check fails. Instead of disabling identity +// verification entirely, assert the cert is for an AWS RDS endpoint. +function rdsServerIdentity(_host: string, cert: PeerCertificate): Error | undefined { + const sans = (cert.subjectaltname ?? '') + .split(',') + .map((s) => s.trim().replace(/^DNS:/, '')); + const cn = (cert.subject as { CN?: string } | undefined)?.CN ?? ''; + if (isRdsHostname(cn) || sans.some(isRdsHostname)) return undefined; + return new Error( + `TLS hostname check: cert is not for an AWS RDS endpoint (CN=${cn}, SANs=${sans.join(',')})`, + ); +} + function stripSslMode(connectionString: string): string { const url = new URL(connectionString); url.searchParams.delete('sslmode'); @@ -42,16 +61,17 @@ function createPrismaClient(): PrismaClient { const allowInsecure = process.env.PRISMA_ALLOW_INSECURE_TLS === '1'; let ssl: | undefined - | { checkServerIdentity: () => undefined } + | { checkServerIdentity: (host: string, cert: PeerCertificate) => Error | undefined } | { rejectUnauthorized: false }; if (isLocalhost) { ssl = undefined; } else if (hasCABundle) { // Verified TLS: rely on Node's TLS context (NODE_EXTRA_CA_CERTS adds the AWS - // RDS CA to the trust store). Skip hostname check because connections may - // traverse an AWS NLB whose hostname isn't in the RDS Proxy cert's SAN list. - // The chain check still rejects forged or wrong-CA certs. - ssl = { checkServerIdentity: () => undefined }; + // RDS CA to the trust store). Replace the default hostname check with one + // that asserts the cert is for an AWS RDS endpoint, since the NLB hostname + // we dial isn't in the RDS Proxy cert's SAN list. The chain check still + // rejects forged or wrong-CA certs. + ssl = { checkServerIdentity: rdsServerIdentity }; } else if (allowInsecure) { ssl = { rejectUnauthorized: false }; } else { diff --git a/apps/app/prisma/client.ts b/apps/app/prisma/client.ts index 74e460d077..d951dcc182 100644 --- a/apps/app/prisma/client.ts +++ b/apps/app/prisma/client.ts @@ -1,5 +1,6 @@ import { PrismaClient } from '@prisma/client'; import { PrismaPg } from '@prisma/adapter-pg'; +import { type PeerCertificate, rootCertificates } from 'node:tls'; import { RDS_CA_BUNDLE } from './rds-ca-bundle'; @@ -7,6 +8,31 @@ const globalForPrisma = global as unknown as { prisma?: PrismaClient }; const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1']); +// `ssl.ca` *replaces* Node's trust store rather than augmenting it. Our +// RDS bundle only contains regional RDS CAs; AWS RDS Proxy chains terminate +// at Amazon Root CA 1, which lives in Node's default Mozilla bundle. +const COMBINED_CA = [RDS_CA_BUNDLE, ...rootCertificates]; + +const isRdsHostname = (n: string): boolean => + n.endsWith('.rds.amazonaws.com') || n.endsWith('.rds.amazonaws.com.cn'); + +// Connections traverse an AWS NLB → RDS Proxy. The cert presented is the +// Proxy's, whose SAN list contains the proxy hostname but NOT the NLB +// hostname we dialed. Default hostname check fails. Instead of disabling +// identity verification entirely, assert the cert is for an AWS RDS +// endpoint — preserves protection against cert substitution while +// accepting the NLB hostname mismatch. +function rdsServerIdentity(_host: string, cert: PeerCertificate): Error | undefined { + const sans = (cert.subjectaltname ?? '') + .split(',') + .map((s) => s.trim().replace(/^DNS:/, '')); + const cn = (cert.subject as { CN?: string } | undefined)?.CN ?? ''; + if (isRdsHostname(cn) || sans.some(isRdsHostname)) return undefined; + return new Error( + `TLS hostname check: cert is not for an AWS RDS endpoint (CN=${cn}, SANs=${sans.join(',')})`, + ); +} + function stripSslMode(connectionString: string): string { const url = new URL(connectionString); url.searchParams.delete('sslmode'); @@ -30,18 +56,17 @@ function createPrismaClient(): PrismaClient { let ssl: | undefined - | { ca: string; checkServerIdentity: () => undefined } + | { + ca: string[]; + checkServerIdentity: (host: string, cert: PeerCertificate) => Error | undefined; + } | { rejectUnauthorized: false }; if (isLocalhost) { ssl = undefined; } else if (allowInsecure) { ssl = { rejectUnauthorized: false }; } else { - // Verified TLS using the inlined AWS RDS CA bundle. Skip hostname check - // because connections may traverse an AWS NLB whose hostname isn't in the - // RDS Proxy cert's SAN list. The chain check still rejects forged or - // wrong-CA certs. - ssl = { ca: RDS_CA_BUNDLE, checkServerIdentity: () => undefined }; + ssl = { ca: COMBINED_CA, checkServerIdentity: rdsServerIdentity }; } const url = ssl !== undefined ? stripSslMode(rawUrl) : rawUrl; diff --git a/apps/framework-editor/prisma/client.ts b/apps/framework-editor/prisma/client.ts index 1d20d5f696..83512add18 100644 --- a/apps/framework-editor/prisma/client.ts +++ b/apps/framework-editor/prisma/client.ts @@ -1,5 +1,6 @@ import { PrismaClient } from '@prisma/client'; import { PrismaPg } from '@prisma/adapter-pg'; +import { type PeerCertificate, rootCertificates } from 'node:tls'; import { RDS_CA_BUNDLE } from './rds-ca-bundle'; @@ -7,6 +8,22 @@ const globalForPrisma = global as unknown as { prisma?: PrismaClient }; const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1']); +const COMBINED_CA = [RDS_CA_BUNDLE, ...rootCertificates]; + +const isRdsHostname = (n: string): boolean => + n.endsWith('.rds.amazonaws.com') || n.endsWith('.rds.amazonaws.com.cn'); + +function rdsServerIdentity(_host: string, cert: PeerCertificate): Error | undefined { + const sans = (cert.subjectaltname ?? '') + .split(',') + .map((s) => s.trim().replace(/^DNS:/, '')); + const cn = (cert.subject as { CN?: string } | undefined)?.CN ?? ''; + if (isRdsHostname(cn) || sans.some(isRdsHostname)) return undefined; + return new Error( + `TLS hostname check: cert is not for an AWS RDS endpoint (CN=${cn}, SANs=${sans.join(',')})`, + ); +} + function stripSslMode(connectionString: string): string { const url = new URL(connectionString); url.searchParams.delete('sslmode'); @@ -30,14 +47,17 @@ function createPrismaClient(): PrismaClient { let ssl: | undefined - | { ca: string; checkServerIdentity: () => undefined } + | { + ca: string[]; + checkServerIdentity: (host: string, cert: PeerCertificate) => Error | undefined; + } | { rejectUnauthorized: false }; if (isLocalhost) { ssl = undefined; } else if (allowInsecure) { ssl = { rejectUnauthorized: false }; } else { - ssl = { ca: RDS_CA_BUNDLE, checkServerIdentity: () => undefined }; + ssl = { ca: COMBINED_CA, checkServerIdentity: rdsServerIdentity }; } const url = ssl !== undefined ? stripSslMode(rawUrl) : rawUrl; diff --git a/apps/portal/prisma/client.ts b/apps/portal/prisma/client.ts index 643230cdaf..3aa85d02d5 100644 --- a/apps/portal/prisma/client.ts +++ b/apps/portal/prisma/client.ts @@ -1,5 +1,6 @@ import { PrismaClient } from '../src/generated/prisma/client'; import { PrismaPg } from '@prisma/adapter-pg'; +import { type PeerCertificate, rootCertificates } from 'node:tls'; import { RDS_CA_BUNDLE } from './rds-ca-bundle'; @@ -7,6 +8,22 @@ const globalForPrisma = global as unknown as { prisma?: PrismaClient }; const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1']); +const COMBINED_CA = [RDS_CA_BUNDLE, ...rootCertificates]; + +const isRdsHostname = (n: string): boolean => + n.endsWith('.rds.amazonaws.com') || n.endsWith('.rds.amazonaws.com.cn'); + +function rdsServerIdentity(_host: string, cert: PeerCertificate): Error | undefined { + const sans = (cert.subjectaltname ?? '') + .split(',') + .map((s) => s.trim().replace(/^DNS:/, '')); + const cn = (cert.subject as { CN?: string } | undefined)?.CN ?? ''; + if (isRdsHostname(cn) || sans.some(isRdsHostname)) return undefined; + return new Error( + `TLS hostname check: cert is not for an AWS RDS endpoint (CN=${cn}, SANs=${sans.join(',')})`, + ); +} + function stripSslMode(connectionString: string): string { const url = new URL(connectionString); url.searchParams.delete('sslmode'); @@ -30,14 +47,17 @@ function createPrismaClient(): PrismaClient { let ssl: | undefined - | { ca: string; checkServerIdentity: () => undefined } + | { + ca: string[]; + checkServerIdentity: (host: string, cert: PeerCertificate) => Error | undefined; + } | { rejectUnauthorized: false }; if (isLocalhost) { ssl = undefined; } else if (allowInsecure) { ssl = { rejectUnauthorized: false }; } else { - ssl = { ca: RDS_CA_BUNDLE, checkServerIdentity: () => undefined }; + ssl = { ca: COMBINED_CA, checkServerIdentity: rdsServerIdentity }; } const url = ssl !== undefined ? stripSslMode(rawUrl) : rawUrl; diff --git a/packages/db/package.json b/packages/db/package.json index 1039de5db3..3aa712b566 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -1,7 +1,7 @@ { "name": "@trycompai/db", "description": "Database package with Prisma client and schema for Comp AI", - "version": "2.2.0", + "version": "2.2.1", "dependencies": { "@prisma/adapter-pg": "7.6.0", "@prisma/client": "7.6.0", diff --git a/packages/db/src/client.test.ts b/packages/db/src/client.test.ts index c31515f9ed..5a7dd2c937 100644 --- a/packages/db/src/client.test.ts +++ b/packages/db/src/client.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect } from 'bun:test'; -import { resolveSslConfig } from './ssl-config'; +import { resolveSslConfig, rdsServerIdentity } from './ssl-config'; +import type { PeerCertificate } from 'node:tls'; describe('resolveSslConfig', () => { it('returns undefined for localhost', () => { @@ -14,14 +15,14 @@ describe('resolveSslConfig', () => { expect(resolveSslConfig('postgresql://u:p@[::1]:5432/x', {})).toBeUndefined(); }); - it('returns checkServerIdentity-noop when NODE_EXTRA_CA_CERTS is set', () => { - const result = resolveSslConfig('postgresql://u:p@db.prod.example.com:5432/x', { - NODE_EXTRA_CA_CERTS: '/etc/ssl/certs/ca-certificates.crt', - }); + it('returns combined-CA + custom rdsServerIdentity for remote URLs', () => { + const result = resolveSslConfig('postgresql://u:p@db.prod.example.com:5432/x', {}); expect(result).toBeDefined(); - expect(typeof (result as { checkServerIdentity: unknown }).checkServerIdentity).toBe('function'); - // The function returns undefined (no error → identity accepted) - expect((result as { checkServerIdentity: () => undefined }).checkServerIdentity()).toBeUndefined(); + if (!result || !('ca' in result)) throw new Error('expected ca branch'); + expect(Array.isArray(result.ca)).toBe(true); + // Combined trust includes our pinned RDS bundle plus Node's defaults. + expect((result.ca as string[]).length).toBeGreaterThan(1); + expect(typeof result.checkServerIdentity).toBe('function'); }); it('returns rejectUnauthorized:false when PRISMA_ALLOW_INSECURE_TLS=1', () => { @@ -32,22 +33,46 @@ describe('resolveSslConfig', () => { ).toEqual({ rejectUnauthorized: false }); }); - it('throws on remote URL with neither env var set', () => { - expect(() => resolveSslConfig('postgresql://u:p@db.prod.example.com:5432/x', {})).toThrow( - /Refusing to connect/, - ); + it('treats malformed URLs as remote (defensive)', () => { + const result = resolveSslConfig('not-a-valid-url', {}); + expect(result).toBeDefined(); + expect((result as { ca: unknown }).ca).toBeDefined(); }); +}); - it('treats malformed URLs as remote (defensive)', () => { - expect(() => resolveSslConfig('not-a-valid-url', {})).toThrow(/Refusing to connect/); +describe('rdsServerIdentity', () => { + const make = (cn: string, sans: string[] = []): PeerCertificate => + ({ + subject: { CN: cn }, + subjectaltname: sans.map((s) => `DNS:${s}`).join(', '), + }) as unknown as PeerCertificate; + + it('accepts a cert whose CN is an RDS endpoint', () => { + const cert = make('my-proxy.proxy-abc.us-east-1.rds.amazonaws.com'); + expect(rdsServerIdentity('any-host.example.com', cert)).toBeUndefined(); }); - it('prefers verified TLS over insecure opt-in when both are set', () => { - const result = resolveSslConfig('postgresql://u:p@db.prod.example.com:5432/x', { - NODE_EXTRA_CA_CERTS: '/etc/ssl/certs/ca-certificates.crt', - PRISMA_ALLOW_INSECURE_TLS: '1', - }); - expect(result).toBeDefined(); - expect(typeof (result as { checkServerIdentity: unknown }).checkServerIdentity).toBe('function'); + it('accepts a cert whose SAN includes an RDS endpoint', () => { + const cert = make('something.example.com', [ + 'my-proxy.proxy-abc.us-east-1.rds.amazonaws.com', + ]); + expect(rdsServerIdentity('any-host.example.com', cert)).toBeUndefined(); + }); + + it('accepts the .cn region suffix', () => { + const cert = make('proxy.proxy-abc.cn-north-1.rds.amazonaws.com.cn'); + expect(rdsServerIdentity('any-host.example.com', cert)).toBeUndefined(); + }); + + it('rejects a cert that is not for an RDS endpoint', () => { + const cert = make('attacker.example.com', ['evil.example.com']); + const err = rdsServerIdentity('any-host.example.com', cert); + expect(err).toBeInstanceOf(Error); + expect(err?.message).toMatch(/not for an AWS RDS endpoint/); + }); + + it('rejects when CN/SAN are empty', () => { + const cert = make('', []); + expect(rdsServerIdentity('any-host.example.com', cert)).toBeInstanceOf(Error); }); }); diff --git a/packages/db/src/ssl-config.ts b/packages/db/src/ssl-config.ts index 726b255d06..9aeb6a1234 100644 --- a/packages/db/src/ssl-config.ts +++ b/packages/db/src/ssl-config.ts @@ -1,8 +1,12 @@ +import { type PeerCertificate, rootCertificates } from 'node:tls'; import { RDS_CA_BUNDLE } from './rds-ca-bundle'; export type SslConfig = | undefined - | { ca: string; checkServerIdentity: () => undefined } + | { + ca: string | string[]; + checkServerIdentity: (host: string, cert: PeerCertificate) => Error | undefined; + } | { rejectUnauthorized: false }; const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1']); @@ -19,15 +23,41 @@ function isLocalhostUrl(connectionString: string): boolean { } } +// `ssl.ca` *replaces* Node's trust store rather than augmenting it. Our +// `rds-global-bundle.pem` only contains the 108 RDS-specific regional CAs; +// AWS RDS Proxy chains terminate at Amazon Root CA 1, which lives in Node's +// default Mozilla bundle. Combine so both direct-instance and Proxy paths +// validate. +const COMBINED_CA = [RDS_CA_BUNDLE, ...rootCertificates]; + +const isRdsHostname = (n: string): boolean => + n.endsWith('.rds.amazonaws.com') || n.endsWith('.rds.amazonaws.com.cn'); + +// We connect via an AWS NLB (TCP passthrough) → RDS Proxy. The cert presented +// is the Proxy's, whose SAN list contains the proxy hostname (e.g. +// `*.proxy-XXX.us-east-1.rds.amazonaws.com`) but NOT the NLB hostname +// (`*.elb.amazonaws.com`) we dialed. Default hostname check fails. Instead of +// disabling identity verification entirely (which would let an attacker +// substitute a chain-valid cert for any host), assert the cert is for an AWS +// RDS endpoint. Combined with the pinned trust store + chain validation, an +// attacker would need a forged or wrong-CA cert for an RDS hostname, both of +// which still fail. +export function rdsServerIdentity(_host: string, cert: PeerCertificate): Error | undefined { + const sans = (cert.subjectaltname ?? '') + .split(',') + .map((s) => s.trim().replace(/^DNS:/, '')); + const cn = (cert.subject as { CN?: string } | undefined)?.CN ?? ''; + if (isRdsHostname(cn) || sans.some(isRdsHostname)) return undefined; + return new Error( + `TLS hostname check: cert is not for an AWS RDS endpoint (CN=${cn}, SANs=${sans.join(',')})`, + ); +} + export function resolveSslConfig( databaseUrl: string, env: Partial = process.env, ): SslConfig { if (isLocalhostUrl(databaseUrl)) return undefined; if (env.PRISMA_ALLOW_INSECURE_TLS === '1') return { rejectUnauthorized: false }; - // Verified TLS using the inlined AWS RDS CA bundle. Skip the hostname check - // because connections may traverse an AWS NLB whose hostname isn't in the - // RDS Proxy cert's SAN list. The chain check still rejects forged or - // wrong-CA certs. - return { ca: RDS_CA_BUNDLE, checkServerIdentity: () => undefined }; + return { ca: COMBINED_CA, checkServerIdentity: rdsServerIdentity }; }