From ba960210c54bf1a9adc60a4b5e55a0d40ad4d2e3 Mon Sep 17 00:00:00 2001 From: Mariano Date: Wed, 6 May 2026 22:18:19 +0100 Subject: [PATCH 1/2] fix(db): combine RDS bundle with default trust + scope identity check to RDS hosts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related fixes against the strict-TLS path introduced in #2772: 1. Combine `ssl.ca` with Node's default trust roots ---------------------------------------------------- Setting `ssl.ca` *replaces* Node's trust store rather than augmenting it. Our `rds-global-bundle.pem` only contains the 108 RDS-specific regional self-signed CAs, NOT the public roots like Amazon Root CA 1, which is where AWS RDS Proxy chains terminate (and which lives in Node's default Mozilla bundle). Surfaced by comp-private/apps/trust during prerender of /sitemap.xml: Error opening a TLS connection: unable to get local issuer certificate apps/app and apps/portal didn't trip this because none of their prerendered routes hit the DB; latent issue if any do later. Combine `RDS_CA_BUNDLE` with `tls.rootCertificates` so direct-instance and RDS Proxy paths both validate. 2. Replace `checkServerIdentity: () => undefined` with a scoped check ---------------------------------------------------------------- Disabling identity verification entirely lets an attacker who controls the NLB hostname's DNS substitute *any* chain-valid cert. The reason we skip is specifically that connections traverse an AWS NLB (TCP passthrough) → RDS Proxy, and the NLB hostname (`*.elb.amazonaws.com`) isn't in the RDS Proxy cert's SAN list. Replace the no-op with `rdsServerIdentity()` which asserts the cert's CN or SAN ends in `.rds.amazonaws.com[.cn]`. Combined with the pinned trust store + chain validation, an attacker would now need a forged or wrong-CA cert *for an RDS hostname*, both of which still fail. The NLB hostname mismatch is accepted, the substitute-arbitrary-cert weakness is not. Applied at the source (`packages/db/src/ssl-config.ts`) and inlined into the apps (apps/{app,portal,framework-editor,api}/prisma/client.ts) since they don't import from `@trycompai/db/ssl-config` (Trigger.dev indexer pins to the npm version, which lags behind workspace). Bumps `@trycompai/db` to 2.2.1 so downstream consumers (comp-private) get the corrected behavior at the source on their next dep bump. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/api/prisma/client.ts | 30 ++++++++++-- apps/framework-editor/prisma/client.ts | 24 ++++++++- apps/portal/prisma/client.ts | 24 ++++++++- packages/db/package.json | 2 +- packages/db/src/client.test.ts | 67 ++++++++++++++++++-------- packages/db/src/ssl-config.ts | 42 +++++++++++++--- 6 files changed, 152 insertions(+), 37 deletions(-) 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/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 }; } From fc3b7a300d910c3af9a72f3de4b4183bcd9716fb Mon Sep 17 00:00:00 2001 From: Mariano Date: Wed, 6 May 2026 22:19:52 +0100 Subject: [PATCH 2/2] fix(db): apply same combine + scoped identity check to apps/app prisma client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Missed in the previous commit — apps/app/prisma/client.ts also needs the combined trust store (RDS bundle + Node defaults) and the scoped rdsServerIdentity check, since it doesn't import from @trycompai/db/ssl-config (Trigger.dev indexer pinning concern). Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/app/prisma/client.ts | 37 +++++++++++++++++++++++++++++++------ 1 file changed, 31 insertions(+), 6 deletions(-) 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;