From c84b864b6cf444ad25ae949e23a23acfcf9b3000 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Thu, 5 Nov 2026 16:55:08 +0100 Subject: [PATCH 1/4] fix(cli): pin the Supabase CA on every linked database connection Only gen types verified hosted servers against the bundled Supabase root CA. The shared linked resolver now pins it on the direct host and every pooler path, including the temp-role probe, for the supabase, supabase-staging and snap profiles. An explicit sslmode or sslrootcert from any source wins. --- .../db-config.integration.test.ts | 180 +++++++++++++++++- .../src/command-internal/db-config.layer.ts | 36 ++-- apps/cli/src/command-internal/pgdelta-ssl.ts | 7 - .../command-internal/pgdelta-ssl.unit.test.ts | 9 - .../storage-gateway.unit.test.ts | 2 +- .../supabase-ca-templates}/prod-ca-2021.ts | 0 .../supabase-ca-templates}/prod-ca-2025.ts | 0 .../supabase-ca-templates}/staging-ca-2021.ts | 0 apps/cli/src/command-internal/supabase-tls.ts | 38 ++++ .../commands/bootstrap/bootstrap.handler.ts | 1 + apps/cli/src/commands/db/dump/SIDE_EFFECTS.md | 4 +- apps/cli/src/commands/db/push/SIDE_EFFECTS.md | 14 +- apps/cli/src/commands/db/shared/migra.ts | 4 +- .../src/commands/gen/types/SIDE_EFFECTS.md | 17 +- .../src/commands/gen/types/types.handler.ts | 67 +++---- .../gen/types/types.integration.test.ts | 117 ++++++------ .../src/commands/gen/types/types.shared.ts | 7 - .../src/commands/gen/types/types.unit.test.ts | 5 - apps/cli/tests/helpers/live.ts | 2 +- .../tests/helpers/refuse-invalid-db-hosts.ts | 14 +- 20 files changed, 366 insertions(+), 158 deletions(-) delete mode 100644 apps/cli/src/command-internal/pgdelta-ssl.ts delete mode 100644 apps/cli/src/command-internal/pgdelta-ssl.unit.test.ts rename apps/cli/src/{commands/gen/types/templates => command-internal/supabase-ca-templates}/prod-ca-2021.ts (100%) rename apps/cli/src/{commands/gen/types/templates => command-internal/supabase-ca-templates}/prod-ca-2025.ts (100%) rename apps/cli/src/{commands/gen/types/templates => command-internal/supabase-ca-templates}/staging-ca-2021.ts (100%) create mode 100644 apps/cli/src/command-internal/supabase-tls.ts diff --git a/apps/cli/src/command-internal/db-config.integration.test.ts b/apps/cli/src/command-internal/db-config.integration.test.ts index b0580c87f4..7998f38dd3 100644 --- a/apps/cli/src/command-internal/db-config.integration.test.ts +++ b/apps/cli/src/command-internal/db-config.integration.test.ts @@ -87,6 +87,7 @@ import type { HostedMutationConsent } from "./hosted-consent/hosted-consent.toke import type { ResolvedTarget } from "./target/resolve-target.ts"; import { DbConnectError } from "./db-connection.errors.ts"; import { DbConnection, type DbSession, type PgConnInput } from "./db-connection.service.ts"; +import { rootCaBundle } from "./supabase-tls.ts"; import { StackApi } from "./stack-api.ts"; import { stackBackendLayer } from "./stack-backend.ts"; import { selection } from "../../tests/helpers/pins.ts"; @@ -984,6 +985,8 @@ describe("dbConfigResolver (linked config ordering)", () => { user: `cli_login_role.${adHocRef}`, password: "temporary-role-password", database: "postgres", + sslmode: "require", + sslrootcertInline: rootCaBundle(), suggestionContext: { dashboardUrl: "https://supabase.com/dashboard", profileName: "supabase", @@ -1008,6 +1011,8 @@ describe("dbConfigResolver (linked config ordering)", () => { user: `cli_login_role.${adHocRef}`, password: "temporary-role-password", database: "postgres", + sslmode: "require", + sslrootcertInline: rootCaBundle(), }, isLocal: false, dnsResolver: "native", @@ -1149,6 +1154,8 @@ describe("dbConfigResolver (linked config ordering)", () => { user: `cli_login_role.${adHocRef}`, password: "temporary-role-password", database: "postgres", + sslmode: "require", + sslrootcertInline: rootCaBundle(), suggestionContext: { dashboardUrl: "https://supabase.com/dashboard", profileName: "supabase", @@ -1173,6 +1180,8 @@ describe("dbConfigResolver (linked config ordering)", () => { user: `cli_login_role.${adHocRef}`, password: "temporary-role-password", database: "postgres", + sslmode: "require", + sslrootcertInline: rootCaBundle(), }, isLocal: false, dnsResolver: "native", @@ -1293,6 +1302,8 @@ describe("dbConfigResolver (linked config ordering)", () => { user: `postgres.${linkedRef}`, password: "linked-password", database: "postgres", + sslmode: "require", + sslrootcertInline: rootCaBundle(), suggestionContext: { dashboardUrl: "https://supabase.com/dashboard", profileName: "supabase", @@ -1403,6 +1414,8 @@ describe("dbConfigResolver (an explicit --project-ref fetches the pooler config) user: `postgres.${ref}`, password: "ambient-password", database: "postgres", + sslmode: "require", + sslrootcertInline: rootCaBundle(), suggestionContext: { dashboardUrl: "https://supabase.com/dashboard", profileName: "supabase", @@ -1521,6 +1534,8 @@ describe("dbConfigResolver (an explicit --project-ref fetches the pooler config) user: `postgres.${targetRef}`, password: "flag-password", database: "postgres", + sslmode: "require", + sslrootcertInline: rootCaBundle(), suggestionContext: { dashboardUrl: "https://supabase.com/dashboard", profileName: "supabase", @@ -1919,6 +1934,8 @@ describe("dbConfigResolver (container pooler fallback)", () => { host: "aws-0-us-east-1.pooler.supabase.com", user: `cli_login_role.${ref}`, password: "temporary-role-password", + sslmode: "require", + sslrootcertInline: rootCaBundle(), }); } expect(requests).toEqual([ @@ -2073,6 +2090,8 @@ describe("resolveLinkedConn libpq TLS", () => { readonly reachable?: boolean; readonly password?: Option.Option; readonly poolerUrl?: string; + readonly profile?: string; + readonly projectHost?: string; } = {}, ) => withConfigEnv( @@ -2082,7 +2101,8 @@ describe("resolveLinkedConn libpq TLS", () => { return yield* resolveLinkedConn( ref, opts.workdir ?? (yield* projectWorkdir([])), - "invalid", + opts.profile ?? "supabase", + opts.projectHost ?? "invalid", "supabase.com", "native", opts.password ?? Option.some("secret"), @@ -2168,6 +2188,164 @@ describe("resolveLinkedConn libpq TLS", () => { }).pipe(Effect.scoped, Effect.provide(linkedConnLayer)), ); + const pinned = { sslmode: "require", sslrootcertInline: rootCaBundle() }; + const serviceFileWith = (content: string) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "cli-linked-service-" }); + const serviceFile = path.join(dir, "pg_service.conf"); + yield* fs.writeFileString(serviceFile, content); + return { PGSERVICE: "svc", PGSERVICEFILE: serviceFile }; + }); + const hostedPoolerUrl = `postgres://postgres.${ref}:[YOUR-PASSWORD]@${poolerHost}:6543/postgres`; + + describe("Supabase CA pin", () => { + for (const [profile, projectHost] of [ + ["supabase", "supabase.co"], + ["supabase-staging", "supabase.red"], + ["snap", "snapcloud.dev"], + ] as const) { + it.effect(`pins the bundled CA on the ${profile} direct host when TLS is unset`, () => + Effect.gen(function* () { + const conn = yield* linkedConn({}, { profile, projectHost }); + expect(conn).toMatchObject({ host: `db.${ref}.${projectHost}`, ...pinned }); + }).pipe(Effect.provide(linkedConnLayer)), + ); + } + + it.effect("pins the bundled CA on the direct host before minting a login role", () => + Effect.gen(function* () { + const conn = yield* linkedConn( + {}, + { projectHost: "supabase.co", password: Option.some("") }, + ); + expect(conn).toMatchObject({ user: "cli_login_x", password: "temp", ...pinned }); + }).pipe(Effect.provide(linkedConnLayer)), + ); + + it.effect("keeps verify-full with the user's own root cert", () => + Effect.gen(function* () { + const conn = yield* linkedConn( + { PGSSLMODE: "verify-full", PGSSLROOTCERT: "/ca.pem" }, + { projectHost: "supabase.co" }, + ); + expect(conn).toMatchObject({ sslmode: "verify-full", sslrootcert: "/ca.pem" }); + expect(conn.sslrootcertInline).toBeUndefined(); + }).pipe(Effect.provide(linkedConnLayer)), + ); + + it.effect("keeps PGSSLMODE=disable", () => + Effect.gen(function* () { + const conn = yield* linkedConn({ PGSSLMODE: "disable" }, { projectHost: "supabase.co" }); + expect(conn.sslmode).toBe("disable"); + expect(conn.sslrootcertInline).toBeUndefined(); + }).pipe(Effect.provide(linkedConnLayer)), + ); + + it.effect("keeps a PGSSLROOTCERT with no sslmode", () => + Effect.gen(function* () { + const conn = yield* linkedConn( + { PGSSLROOTCERT: "/ca.pem" }, + { projectHost: "supabase.co" }, + ); + expect(conn).toMatchObject({ sslrootcert: "/ca.pem" }); + expect(conn.sslmode).toBeUndefined(); + expect(conn.sslrootcertInline).toBeUndefined(); + }).pipe(Effect.provide(linkedConnLayer)), + ); + + it.effect("keeps a service-file sslmode", () => + Effect.gen(function* () { + const env = yield* serviceFileWith("[svc]\nsslmode=require\n"); + const conn = yield* linkedConn(env, { projectHost: "supabase.co" }); + expect(conn.sslmode).toBe("require"); + expect(conn.sslrootcertInline).toBeUndefined(); + }).pipe(Effect.scoped, Effect.provide(linkedConnLayer)), + ); + + it.effect("pins the bundled CA on the pooler when TLS is unset", () => + Effect.gen(function* () { + const conn = yield* linkedConn( + {}, + { reachable: false, poolerUrl: hostedPoolerUrl, password: Option.some("secret") }, + ); + expect(conn).toMatchObject({ host: poolerHost, ...pinned }); + }).pipe(Effect.scoped, Effect.provide(linkedConnLayer)), + ); + + it.effect("keeps an explicit sslmode in the saved pooler URL", () => + Effect.gen(function* () { + const conn = yield* linkedConn( + {}, + { + reachable: false, + poolerUrl: `${hostedPoolerUrl}?sslmode=disable`, + password: Option.some("secret"), + }, + ); + expect(conn.sslmode).toBe("disable"); + expect(conn.sslrootcertInline).toBeUndefined(); + }).pipe(Effect.scoped, Effect.provide(linkedConnLayer)), + ); + + it.effect("pins the temp-role probe connection through the pooler", () => { + const probed: Array = []; + const probeSession: DbSession = { + exec: () => Effect.void, + execBatch: () => Effect.void, + query: () => Effect.succeed([]), + extensionExists: () => Effect.succeed(false), + copyToCsv: () => Effect.succeed(new Uint8Array()), + queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "SELECT 1" }), + }; + return Effect.gen(function* () { + const conn = yield* linkedConn( + {}, + { reachable: false, poolerUrl: hostedPoolerUrl, password: Option.some("") }, + ); + expect(probed).toHaveLength(1); + expect(probed[0]).toMatchObject({ + host: poolerHost, + user: `cli_login_x.${ref}`, + ...pinned, + }); + expect(conn).toMatchObject(pinned); + }).pipe( + Effect.scoped, + Effect.provideService(DbConnection, { + connect: (conn) => { + probed.push(conn); + return Effect.succeed(probeSession); + }, + }), + Effect.provide(linkedConnLayer), + ); + }); + + for (const [profile, projectHost] of [ + ["supabase-local", "supabase.red"], + ["my-profile", "supabase.co"], + ] as const) { + it.effect(`does not pin the ${profile} profile`, () => + Effect.gen(function* () { + const conn = yield* linkedConn({}, { profile, projectHost }); + expect(conn.host).toBe(`db.${ref}.${projectHost}`); + expect(conn.sslmode).toBeUndefined(); + expect(conn.sslrootcertInline).toBeUndefined(); + }).pipe(Effect.provide(linkedConnLayer)), + ); + } + + it.effect("does not pin a host outside the profile's domains", () => + Effect.gen(function* () { + const conn = yield* linkedConn({}, { profile: "supabase", projectHost: "supabase.red" }); + expect(conn.sslmode).toBeUndefined(); + expect(conn.sslrootcertInline).toBeUndefined(); + }).pipe(Effect.provide(linkedConnLayer)), + ); + }); + it.effect("names the libpq variables when PGSSLMODE is invalid on a reachable direct host", () => Effect.gen(function* () { const error = yield* linkedConn({ PGSSLMODE: "verifyfull" }).pipe(Effect.flip); diff --git a/apps/cli/src/command-internal/db-config.layer.ts b/apps/cli/src/command-internal/db-config.layer.ts index f2151bd67e..d3722963cf 100644 --- a/apps/cli/src/command-internal/db-config.layer.ts +++ b/apps/cli/src/command-internal/db-config.layer.ts @@ -57,6 +57,7 @@ import { mapHttpError } from "./http-errors.ts"; import { currentStackBackend } from "./stack-backend.ts"; import { StackApi, stackApiLayer } from "./stack-api.ts"; import { stackLocalDatabaseConn } from "./stack-local-database.ts"; +import { pinSupabaseTls } from "./supabase-tls.ts"; import { block, type OperationEntry } from "./command-permissions/model.ts"; import { renderDbUrlTarget, renderLocalTarget } from "./target/target-identity.ts"; import { isCheckoutLinked } from "../shared/config/link-snapshot.ts"; @@ -512,6 +513,7 @@ const fetchApiPoolerUrl = Effect.fn("DbConfig.fetchApiPoolerUrl")( const resolvePoolerConn = Effect.fn("DbConfig.resolvePoolerConn")(function* ( ref: string, workdir: string, + profile: string, poolerHost: string, dnsResolver: "native" | "https", password: string, @@ -551,7 +553,7 @@ const resolvePoolerConn = Effect.fn("DbConfig.resolvePoolerConn")(function* ( } } if (Option.isNone(pooler)) return Option.none(); - const poolerConn = pooler.value; + const poolerConn = pinSupabaseTls(pooler.value, profile); if (password.length > 0) return Option.some({ ...poolerConn, password }); // Mint a temp role; preserve Supavisor's `.` tenant suffix. const originalUser = poolerConn.user; @@ -578,6 +580,7 @@ const resolvePoolerConn = Effect.fn("DbConfig.resolvePoolerConn")(function* ( export const resolveLinkedConn = Effect.fn("DbConfig.resolveLinkedConn")(function* ( ref: string, workdir: string, + profile: string, projectHost: string, poolerHost: string, dnsResolver: "native" | "https", @@ -621,24 +624,28 @@ export const resolveLinkedConn = Effect.fn("DbConfig.resolveLinkedConn")(functio yield* Effect.annotateCurrentSpan("db.direct_reachable", reachable); if (reachable) { /* Same libpq TLS and timeout precedence as the pooler URL; host, port, role and database stay - pinned. The empty `passfile=` skips a `.pgpass` read for a password that is discarded. */ + pinned. The empty `passfile=` skips a `.pgpass` read for a password that is discarded. Absent an + explicit `sslmode`/`sslrootcert`, the Supabase CA is pinned, as on the pooler path. */ const parsed = yield* parseConnectionString( `postgresql://postgres@${host}:${DIRECT_PORT}/postgres?passfile=`, ); if (parsed === undefined) { return yield* new Errors.DbConfigLibpqEnvError({ message: LIBPQ_ENV_MESSAGE }); } - const direct = { - ...base, - ...Struct.pick(parsed, [ - "sslmode", - "sslrootcert", - "sslcert", - "sslkey", - "sslpassword", - "connectTimeoutSeconds", - ]), - }; + const direct = pinSupabaseTls( + { + ...base, + ...Struct.pick(parsed, [ + "sslmode", + "sslrootcert", + "sslcert", + "sslkey", + "sslpassword", + "connectTimeoutSeconds", + ]), + }, + profile, + ); if (direct.password.length > 0) return direct; return yield* initLoginRole(ref, direct); } @@ -649,6 +656,7 @@ export const resolveLinkedConn = Effect.fn("DbConfig.resolveLinkedConn")(functio const poolerConn = yield* resolvePoolerConn( ref, workdir, + profile, poolerHost, dnsResolver, base.password, @@ -877,6 +885,7 @@ export const dbConfigResolverLayer = Layer.effect( return yield* resolveLinkedConn( ref, cliSettings.workdir, + cliSettings.profile, cliSettings.projectHost, cliSettings.poolerHost, options.dnsResolver, @@ -977,6 +986,7 @@ export const dbConfigResolverLayer = Layer.effect( return yield* resolvePoolerConn( target.ref, cliSettings.workdir, + cliSettings.profile, cliSettings.poolerHost, input.dnsResolver, password, diff --git a/apps/cli/src/command-internal/pgdelta-ssl.ts b/apps/cli/src/command-internal/pgdelta-ssl.ts deleted file mode 100644 index 9d981842c0..0000000000 --- a/apps/cli/src/command-internal/pgdelta-ssl.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * The Supabase CA bundle for TLS-requiring remote Postgres endpoints (staging + production + - * snapshot roots, concatenated). The migra engine passes it to its edge-runtime script as - * `SSL_CA` when the SSL probe reports the server requires TLS. - */ -export const PG_DELTA_CA_BUNDLE = - "-----BEGIN CERTIFICATE-----\nMIID1DCCArygAwIBAgIUbYRdq/8/uNq8G9stMCdOFSBgA2MwDQYJKoZIhvcNAQEL\nBQAwczELMAkGA1UEBhMCVVMxEDAOBgNVBAgMB0RlbHdhcmUxEzARBgNVBAcMCk5l\ndyBDYXN0bGUxFTATBgNVBAoMDFN1cGFiYXNlIEluYzEmMCQGA1UEAwwdU3VwYWJh\nc2UgU3RhZ2luZyBSb290IDIwMjEgQ0EwHhcNMjEwNDI4MTAzNjEzWhcNMzEwNDI2\nMTAzNjEzWjBzMQswCQYDVQQGEwJVUzEQMA4GA1UECAwHRGVsd2FyZTETMBEGA1UE\nBwwKTmV3IENhc3RsZTEVMBMGA1UECgwMU3VwYWJhc2UgSW5jMSYwJAYDVQQDDB1T\ndXBhYmFzZSBTdGFnaW5nIFJvb3QgMjAyMSBDQTCCASIwDQYJKoZIhvcNAQEBBQAD\nggEPADCCAQoCggEBAN0AKRE8a56O8LaZxiOAcHFUFnwiKUvPoXPq26Ifw+Nv+7zg\nN2V5WnMZbbw24q61Os60ZUn0XmbVtuIeJ+stPHsO7qxxuL+bmPR+qU5tkDrIOyEe\nYD/2u8/q6ssVv42k4XcXbhM6RVz7CkCDY0TiBm1bMtRZso3xB6E9wAjxDf43XfV5\nPAGs3JI+Zo/vyqCDlN0hHOrB/aBl01JXqQWI84Gia5ooucq4SjA1CyawBcQ2IAvG\nrXuy1BouY+xM3zRuNvtfFP6rb5Mta+jCYEMh1AZ8yP8sYUWAyhxX6k9EbOb009wQ\naZljbUCh/UglGWuBxdzePavx+zPjzWXB1NyVkpkCAwEAAaNgMF4wCwYDVR0PBAQD\nAgEGMB0GA1UdDgQWBBQFx+PHLf27iIo/PMfIfGqXF7Zb+DAfBgNVHSMEGDAWgBQF\nx+PHLf27iIo/PMfIfGqXF7Zb+DAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEB\nCwUAA4IBAQB/xIiz5dDqzGXjqYqXZYx4iSfSxsVayeOPDMfmaiCfSMJEUG4cUiwG\nOvMPGztaUEYeip5SCvSKuAAjVkXyP7ahKR7t7lZ9mErVXyxSZoVLbOd578CuYiZk\nOgT17UjPv66WMzEKEr8wGpomTYWWfEkuqt8ENdiM1Z4LNFahdKj36+jm6/a+9R8K\n25VIL68DTaQpBxFWG6ixC1HRMHJ12lDhKsshIi099BVpkGibESlxPrQOdKKqBB/J\nvIX+/Hb+mS4H5zYMeK2wX0onp+GBcD6X9L1UJuXMVd+BRan8RFidXL5s3++xXjQq\nNzbc6lnA69urKffvcT07YwMsY/OmHzVa\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nMIIDxDCCAqygAwIBAgIUbLxMod62P2ktCiAkxnKJwtE9VPYwDQYJKoZIhvcNAQEL\nBQAwazELMAkGA1UEBhMCVVMxEDAOBgNVBAgMB0RlbHdhcmUxEzARBgNVBAcMCk5l\ndyBDYXN0bGUxFTATBgNVBAoMDFN1cGFiYXNlIEluYzEeMBwGA1UEAwwVU3VwYWJh\nc2UgUm9vdCAyMDIxIENBMB4XDTIxMDQyODEwNTY1M1oXDTMxMDQyNjEwNTY1M1ow\nazELMAkGA1UEBhMCVVMxEDAOBgNVBAgMB0RlbHdhcmUxEzARBgNVBAcMCk5ldyBD\nYXN0bGUxFTATBgNVBAoMDFN1cGFiYXNlIEluYzEeMBwGA1UEAwwVU3VwYWJhc2Ug\nUm9vdCAyMDIxIENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqQXW\nQyHOB+qR2GJobCq/CBmQ40G0oDmCC3mzVnn8sv4XNeWtE5XcEL0uVih7Jo4Dkx1Q\nDmGHBH1zDfgs2qXiLb6xpw/CKQPypZW1JssOTMIfQppNQ87K75Ya0p25Y3ePS2t2\nGtvHxNjUV6kjOZjEn2yWEcBdpOVCUYBVFBNMB4YBHkNRDa/+S4uywAoaTWnCJLUi\ncvTlHmMw6xSQQn1UfRQHk50DMCEJ7Cy1RxrZJrkXXRP3LqQL2ijJ6F4yMfh+Gyb4\nO4XajoVj/+R4GwywKYrrS8PrSNtwxr5StlQO8zIQUSMiq26wM8mgELFlS/32Uclt\nNaQ1xBRizkzpZct9DwIDAQABo2AwXjALBgNVHQ8EBAMCAQYwHQYDVR0OBBYEFKjX\nuXY32CztkhImng4yJNUtaUYsMB8GA1UdIwQYMBaAFKjXuXY32CztkhImng4yJNUt\naUYsMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAB8spzNn+4VU\ntVxbdMaX+39Z50sc7uATmus16jmmHjhIHz+l/9GlJ5KqAMOx26mPZgfzG7oneL2b\nVW+WgYUkTT3XEPFWnTp2RJwQao8/tYPXWEJDc0WVQHrpmnWOFKU/d3MqBgBm5y+6\njB81TU/RG2rVerPDWP+1MMcNNy0491CTL5XQZ7JfDJJ9CCmXSdtTl4uUQnSuv/Qx\nCea13BX2ZgJc7Au30vihLhub52De4P/4gonKsNHYdbWjg7OWKwNv/zitGDVDB9Y2\nCMTyZKG3XEu5Ghl1LEnI3QmEKsqaCLv12BnVjbkSeZsMnevJPs1Ye6TjjJwdik5P\no/bKiIz+Fq8=\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nMIIDxzCCAq+gAwIBAgIUeX+gpfmsRW9asFkRvjyXjHxbfgcwDQYJKoZIhvcNAQEL\nBQAwazELMAkGA1UEBhMCVVMxEDAOBgNVBAgMB0RlbHdhcmUxEzARBgNVBAcMCk5l\ndyBDYXN0bGUxFTATBgNVBAoMDFN1cGFiYXNlIEluYzEeMBwGA1UEAwwVU3VwYWJh\nc2UgUm9vdCAyMDIxIENBMB4XDTI1MDkwMzA4MDEyNVoXDTM1MDkwMTA4MDEyNVow\nazELMAkGA1UEBhMCVVMxEDAOBgNVBAgMB0RlbHdhcmUxEzARBgNVBAcMCk5ldyBD\nYXN0bGUxFTATBgNVBAoMDFN1cGFiYXNlIEluYzEeMBwGA1UEAwwVU3VwYWJhc2Ug\nUm9vdCAyMDIxIENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA5Ve7\ni9UAmc7luUilELPtqzEk8nGHxg7nY0aCStr625M7+K4OPO6RUllTsHh47k1jWyzm\nLXLlyYwCsYCjQp+3vn06H+F/HRUxBt6CK2B7bNng230exTunk0xFvfkX6YgHR7B3\n1B7L25Rq3PhuRFPV4hnGYRam2XBZC4UNPqoAgrhV0HOYzXXAVoTr2yaBTMnB331Z\nRwOmINh7eqTCk/JRZbb6vfZOhZRAVAe9AoRLoG8aKwmeoLGwlu0UuFx6z3E+6bmA\nfSNa8Lx02GEoCdPLw9IRKUFq/SgBpQUKm44H1fDwTjH2CMM0N4p0mL/6wXnNeHvt\nC40MmKZ0RcVmHE5wBwIDAQABo2MwYTAdBgNVHQ4EFgQUjvEE541toZcwtXQlZlcB\nYOBRTnowHwYDVR0jBBgwFoAUjvEE541toZcwtXQlZlcBYOBRTnowDwYDVR0TAQH/\nBAUwAwEB/zAOBgNVHQ8BAf8EBAMCAYYwDQYJKoZIhvcNAQELBQADggEBACD5IcGP\nXKvS9qg0CgEQPFqYavt5c7P+0xxFgiZe+xoG8fUw58yNeK2APtgGPRpxEOGfAlNx\nz9HDt4gcyHEE00B3qAVDm49pqNxioFWzNqU2LGfM/HL1QmN6urR7hCOkVCJddvOc\nFhFX4nZDuRfaBboDvS5HlK3Pzxddp9hvrJi2bemr8HLqYc3HzmVckgPGSLML6t+h\n4LRCXSlQsDgQ1LZ4KHsl4cq7K51N6FOXQBLB5q4lMKhs0VUhCT8Pdsj12+84laCV\nc22q6p2mdT9SaernCSRnWazXWisgpjv3H7Ex4S1DCYjJIwn3PUToGFv1r8YRN2/S\nO19yVSxxCIf64Sg=\n-----END CERTIFICATE-----\n"; diff --git a/apps/cli/src/command-internal/pgdelta-ssl.unit.test.ts b/apps/cli/src/command-internal/pgdelta-ssl.unit.test.ts deleted file mode 100644 index 68844c665d..0000000000 --- a/apps/cli/src/command-internal/pgdelta-ssl.unit.test.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { PG_DELTA_CA_BUNDLE } from "./pgdelta-ssl.ts"; - -describe("PG_DELTA_CA_BUNDLE", () => { - it("concatenates the three Supabase CA certificates", () => { - expect(PG_DELTA_CA_BUNDLE.match(/BEGIN CERTIFICATE/g)).toHaveLength(3); - }); -}); diff --git a/apps/cli/src/command-internal/storage-gateway.unit.test.ts b/apps/cli/src/command-internal/storage-gateway.unit.test.ts index 0ae48a9be2..48fb95bd7b 100644 --- a/apps/cli/src/command-internal/storage-gateway.unit.test.ts +++ b/apps/cli/src/command-internal/storage-gateway.unit.test.ts @@ -7,7 +7,7 @@ import * as HttpClientResponse from "effect/http/HttpClientResponse"; import type * as HttpClientRequest from "effect/http/HttpClientRequest"; import { withEnvVar } from "../../tests/helpers/command-mocks.ts"; -import caProd2021 from "../commands/gen/types/templates/prod-ca-2021.ts"; +import caProd2021 from "./supabase-ca-templates/prod-ca-2021.ts"; import { KONG_LOCAL_TLS_CERT, KONG_LOCAL_TLS_KEY, diff --git a/apps/cli/src/commands/gen/types/templates/prod-ca-2021.ts b/apps/cli/src/command-internal/supabase-ca-templates/prod-ca-2021.ts similarity index 100% rename from apps/cli/src/commands/gen/types/templates/prod-ca-2021.ts rename to apps/cli/src/command-internal/supabase-ca-templates/prod-ca-2021.ts diff --git a/apps/cli/src/commands/gen/types/templates/prod-ca-2025.ts b/apps/cli/src/command-internal/supabase-ca-templates/prod-ca-2025.ts similarity index 100% rename from apps/cli/src/commands/gen/types/templates/prod-ca-2025.ts rename to apps/cli/src/command-internal/supabase-ca-templates/prod-ca-2025.ts diff --git a/apps/cli/src/commands/gen/types/templates/staging-ca-2021.ts b/apps/cli/src/command-internal/supabase-ca-templates/staging-ca-2021.ts similarity index 100% rename from apps/cli/src/commands/gen/types/templates/staging-ca-2021.ts rename to apps/cli/src/command-internal/supabase-ca-templates/staging-ca-2021.ts diff --git a/apps/cli/src/command-internal/supabase-tls.ts b/apps/cli/src/command-internal/supabase-tls.ts new file mode 100644 index 0000000000..c2fa599262 --- /dev/null +++ b/apps/cli/src/command-internal/supabase-tls.ts @@ -0,0 +1,38 @@ +import { getDomain } from "tldts"; + +import type { PgConnInput } from "./db-connection.service.ts"; +import { isDirectDbHost } from "./pooler-fallback.ts"; +import { poolerHost, projectHost } from "./profile.ts"; +import caProd2021 from "./supabase-ca-templates/prod-ca-2021.ts"; +import caProd2025 from "./supabase-ca-templates/prod-ca-2025.ts"; +import caStaging2021 from "./supabase-ca-templates/staging-ca-2021.ts"; + +export function rootCaBundle() { + return `${caStaging2021}${caProd2021}${caProd2025}`; +} + +/** Built-in profiles whose database servers chain to {@link rootCaBundle}. */ +const PINNED_PROFILES: ReadonlySet = new Set(["supabase", "supabase-staging", "snap"]); + +/** + * Pins the bundled Supabase CA (`require` plus an inline root CA, which the driver promotes to + * `verify-ca`) on a connection to a hosted Supabase database of the active built-in profile. A + * connection that sets `sslmode` or `sslrootcert` from any source, a host outside the profile's + * project and pooler domains, and every other profile (`supabase-local`, YAML) are + * returned unchanged. + */ +export function pinSupabaseTls(conn: PgConnInput, profile: string): PgConnInput { + if ( + !PINNED_PROFILES.has(profile) || + conn.sslmode !== undefined || + conn.sslrootcert !== undefined || + conn.sslrootcertInline !== undefined + ) { + return conn; + } + const domain = getDomain(conn.host); + const hosted = + isDirectDbHost(conn.host, projectHost(profile)) || + (domain !== null && domain.toLowerCase() === poolerHost(profile)); + return hosted ? { ...conn, sslmode: "require", sslrootcertInline: rootCaBundle() } : conn; +} diff --git a/apps/cli/src/commands/bootstrap/bootstrap.handler.ts b/apps/cli/src/commands/bootstrap/bootstrap.handler.ts index 08aed02089..b773aa07f9 100644 --- a/apps/cli/src/commands/bootstrap/bootstrap.handler.ts +++ b/apps/cli/src/commands/bootstrap/bootstrap.handler.ts @@ -372,6 +372,7 @@ export const bootstrap = Effect.fn("bootstrap")(function* ( const resolvedConn = yield* resolveLinkedConn( projectRef, workdir, + cliSettings.profile, cliSettings.projectHost, cliSettings.poolerHost, dnsResolver, diff --git a/apps/cli/src/commands/db/dump/SIDE_EFFECTS.md b/apps/cli/src/commands/db/dump/SIDE_EFFECTS.md index 8fd54cf5cd..60a2e61772 100644 --- a/apps/cli/src/commands/db/dump/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/dump/SIDE_EFFECTS.md @@ -112,7 +112,9 @@ shell inherits the suppressing variables and is missed. - **TLS settings in `pg_dump`.** A remote target (a remote project or branch, or `--remote=`) gets the resolved `sslmode` and connect timeout, the root cert only under `require`/`verify-ca`/`verify-full`, and the client cert and key unless the key needs - `sslpassword` (libpq reads no passphrase from the environment). `system` pins + `sslpassword` (libpq reads no passphrase from the environment). The Supabase CA pin that + `db push` describes reaches `pg_dump` as `PGSSLMODE=require` only: the inline CA is not + forwarded, so `pg_dump` requires TLS without verifying the server. `system` pins `verify-full`. Verifying without a root cert, or with `system`, uses the image's trust store in a container and the host's CA bundle for a native `pg_dump`, which keeps libpq's `~/.postgresql/root.crt` when no root cert is set, keeps `system` on libpq 16+ diff --git a/apps/cli/src/commands/db/push/SIDE_EFFECTS.md b/apps/cli/src/commands/db/push/SIDE_EFFECTS.md index 6b74b50f73..6d3439c46f 100644 --- a/apps/cli/src/commands/db/push/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/push/SIDE_EFFECTS.md @@ -144,11 +144,19 @@ project ref and its environment. adopted into TS in PR supabase/cli#5671 (landed on develop as `b48fad60`). - **Remote-project libpq env.** On every remote-project database connection, `PGSSLMODE`, `PGSSLROOTCERT`, `PGSSLCERT`/`PGSSLKEY`/`PGSSLPASSWORD`, `PGCONNECT_TIMEOUT`, and `PGSERVICE`/`PGSERVICEFILE` are - read from the shell only, never the project `.env*` (`gen types` keeps its pinned mode and CA, - except on the pooler temp-role probe), on the direct host and the IPv4 pooler alike; host, port, - role, and database stay pinned. As in libpq, a `PGSERVICE` entry's settings outrank these vars. + read from the shell only, never the project `.env*`, on the direct host and the IPv4 pooler + alike; host, port, role, and database stay pinned. As in libpq, a `PGSERVICE` entry's settings outrank these vars. An invalid mode or `PGCONNECT_TIMEOUT`, an unknown `PGSERVICE`, or a lone `PGSSLCERT`/`PGSSLKEY` outside `sslmode=disable` fails with `invalid libpq environment` on either route. + **Supabase CA pin (every remote-project connection, shared by all commands that resolve a linked + database):** when neither `sslmode` nor `sslrootcert` is set (query, `pg_service.conf`, + `PGSSLMODE` or `PGSSLROOTCERT`) and the host is a `db..` direct host or a + pooler host of the `supabase`, `supabase-staging` or `snap` built-in profile, the connection uses + `sslmode=require` with the bundled Supabase root CA, which the driver verifies as `verify-ca` + (chain checked, hostname not). This covers the temp-role probe through the pooler and the + container pooler fallback. Any explicit setting, `disable` and `verify-full` with the user's own + root cert included, is passed through unchanged. `supabase-local` and YAML profiles are + never pinned. `sslmode=disable` dials plaintext and reads no CA or client-cert file; otherwise those PEM files are read when set and a missing one fails the connection, and a `pg_service.conf` is read whenever `PGSERVICE` names a service. The pooler route also reads `.pgpass` (`PGPASSFILE`, else diff --git a/apps/cli/src/commands/db/shared/migra.ts b/apps/cli/src/commands/db/shared/migra.ts index 9f07c37a8a..c9e43caa0f 100644 --- a/apps/cli/src/commands/db/shared/migra.ts +++ b/apps/cli/src/commands/db/shared/migra.ts @@ -16,7 +16,7 @@ import { import { parseConnectionString } from "../../../command-internal/db-config.parse.ts"; import { DockerRun } from "../../../command-internal/docker-run.service.ts"; import { EdgeRuntimeScript } from "../../../command-internal/edge-runtime-script.service.ts"; -import { PG_DELTA_CA_BUNDLE } from "../../../command-internal/pgdelta-ssl.ts"; +import { rootCaBundle } from "../../../command-internal/supabase-tls.ts"; import { PgDeltaSslProbe } from "../../../command-internal/pgdelta-ssl-probe.service.ts"; import { rewriteDumpHostForToolContainer, @@ -148,7 +148,7 @@ const buildMigraEnv = Effect.fn("Migra.buildEnv")(function* (params: { if (yield* isSslDebugEnabled) env["SUPABASE_SSL_DEBUG"] = "true"; // Probe the target for TLS; if it speaks TLS, inject the embedded CA bundle as SSL_CA. const requireSsl = yield* probe.requireSsl(params.target); - if (requireSsl) env["SSL_CA"] = PG_DELTA_CA_BUNDLE; + if (requireSsl) env["SSL_CA"] = rootCaBundle(); if (params.schema.length > 0) { env["INCLUDED_SCHEMAS"] = params.schema.join(","); } else { diff --git a/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md b/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md index fd13119453..d87819f218 100644 --- a/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md @@ -28,7 +28,7 @@ instead of inspecting `supabase_db_*`. | `~/.pg_service.conf` (or a `PGSERVICEFILE`/DSN `servicefile=` override) | libpq service file | `--remote=`, only when the DSN or `PGSERVICE` names a `service` — its settings fill in for connection fields the DSN itself omits | | `Package.resolved` (nearest) | JSON | `--lang swift` without `SUPABASE_TYPEGEN`, on macOS or Linux x86_64: the first one found walking up from the working directory, including the copies inside an `.xcodeproj` or `.xcworkspace`; the supabase-swift version it pins picks the `supabase-typegen` release | | `/supabase/.temp/rest-version` | plain text | the local target only, when `db.major_version > 14` — forces v9 compat if the tag contains `v9` | -| `$PGSSLROOTCERT`, `$PGSSLCERT` / `$PGSSLKEY`, `pg_service.conf` | PEM, libpq | non-TypeScript `--lang` on a remote project, when `PGSSLCERT`/`PGSSLKEY` or `PGSERVICE` are set; `$PGSSLROOTCERT` only on the pooler temp-role probe; the PEM files are skipped under `PGSSLMODE=disable` | +| `$PGSSLROOTCERT`, `$PGSSLCERT` / `$PGSSLKEY`, `pg_service.conf` | PEM, libpq | non-TypeScript `--lang` on a remote project, when `PGSSLCERT`/`PGSSLKEY` or `PGSERVICE` are set; `$PGSSLROOTCERT` also replaces the Supabase CA pin; the PEM files are skipped under `PGSSLMODE=disable` | ## Files Written @@ -109,7 +109,7 @@ way `--remote=` does. | `SUPABASE_TYPEGEN` | path of the `supabase-typegen` binary `--lang swift` runs, skipping the version lookup and download | no | | `XDG_CACHE_HOME`, `HOME` | locate the cache directory of the `--lang swift` release binary | no | | `PATH`, `PATHEXT`, `ComSpec` | find the tool of an out-of-process `--lang`; `PATHEXT` and `ComSpec` matter on Windows only | no (`PATHEXT` defaults to `.COM;.EXE;.BAT;.CMD`, `ComSpec` to `cmd.exe`; without `PATH` the tool is reported as not installed) | -| `PGSSLMODE`, `PGSSLROOTCERT`, `PGSSLCERT`, `PGSSLKEY`, `PGSSLPASSWORD`, `PGSERVICE`, `PGSERVICEFILE` | non-TypeScript `--lang` on a remote project: client-cert pair outside `sslmode=disable`; invalid values fail; mode and CA stay pinned except on the pooler temp-role probe (see Notes) | no | +| `PGSSLMODE`, `PGSSLROOTCERT`, `PGSSLCERT`, `PGSSLKEY`, `PGSSLPASSWORD`, `PGSERVICE`, `PGSERVICEFILE` | non-TypeScript `--lang` on a remote project: client-cert pair outside `sslmode=disable`; invalid values fail; an explicit `sslmode`/`sslrootcert` replaces the Supabase CA pin (see Notes) | no | | `PGCONNECT_TIMEOUT` | same path: the 15s query-timeout default pins it on the project database; the pooler temp-role probe always uses it | no | ## Exit Codes @@ -195,11 +195,14 @@ Not applicable. PostgreSQL connection, and there is no container to join a Docker network from. Use a host-reachable `--remote=` instead. - **TLS.** There is no SSL probe on any path. - - A remote project (including a resolved preview branch) connects with `sslmode=require` and the bundled Supabase CA pinned - (promoted to `verify-ca`), matching prior behavior. On a project ref the `PG*` env - (see `db push`) is validated and supplies the client-cert pair, and the 15s query timeout - sets the connect timeout; the pooler temp-role probe runs before the pin and takes the shell - `PG*` TLS and timeout settings; a preview branch's direct host takes no `PG*` setting. + - A remote project (including a resolved preview branch) on a `supabase`, `supabase-staging` or + `snap` profile host connects with `sslmode=require` and the bundled Supabase CA pinned (promoted to + `verify-ca`) unless `sslmode` or `sslrootcert` is set from any source, which is then used as + is. The connection resolver applies the pin for a project ref, its direct host and pooler + alike, including the pooler temp-role probe (see `db push`); this command applies it itself + only to a preview branch. On a project ref the `PG*` env (see `db push`) is validated and + supplies the client-cert pair, and the 15s query timeout sets the connect timeout; a preview + branch's direct host takes no `PG*` setting. - `--remote=` honors the DSN's own `sslmode`/`sslrootcert` when either is set, even against a target classified local (e.g. a TLS tunnel on the loopback stack); otherwise, a known Supabase host gets the Supabase CA pinned the same way, and any diff --git a/apps/cli/src/commands/gen/types/types.handler.ts b/apps/cli/src/commands/gen/types/types.handler.ts index 63510cfc7e..23dee9b7e1 100644 --- a/apps/cli/src/commands/gen/types/types.handler.ts +++ b/apps/cli/src/commands/gen/types/types.handler.ts @@ -1,6 +1,5 @@ import { ChildProcessSpawner } from "effect/process"; import { Effect, FileSystem, Match, Option, Path, Predicate, Result, Stdio, Stream } from "effect"; -import { getDomain } from "tldts"; import { DnsResolverFlag } from "../../../command-internal/global-flags.ts"; import { announceTarget } from "../../../shared/output/command-target.service.ts"; import { Output } from "../../../shared/output/output.service.ts"; @@ -50,6 +49,7 @@ import { isDirectDbHost, runWithPoolerFallback, } from "../../../command-internal/pooler-fallback.ts"; +import { pinSupabaseTls } from "../../../command-internal/supabase-tls.ts"; import type { GenTypesFlags } from "./types.command.ts"; import { GEN_TYPES_LANGUAGE_FLAG_NAMES, @@ -72,12 +72,7 @@ import { import { type GenTypesGenerateError, GenTypesGenerator } from "./types.generator.service.ts"; import { currentStackBackend } from "../../../command-internal/stack-backend.ts"; import { CommandPlatformApiFactory } from "../../../auth/command-platform-api-factory.service.ts"; -import { - defaultSchemas, - localDbContainerId, - parseQueryTimeoutMillis, - rootCaBundle, -} from "./types.shared.ts"; +import { defaultSchemas, localDbContainerId, parseQueryTimeoutMillis } from "./types.shared.ts"; const mapProjectTypesError = mapHttpError({ networkError: GenTypesNetworkError, @@ -107,18 +102,6 @@ function isProjectNotFound(cause: unknown) { return cause instanceof GenTypesUnexpectedStatusError && cause.status === 404; } -/** Pins the Supabase CA on a known-Supabase target, promoting `require` to `verify-ca`. */ -function pinSupabaseTls(conn: PgConnInput): PgConnInput { - return { ...conn, sslmode: "require", sslrootcertInline: rootCaBundle() }; -} - -/** Whether `host`'s registrable domain matches the active profile's pooler domain. */ -function isPoolerHost(host: string, poolerHost: string): boolean { - if (poolerHost.length === 0) return false; - const domain = getDomain(host); - return domain !== null && domain.toLowerCase() === poolerHost.toLowerCase(); -} - const GEN_TYPES_COMMAND_PATH = ["gen", "types"] as const; // A hosted project target excludes these flags; only the first passed, in this order, is reported. @@ -437,18 +420,16 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) const connectionInput = yield* connectionInputFor(target); const resolved = yield* dbConfig.resolveConnection(connectionInput); - const conn = pinSupabaseTls(resolved.conn); yield* runGenerate({ - conn, + conn: resolved.conn, isLocal: resolved.isLocal, includedSchemas, detectOneToOneRelationships: !flags.postgrestV9Compat, poolerFallback: { - directHost: conn.host, - eligible: !resolved.isLocal && isDirectDbHost(conn.host, cliSettings.projectHost), - resolve: dbConfig - .resolvePoolerFallback(connectionInput) - .pipe(Effect.map(Option.map(pinSupabaseTls))), + directHost: resolved.conn.host, + eligible: + !resolved.isLocal && isDirectDbHost(resolved.conn.host, cliSettings.projectHost), + resolve: dbConfig.resolvePoolerFallback(connectionInput), }, }); return; @@ -492,7 +473,9 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) cliSettings.poolerHost, ); return Predicate.isTagged(parsed, "ok") - ? Option.some(pinSupabaseTls({ ...parsed.conn, password: branchPassword })) + ? Option.some( + pinSupabaseTls({ ...parsed.conn, password: branchPassword }, cliSettings.profile), + ) : Option.none(); }), ), @@ -502,13 +485,16 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) ); yield* runGenerate({ - conn: pinSupabaseTls({ - host: branch.db_host, - port: branch.db_port, - user: branchUser, - password: branchPassword, - database: "postgres", - }), + conn: pinSupabaseTls( + { + host: branch.db_host, + port: branch.db_port, + user: branchUser, + password: branchPassword, + database: "postgres", + }, + cliSettings.profile, + ), isLocal: false, includedSchemas, detectOneToOneRelationships: !flags.postgrestV9Compat, @@ -706,16 +692,9 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) const includedSchemas = yield* resolveIncludedSchemas(); const resolved = yield* connectTo(target); - // A DSN's own `sslmode`/`sslrootcert` is honored as-is; only a known Supabase host with - // neither set gets the CA pinned, matching the project-ref/branch paths. - const conn = - !resolved.isLocal && - resolved.conn.sslmode === undefined && - resolved.conn.sslrootcert === undefined && - (isDirectDbHost(resolved.conn.host, cliSettings.projectHost) || - isPoolerHost(resolved.conn.host, cliSettings.poolerHost)) - ? pinSupabaseTls(resolved.conn) - : resolved.conn; + const conn = resolved.isLocal + ? resolved.conn + : pinSupabaseTls(resolved.conn, cliSettings.profile); yield* runGenerate({ conn, diff --git a/apps/cli/src/commands/gen/types/types.integration.test.ts b/apps/cli/src/commands/gen/types/types.integration.test.ts index 2207f34c10..854d23b46e 100644 --- a/apps/cli/src/commands/gen/types/types.integration.test.ts +++ b/apps/cli/src/commands/gen/types/types.integration.test.ts @@ -86,7 +86,8 @@ import type { import type { GenTypesFlags } from "./types.command.ts"; import { GenTypesLocalDbInspectError, GenTypesLocalDbNotRunningError } from "./types.errors.ts"; import { genTypes } from "./types.handler.ts"; -import { localDbContainerId, parseQueryTimeoutMillis, rootCaBundle } from "./types.shared.ts"; +import { localDbContainerId, parseQueryTimeoutMillis } from "./types.shared.ts"; +import { rootCaBundle } from "../../../command-internal/supabase-tls.ts"; import { stackBackendLayer } from "../../../command-internal/stack-backend.ts"; import { projectStateModeLayer } from "../../../shared/config/project-state-mode.ts"; import { @@ -1800,9 +1801,6 @@ describe("gen types", () => { expect(call?.lang).toBe(scenario.lang); expect(call?.includedSchemas).toEqual(["public"]); expect(call?.isLocal).toBe(false); - // project-ref generation always pins the Supabase CA, promoting sslmode to verify-ca. - expect(call?.conn.sslmode).toBe("require"); - expect(call?.conn.sslrootcertInline).toBe(rootCaBundle()); expect(linkedProjectCache.cached).toBe(true); }).pipe(Effect.provide(BunServices.layer)), ); @@ -1971,56 +1969,65 @@ describe("gen types", () => { }); describe("Preview-branch fallback", () => { - it.effect("falls back to preview branch config for non-TypeScript project refs", () => - Effect.gen(function* () { - const { layer, api, dbConfig, generator } = yield* setup({ - args: ["gen", "types", "--lang", "python", `--remote=${VALID_REF}`], - generatorOutput: "class PublicMovies(BaseModel):", - getProject: () => - Effect.fail(statusApiError(404, `{"message":"Preview branch not found"}`)), - getABranchConfig: ({ branch_id_or_ref }) => - Effect.succeed({ - ref: branch_id_or_ref, - postgres_version: "15.1", - postgres_engine: "15", - release_channel: "ga", - status: "ACTIVE_HEALTHY", - db_host: "127.0.0.1", - db_port: 5432, - db_user: "branch_user", - db_pass: "branch-password", - jwt_secret: "secret", - }), - createLoginRole: ({ ref }) => - Effect.fail( - new ApiCallFailure({ message: `unexpected login role creation for ${ref}` }), - ), - }); - yield* genTypes( - defaultFlags({ - remote: Option.some({ _tag: "Ref", ref: VALID_REF } as const), - lang: "python", - }), - ).pipe(Effect.provide(layer)); + for (const { dbHost, pinned } of [ + { dbHost: `db.${VALID_REF}.supabase.co`, pinned: true }, + { dbHost: "127.0.0.1", pinned: false }, + ]) { + it.effect( + `falls back to preview branch config for non-TypeScript project refs (${pinned ? "Supabase" : "other"} host)`, + () => + Effect.gen(function* () { + const { layer, api, dbConfig, generator } = yield* setup({ + args: ["gen", "types", "--lang", "python", `--remote=${VALID_REF}`], + generatorOutput: "class PublicMovies(BaseModel):", + getProject: () => + Effect.fail(statusApiError(404, `{"message":"Preview branch not found"}`)), + getABranchConfig: ({ branch_id_or_ref }) => + Effect.succeed({ + ref: branch_id_or_ref, + postgres_version: "15.1", + postgres_engine: "15", + release_channel: "ga", + status: "ACTIVE_HEALTHY", + db_host: dbHost, + db_port: 5432, + db_user: "branch_user", + db_pass: "branch-password", + jwt_secret: "secret", + }), + createLoginRole: ({ ref }) => + Effect.fail( + new ApiCallFailure({ message: `unexpected login role creation for ${ref}` }), + ), + }); + yield* genTypes( + defaultFlags({ + remote: Option.some({ _tag: "Ref", ref: VALID_REF } as const), + lang: "python", + }), + ).pipe(Effect.provide(layer)); - expect(api.requests).toContainEqual({ method: "getProject", input: { ref: VALID_REF } }); - expect(api.requests).toContainEqual({ - method: "getABranchConfig", - input: { branch_id_or_ref: VALID_REF }, - }); - expect(api.requests).not.toContainEqual( - expect.objectContaining({ method: "createLoginRole" }), - ); - expect(dbConfig.resolves).toHaveLength(0); - const call = generator.calls[0]; - expect(call?.conn.host).toBe("127.0.0.1"); - expect(call?.conn.user).toBe("branch_user"); - expect(call?.conn.password).toBe("branch-password"); - // Preview-branch generation pins the Supabase CA the same as any other remote target. - expect(call?.conn.sslmode).toBe("require"); - expect(call?.conn.sslrootcertInline).toBe(rootCaBundle()); - }).pipe(Effect.provide(BunServices.layer)), - ); + expect(api.requests).toContainEqual({ + method: "getProject", + input: { ref: VALID_REF }, + }); + expect(api.requests).toContainEqual({ + method: "getABranchConfig", + input: { branch_id_or_ref: VALID_REF }, + }); + expect(api.requests).not.toContainEqual( + expect.objectContaining({ method: "createLoginRole" }), + ); + expect(dbConfig.resolves).toHaveLength(0); + const call = generator.calls[0]; + expect(call?.conn.host).toBe(dbHost); + expect(call?.conn.user).toBe("branch_user"); + expect(call?.conn.password).toBe("branch-password"); + expect(call?.conn.sslmode).toBe(pinned ? "require" : undefined); + expect(call?.conn.sslrootcertInline).toBe(pinned ? rootCaBundle() : undefined); + }).pipe(Effect.provide(BunServices.layer)), + ); + } it.effect("falls back to preview branch config for any project 404 body", () => Effect.gen(function* () { @@ -2439,6 +2446,10 @@ describe("gen types", () => { expect(generator.calls).toHaveLength(2); expect(generator.calls[1]?.conn.host).toBe(poolerHost); expect(generator.calls[1]?.conn.password).toBe("branch-password"); + for (const call of generator.calls) { + expect(call.conn.sslmode).toBe("require"); + expect(call.conn.sslrootcertInline).toBe(rootCaBundle()); + } }).pipe(Effect.provide(BunServices.layer)), ); diff --git a/apps/cli/src/commands/gen/types/types.shared.ts b/apps/cli/src/commands/gen/types/types.shared.ts index f493bb62d2..14621d07d4 100644 --- a/apps/cli/src/commands/gen/types/types.shared.ts +++ b/apps/cli/src/commands/gen/types/types.shared.ts @@ -1,8 +1,5 @@ import { Effect, Schema } from "effect"; import { InvalidGenTypesDurationError } from "./types.errors.ts"; -import caProd2021 from "./templates/prod-ca-2021.ts"; -import caProd2025 from "./templates/prod-ca-2025.ts"; -import caStaging2021 from "./templates/staging-ca-2021.ts"; // Local Docker resource ids are hoisted to `command-internal` so the declarative seam // can derive the same `supabase_db_` name when checking the local stack. @@ -79,7 +76,3 @@ export function parseQueryTimeoutMillis( return totalMillis; }); } - -export function rootCaBundle() { - return `${caStaging2021}${caProd2021}${caProd2025}`; -} diff --git a/apps/cli/src/commands/gen/types/types.unit.test.ts b/apps/cli/src/commands/gen/types/types.unit.test.ts index 4658239bbb..cda6749794 100644 --- a/apps/cli/src/commands/gen/types/types.unit.test.ts +++ b/apps/cli/src/commands/gen/types/types.unit.test.ts @@ -3,7 +3,6 @@ import { Effect, Exit } from "effect"; import { parseSchemaFlags } from "../../../command-internal/schema-flags.ts"; import { defaultSchemas, - rootCaBundle, localDbContainerId, localNetworkId, parseQueryTimeoutMillis, @@ -86,8 +85,4 @@ describe("schema and id helpers", () => { const longId = "a".repeat(60); expect(localDbContainerId(longId)).toBe(`supabase_db_${"a".repeat(40)}`); }); - - it("bundles the staging and production CA certificates", () => { - expect(rootCaBundle().length).toBeGreaterThan(0); - }); }); diff --git a/apps/cli/tests/helpers/live.ts b/apps/cli/tests/helpers/live.ts index a9c4e7ec99..0d9c0e9ce1 100644 --- a/apps/cli/tests/helpers/live.ts +++ b/apps/cli/tests/helpers/live.ts @@ -4,7 +4,7 @@ import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/http"; import pg from "pg"; import { expect, inject, test as vitestTest } from "vitest"; -import { rootCaBundle } from "../../src/commands/gen/types/types.shared.ts"; +import { rootCaBundle } from "../../src/command-internal/supabase-tls.ts"; import { type CliRunError, requireCliSuccess, diff --git a/apps/cli/tests/helpers/refuse-invalid-db-hosts.ts b/apps/cli/tests/helpers/refuse-invalid-db-hosts.ts index a989c82d89..2548eb8fc1 100644 --- a/apps/cli/tests/helpers/refuse-invalid-db-hosts.ts +++ b/apps/cli/tests/helpers/refuse-invalid-db-hosts.ts @@ -6,10 +6,12 @@ export interface DirectHostToggle { directHostReachable: boolean; } +const FIXTURE_DB_HOST_SUFFIXES = [".invalid", ".supabase.co", ".supabase.red", ".snapcloud.dev"]; + /** - * Wraps `node:net` so a connection to a `db..invalid` fixture host fails at once instead of - * waiting on ambient DNS or TCP timing. Use it from a test file's `vi.mock("node:net", ...)` - * factory; other hosts connect normally. While the toggle passed in is on, the same hosts report a + * Wraps `node:net` so a connection to a `db.` fixture host on one of + * `FIXTURE_DB_HOST_SUFFIXES` fails at once instead of waiting on ambient DNS or TCP timing. Use it + * from a test file's `vi.mock("node:net", ...)` factory; other hosts connect normally. While the toggle passed in is on, the same hosts report a * successful connect instead, which fakes the TCP reachability probe only (nothing is dialed). */ export function refuseInvalidDbHosts( @@ -20,7 +22,11 @@ export function refuseInvalidDbHosts( ...net, connect: (connectOptions: NodeNet.NetConnectOpts) => { const host = "host" in connectOptions ? connectOptions.host : undefined; - if (typeof host !== "string" || !host.startsWith("db.") || !host.endsWith(".invalid")) { + if ( + typeof host !== "string" || + !host.startsWith("db.") || + !FIXTURE_DB_HOST_SUFFIXES.some((suffix) => host.endsWith(suffix)) + ) { return net.connect(connectOptions); } const socket = new net.Socket(); From 1ddb56baf0161eff8cc60545a248beb7671392c8 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Thu, 5 Nov 2026 18:57:01 +0100 Subject: [PATCH 2/4] fix(cli): fail fast when the temp-role probe cannot succeed - stop the temp-role retries at once on a rejected server certificate - add a CA-verification hint (PGSSLROOTCERT / PGSSLMODE=require), shown on the temp-role error - keep retrying auth and dial failures so propagation and ban detection are unchanged - normalize host case and trailing dot before matching direct hosts in pinSupabaseTls - document the built-in-profile restriction and the preview-branch pooler PG* handling - wrap an over-long JSDoc line in refuse-invalid-db-hosts --- .../src/command-internal/connect-errors.ts | 18 ++++ .../connect-errors.unit.test.ts | 21 +++++ .../db-config.integration.test.ts | 87 +++++++++++++++++-- .../src/command-internal/db-config.layer.ts | 19 +++- .../command-internal/db-connection.errors.ts | 5 ++ .../db-connection.sql-pg.layer.ts | 2 + apps/cli/src/command-internal/supabase-tls.ts | 5 +- .../supabase-tls.unit.test.ts | 46 ++++++++++ apps/cli/src/commands/db/push/SIDE_EFFECTS.md | 6 +- .../src/commands/gen/types/SIDE_EFFECTS.md | 9 +- .../tests/helpers/refuse-invalid-db-hosts.ts | 5 +- 11 files changed, 207 insertions(+), 16 deletions(-) create mode 100644 apps/cli/src/command-internal/supabase-tls.unit.test.ts diff --git a/apps/cli/src/command-internal/connect-errors.ts b/apps/cli/src/command-internal/connect-errors.ts index 7fd6054e1f..8e5c6670c6 100644 --- a/apps/cli/src/command-internal/connect-errors.ts +++ b/apps/cli/src/command-internal/connect-errors.ts @@ -55,6 +55,13 @@ export function isIPv6ConnectivityError(message: string): boolean { export const SUGGEST_ENV_VAR = "Connect to your database by setting the env var correctly: SUPABASE_DB_PASSWORD"; +/** + * Hint shown when the server certificate does not verify against the CA the connection trusts, + * which on a hosted project is the bundled Supabase CA unless the user chose a trust setting. + */ +export const SUGGEST_CA_VERIFICATION = + "The server certificate did not verify against the trusted CA (the bundled Supabase CA on a hosted project). Behind a TLS-inspecting proxy, set PGSSLROOTCERT to a CA bundle that trusts it, or set PGSSLMODE=require to skip certificate verification."; + /** * Shown instead of the remote-only "Network Restrictions" hint when `ctx.isLocal` is true — a * refused `--local` connection (Docker/Postgres not running) needs a different fix than an @@ -221,6 +228,16 @@ const TLS_ERROR_CODES = new Set([ "CERT_REJECTED", "HOSTNAME_MISMATCH", ]); + +/** + * Whether a connect failure is an X509 certificate-verification rejection. It is deterministic: + * the same server certificate fails the same way on every attempt. Sets + * `DbConnectError.certificateRejected`, which the temp-role probe keys off to stop retrying. + */ +export function isCertificateVerificationFailure(error: unknown): boolean { + const cause = deepestConnectCause(error); + return hasStringCode(cause) && TLS_ERROR_CODES.has(cause.code); +} // node-postgres' own message when the server answers `N` to SSLRequest // (`pg/lib/connection.js`); rendered as `tls error (server refused TLS connection)`. const SERVER_REFUSED_SSL = "The server does not support SSL connections"; @@ -363,6 +380,7 @@ export function connectSuggestion( ? localStackSuggestion(ctx.linkedCheckout) : suggestNetworkRestrictions(ctx.dashboardUrl); } + if (isCertificateVerificationFailure(error)) return SUGGEST_CA_VERIFICATION; if ( text.includes("SCRAM exchange: Wrong password") || text.includes("failed SASL auth") || diff --git a/apps/cli/src/command-internal/connect-errors.unit.test.ts b/apps/cli/src/command-internal/connect-errors.unit.test.ts index 3b31e9d043..c9c67a032e 100644 --- a/apps/cli/src/command-internal/connect-errors.unit.test.ts +++ b/apps/cli/src/command-internal/connect-errors.unit.test.ts @@ -3,11 +3,13 @@ import * as Pg from "pg"; import { describe, expect, it } from "vitest"; import { + SUGGEST_CA_VERIFICATION, SUGGEST_ENV_VAR, SUGGEST_LOCAL_STACK, connectFailureMessage, connectSuggestion, ipv6Suggestion, + isCertificateVerificationFailure, isDialFailure, isIPv6ConnectivityError, isIPv6ConnectivityErrorCause, @@ -269,6 +271,13 @@ describe("connectSuggestion", () => { ); }); + it("maps a certificate-verification failure to the CA hint", () => { + const err = sqlError( + systemError("self-signed certificate in certificate chain", "SELF_SIGNED_CERT_IN_CHAIN"), + ); + expect(connectSuggestion(err, ctx)).toBe(SUGGEST_CA_VERIFICATION); + }); + it("maps a refused local connection to the local stack hint", () => { const err = sqlError(systemError("connect ECONNREFUSED 127.0.0.1:54322", "ECONNREFUSED")); expect(connectSuggestion(err, { ...ctx, isLocal: true })).toBe(SUGGEST_LOCAL_STACK); @@ -421,6 +430,18 @@ describe("connectSuggestion", () => { }); }); +describe("isCertificateVerificationFailure", () => { + it("matches the X509 verification codes but not other TLS or dial failures", () => { + const withCode = (code: string) => + realSqlConnectError(Object.assign(new Error("failure"), { code })); + expect(isCertificateVerificationFailure(withCode("SELF_SIGNED_CERT_IN_CHAIN"))).toBe(true); + expect(isCertificateVerificationFailure(withCode("CERT_HAS_EXPIRED"))).toBe(true); + expect(isCertificateVerificationFailure(withCode("ECONNREFUSED"))).toBe(false); + expect(isCertificateVerificationFailure(withCode("ECONNRESET"))).toBe(false); + expect(isCertificateVerificationFailure(authFailedError())).toBe(false); + }); +}); + describe("isDialFailure", () => { it("classifies dial errno failures", () => { expect(isDialFailure(realSqlConnectError(dialError("ECONNREFUSED", "127.0.0.1", 54322)))).toBe( diff --git a/apps/cli/src/command-internal/db-config.integration.test.ts b/apps/cli/src/command-internal/db-config.integration.test.ts index 7998f38dd3..101d200a5c 100644 --- a/apps/cli/src/command-internal/db-config.integration.test.ts +++ b/apps/cli/src/command-internal/db-config.integration.test.ts @@ -85,7 +85,9 @@ import type { DbConnectionInput } from "./db-config.types.ts"; import { hostedMutationPreapproval } from "./hosted-consent/hosted-consent.ts"; import type { HostedMutationConsent } from "./hosted-consent/hosted-consent.token.ts"; import type { ResolvedTarget } from "./target/resolve-target.ts"; +import { SUGGEST_CA_VERIFICATION, SUGGEST_ENV_VAR } from "./connect-errors.ts"; import { DbConnectError } from "./db-connection.errors.ts"; +import { toConnectError } from "./db-connection.sql-pg.layer.ts"; import { DbConnection, type DbSession, type PgConnInput } from "./db-connection.service.ts"; import { rootCaBundle } from "./supabase-tls.ts"; import { StackApi } from "./stack-api.ts"; @@ -2675,19 +2677,23 @@ describe("dbConfigResolver.resolveConnection", () => { } return undefined; }); - const failingConnect = (failures: number) => { + const failingConnect = ( + failures: number, + failure: (cfg: PgConnInput) => DbConnectError = () => + new DbConnectError({ message: "password authentication failed" }), + ) => { const attempts = { count: 0 }; const layer = Layer.succeed(DbConnection, { - connect: () => + connect: (cfg) => Effect.suspend(() => { attempts.count += 1; - return attempts.count <= failures - ? Effect.fail(new DbConnectError({ message: "password authentication failed" })) - : Effect.succeed(session); + return attempts.count <= failures ? Effect.fail(failure(cfg)) : Effect.succeed(session); }), }); return { attempts, layer }; }; + const driverFailure = (code: string, message: string) => (cfg: PgConnInput) => + toConnectError(cfg, false, Object.assign(new Error(message), { code })); const forkResolve = ( input: DbConnectionInput, connection: Layer.Layer, @@ -2747,6 +2753,77 @@ describe("dbConfigResolver.resolveConnection", () => { }, ); + it.effect( + "keeps looking for bans when the dial fails, as a ban surfaces as a dial failure", + () => { + const requests: Array<{ readonly method: string; readonly path: string }> = []; + const connection = failingConnect( + Number.POSITIVE_INFINITY, + driverFailure("ETIMEDOUT", "connect ETIMEDOUT 203.0.113.9:6543"), + ); + return Effect.gen(function* () { + yield* bannedRequests(requests, ["203.0.113.7"]); + const { fiber, advance } = yield* forkResolve( + inputFor(linkedTarget(ref, { source: "flag", matchesLinkedRef: false })), + connection.layer, + ); + yield* advance(8); + const exit = yield* Fiber.await(fiber); + expect(failureText(exit)).toContain("LinkedDbNetworkBannedError"); + expect(connection.attempts.count).toBe(9); + }).pipe(Effect.scoped, Effect.provide([BunServices.layer, BunCrypto.layer])); + }, + ); + + it.effect("fails at once on a rejected server certificate and names the CA setting", () => { + const requests: Array<{ readonly method: string; readonly path: string }> = []; + const connection = failingConnect( + Number.POSITIVE_INFINITY, + driverFailure("SELF_SIGNED_CERT_IN_CHAIN", "self-signed certificate in certificate chain"), + ); + return Effect.gen(function* () { + yield* bannedRequests(requests, ["203.0.113.7"]); + const { fiber } = yield* forkResolve( + inputFor(linkedTarget(ref, { source: "flag", matchesLinkedRef: false })), + connection.layer, + ); + const exit = yield* Fiber.await(fiber); + const error = Exit.isFailure(exit) ? Cause.findErrorOption(exit.cause) : Option.none(); + expect(error).toEqual( + Option.some( + expect.objectContaining({ + _tag: "DbConfigConnectTempRoleError", + message: expect.stringContaining("tls error (self-signed certificate"), + suggestion: SUGGEST_CA_VERIFICATION, + }), + ), + ); + expect(SUGGEST_CA_VERIFICATION).not.toBe(SUGGEST_ENV_VAR); + expect(connection.attempts.count).toBe(1); + expect(requests.some((request) => request.path.includes("network-bans"))).toBe(false); + }).pipe(Effect.scoped, Effect.provide([BunServices.layer, BunCrypto.layer])); + }); + + it.effect("stops retrying authentication failures once the certificate is rejected", () => { + const requests: Array<{ readonly method: string; readonly path: string }> = []; + const connection = failingConnect(Number.POSITIVE_INFINITY, (cfg) => + connection.attempts.count <= 2 + ? new DbConnectError({ message: "password authentication failed" }) + : driverFailure("CERT_HAS_EXPIRED", "certificate has expired")(cfg), + ); + return Effect.gen(function* () { + yield* bannedRequests(requests, []); + const { fiber, advance } = yield* forkResolve( + inputFor(linkedTarget(ref, { source: "flag", matchesLinkedRef: false })), + connection.layer, + ); + yield* advance(2); + const exit = yield* Fiber.await(fiber); + expect(failureText(exit)).toContain("DbConfigConnectTempRoleError"); + expect(connection.attempts.count).toBe(3); + }).pipe(Effect.scoped, Effect.provide([BunServices.layer, BunCrypto.layer])); + }); + it.effect("connects without removing bans that are not blocking the caller", () => { const requests: Array<{ readonly method: string; readonly path: string }> = []; const connection = failingConnect(3); diff --git a/apps/cli/src/command-internal/db-config.layer.ts b/apps/cli/src/command-internal/db-config.layer.ts index d3722963cf..567d39893d 100644 --- a/apps/cli/src/command-internal/db-config.layer.ts +++ b/apps/cli/src/command-internal/db-config.layer.ts @@ -33,7 +33,11 @@ import { RuntimeInfo } from "../shared/runtime/runtime-info.service.ts"; import { Tty } from "../shared/runtime/tty.service.ts"; import { Analytics } from "../shared/telemetry/analytics.service.ts"; import { TelemetryRuntime } from "../shared/telemetry/runtime.service.ts"; -import { type ConnectSuggestionContext, SUGGEST_ENV_VAR } from "./connect-errors.ts"; +import { + type ConnectSuggestionContext, + SUGGEST_CA_VERIFICATION, + SUGGEST_ENV_VAR, +} from "./connect-errors.ts"; import { DbConnection, type PgConnInput } from "./db-connection.service.ts"; import { IdentityStitch } from "./identity-stitch.ts"; import { @@ -317,7 +321,10 @@ const NO_BANS: BanState = { listed: [], failure: Option.none() }; // Verify-connect with backoff while the pooler refreshes the temp password. From attempt 3 on, look // for network bans on the project: with approval they are removed and a failed removal is what the -// run reports if the retries run out; without it the last listing is reported instead. +// run reports if the retries run out; without it the last listing is reported instead. A rejected +// server certificate ends the loop at once: it cannot change between attempts, and bans do not +// cause it. Every other failure keeps retrying, dial failures included, because a ban surfaces as +// a dial failure and only the retry path lists bans. const waitForTempRole = Effect.fn("DbConfig.waitForTempRole")(function* ( ref: string, conn: PgConnInput, @@ -335,6 +342,14 @@ const waitForTempRole = Effect.fn("DbConfig.waitForTempRole")(function* ( Effect.scoped(dbConn.connect(conn, { isLocal: false, dnsResolver }).pipe(Effect.asVoid)).pipe( Effect.ensuring(Effect.annotateCurrentSpan("retry.attempt_count", n)), Effect.catch((cause) => { + if (cause.certificateRejected === true) { + return Effect.fail( + new Errors.DbConfigConnectTempRoleError({ + message: `failed to connect as temp role: ${cause.message}`, + suggestion: SUGGEST_CA_VERIFICATION, + }), + ); + } // 8 retries after the initial attempt allows 9 total attempts. `n` is 1-based, so give // up only after attempt 9 (`n > MAX_RETRIES`), not at attempt 8. if (n > MAX_RETRIES) { diff --git a/apps/cli/src/command-internal/db-connection.errors.ts b/apps/cli/src/command-internal/db-connection.errors.ts index fc4391d2ac..4751e8af15 100644 --- a/apps/cli/src/command-internal/db-connection.errors.ts +++ b/apps/cli/src/command-internal/db-connection.errors.ts @@ -17,6 +17,11 @@ export class DbConnectError extends Data.TaggedError("DbConnectError")<{ * fresh-db bootstrap's connect retry keys off this field. */ readonly retryable?: boolean; + /** + * True when the server certificate failed verification; the temp-role probe keys off this + * field to fail fast, since no retry can change the certificate. + */ + readonly certificateRejected?: boolean; /** * True when the failure is an IPv6 dial failure that an IPv4 pooler retry can recover; * `gen types`' pooler fallback keys off this field. diff --git a/apps/cli/src/command-internal/db-connection.sql-pg.layer.ts b/apps/cli/src/command-internal/db-connection.sql-pg.layer.ts index 7f761f07c5..924a88706e 100644 --- a/apps/cli/src/command-internal/db-connection.sql-pg.layer.ts +++ b/apps/cli/src/command-internal/db-connection.sql-pg.layer.ts @@ -10,6 +10,7 @@ import { to as pgCopyTo } from "pg-copy-streams"; import { connectFailureMessage, connectSuggestion, + isCertificateVerificationFailure, isDialFailure, isIPv6ConnectivityErrorCause, isSqlState, @@ -650,6 +651,7 @@ export const toConnectError = ( message: `failed to connect to postgres: ${connectFailureMessage(cfg, error)}`, ...(suggestion === undefined ? {} : { suggestion }), ...(isDialFailure(error) ? { retryable: true } : {}), + ...(isCertificateVerificationFailure(error) ? { certificateRejected: true } : {}), ...(isIPv6ConnectivityErrorCause(error) ? { ipv6Unreachable: true } : {}), }); }; diff --git a/apps/cli/src/command-internal/supabase-tls.ts b/apps/cli/src/command-internal/supabase-tls.ts index c2fa599262..424d09444f 100644 --- a/apps/cli/src/command-internal/supabase-tls.ts +++ b/apps/cli/src/command-internal/supabase-tls.ts @@ -30,9 +30,10 @@ export function pinSupabaseTls(conn: PgConnInput, profile: string): PgConnInput ) { return conn; } - const domain = getDomain(conn.host); + const host = conn.host.toLowerCase().replace(/\.$/, ""); + const domain = getDomain(host); const hosted = - isDirectDbHost(conn.host, projectHost(profile)) || + isDirectDbHost(host, projectHost(profile)) || (domain !== null && domain.toLowerCase() === poolerHost(profile)); return hosted ? { ...conn, sslmode: "require", sslrootcertInline: rootCaBundle() } : conn; } diff --git a/apps/cli/src/command-internal/supabase-tls.unit.test.ts b/apps/cli/src/command-internal/supabase-tls.unit.test.ts new file mode 100644 index 0000000000..2112b348c6 --- /dev/null +++ b/apps/cli/src/command-internal/supabase-tls.unit.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; + +import type { PgConnInput } from "./db-connection.service.ts"; +import { pinSupabaseTls, rootCaBundle } from "./supabase-tls.ts"; + +const conn = (host: string, overrides: Partial = {}): PgConnInput => ({ + host, + port: 5432, + user: "postgres", + password: "pw", + database: "postgres", + ...overrides, +}); + +describe("pinSupabaseTls host matching", () => { + it.each([ + "db.ref.supabase.co", + "DB.REF.SUPABASE.CO", + "db.ref.supabase.co.", + "aws-0-us-east-1.pooler.supabase.com", + "AWS-0-US-EAST-1.POOLER.SUPABASE.COM.", + ])("pins %s", (host) => { + expect(pinSupabaseTls(conn(host), "supabase")).toMatchObject({ + sslmode: "require", + sslrootcertInline: rootCaBundle(), + }); + }); + + it.each([ + "db.x.evilsupabase.co", + "db.ref.supabase.co.evil.com", + "supabase.co", + "localhost", + "127.0.0.1", + "::1", + "2600:1f18::1", + ])("leaves %s unpinned", (host) => { + const input = conn(host); + expect(pinSupabaseTls(input, "supabase")).toBe(input); + }); + + it("leaves a connection that chose its own TLS settings unchanged", () => { + const input = conn("db.ref.supabase.co", { sslmode: "disable" }); + expect(pinSupabaseTls(input, "supabase")).toBe(input); + }); +}); diff --git a/apps/cli/src/commands/db/push/SIDE_EFFECTS.md b/apps/cli/src/commands/db/push/SIDE_EFFECTS.md index 6d3439c46f..8a13bfa9aa 100644 --- a/apps/cli/src/commands/db/push/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/push/SIDE_EFFECTS.md @@ -154,8 +154,10 @@ project ref and its environment. pooler host of the `supabase`, `supabase-staging` or `snap` built-in profile, the connection uses `sslmode=require` with the bundled Supabase root CA, which the driver verifies as `verify-ca` (chain checked, hostname not). This covers the temp-role probe through the pooler and the - container pooler fallback. Any explicit setting, `disable` and `verify-full` with the user's own - root cert included, is passed through unchanged. `supabase-local` and YAML profiles are + container pooler fallback. A server certificate that fails verification ends the temp-role probe + at once, without its retries, and suggests setting `PGSSLROOTCERT` or `PGSSLMODE=require`. Any + explicit setting, `disable` and `verify-full` with the user's own root cert included, is passed + through unchanged. `supabase-local` and YAML profiles are never pinned. `sslmode=disable` dials plaintext and reads no CA or client-cert file; otherwise those PEM files are read when set and a missing one fails the connection, and a `pg_service.conf` is read diff --git a/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md b/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md index d87819f218..a49f755e2f 100644 --- a/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md @@ -201,11 +201,14 @@ Not applicable. is. The connection resolver applies the pin for a project ref, its direct host and pooler alike, including the pooler temp-role probe (see `db push`); this command applies it itself only to a preview branch. On a project ref the `PG*` env (see `db push`) is validated and - supplies the client-cert pair, and the 15s query timeout sets the connect timeout; a preview - branch's direct host takes no `PG*` setting. + supplies the client-cert pair, and the 15s query timeout sets the connect timeout. A preview + branch's direct host takes no `PG*` setting and is always pinned; its pooler fallback parses + the pooler URL with the shell `PG*` env, so `PGSSLMODE`, `PGSSLROOTCERT`, the client-cert pair + and `PGCONNECT_TIMEOUT` apply there, and a set `PGSSLMODE` or `PGSSLROOTCERT` replaces the pin. - `--remote=` honors the DSN's own `sslmode`/`sslrootcert` when either is set, even against a target classified local (e.g. a TLS tunnel on the loopback stack); - otherwise, a known Supabase host gets the Supabase CA pinned the same way, and any + otherwise, a known Supabase host on a `supabase`, `supabase-staging` or `snap` profile gets + the Supabase CA pinned the same way, and any other host — including a loopback target where the DSN sets neither — uses the connection resolver's default, which is plaintext for a local target. - The local target uses no TLS. diff --git a/apps/cli/tests/helpers/refuse-invalid-db-hosts.ts b/apps/cli/tests/helpers/refuse-invalid-db-hosts.ts index 2548eb8fc1..ee8e1465e0 100644 --- a/apps/cli/tests/helpers/refuse-invalid-db-hosts.ts +++ b/apps/cli/tests/helpers/refuse-invalid-db-hosts.ts @@ -11,8 +11,9 @@ const FIXTURE_DB_HOST_SUFFIXES = [".invalid", ".supabase.co", ".supabase.red", " /** * Wraps `node:net` so a connection to a `db.` fixture host on one of * `FIXTURE_DB_HOST_SUFFIXES` fails at once instead of waiting on ambient DNS or TCP timing. Use it - * from a test file's `vi.mock("node:net", ...)` factory; other hosts connect normally. While the toggle passed in is on, the same hosts report a - * successful connect instead, which fakes the TCP reachability probe only (nothing is dialed). + * from a test file's `vi.mock("node:net", ...)` factory; other hosts connect normally. While the + * toggle passed in is on, the same hosts report a successful connect instead, which fakes the TCP + * reachability probe only (nothing is dialed). */ export function refuseInvalidDbHosts( net: typeof NodeNet, From 4d13e2bf187d5db09e029f4defe76ba928db9259 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Thu, 5 Nov 2026 19:04:51 +0100 Subject: [PATCH 3/4] fix(cli): stop the temp-role retries when network restrictions reject the IP The temp-role probe retried for about 2.5 minutes when the pooler rejected the caller's IP (Address not in tenant allow_list), which no retry can fix. It now fails on the first attempt with the network-restrictions suggestion. --- .../src/command-internal/connect-errors.ts | 16 +++++- .../connect-errors.unit.test.ts | 15 +++++ .../db-config.integration.test.ts | 55 ++++++++++++++++++- .../src/command-internal/db-config.layer.ts | 16 +++++- .../command-internal/db-connection.errors.ts | 5 ++ .../db-connection.sql-pg.layer.ts | 2 + apps/cli/src/commands/db/push/SIDE_EFFECTS.md | 4 +- 7 files changed, 106 insertions(+), 7 deletions(-) diff --git a/apps/cli/src/command-internal/connect-errors.ts b/apps/cli/src/command-internal/connect-errors.ts index 8e5c6670c6..9e3324e94d 100644 --- a/apps/cli/src/command-internal/connect-errors.ts +++ b/apps/cli/src/command-internal/connect-errors.ts @@ -77,13 +77,16 @@ export function localStackSuggestion(linkedCheckout: boolean | undefined): strin : SUGGEST_LOCAL_STACK; } +export const SUGGEST_NETWORK_RESTRICTIONS = + "Make sure your local IP is allowed in Network Restrictions and Network Bans."; + /** * Remote-only "Network Restrictions" hint, shown for a connection refused or blocked by IP * allow-listing. Shared by both the always-remote `Address not in tenant allow_list` branch * and the non-local `ECONNREFUSED`/`connection refused` branch in `connectSuggestion`. */ function suggestNetworkRestrictions(dashboardUrl: string): string { - return `Make sure your local IP is allowed in Network Restrictions and Network Bans.\n${dashboardUrl}/project/_/database/settings`; + return `${SUGGEST_NETWORK_RESTRICTIONS}\n${dashboardUrl}/project/_/database/settings`; } /** Context the connect-suggestion needs but cannot derive from the error alone. */ @@ -229,6 +232,15 @@ const TLS_ERROR_CODES = new Set([ "HOSTNAME_MISMATCH", ]); +/** + * Whether the remote pooler rejected the caller's IP for project network restrictions. Only + * the allow-list changing can fix it, so it sets `DbConnectError.networkRestricted`, which the + * temp-role probe keys off to stop retrying. + */ +export function isNetworkRestrictionFailure(error: unknown): boolean { + return collectConnectErrorText(error).includes("Address not in tenant allow_list"); +} + /** * Whether a connect failure is an X509 certificate-verification rejection. It is deterministic: * the same server certificate fails the same way on every attempt. Sets @@ -371,7 +383,7 @@ export function connectSuggestion( const text = collectConnectErrorText(error); // "Address not in tenant allow_list" only ever comes from the remote pooler rejecting the // caller's IP, so it always means network restrictions. - if (text.includes("Address not in tenant allow_list")) { + if (isNetworkRestrictionFailure(error)) { return suggestNetworkRestrictions(ctx.dashboardUrl); } // Don't send the user to the dashboard's Network Restrictions page for a --local connection. diff --git a/apps/cli/src/command-internal/connect-errors.unit.test.ts b/apps/cli/src/command-internal/connect-errors.unit.test.ts index c9c67a032e..7e531158f2 100644 --- a/apps/cli/src/command-internal/connect-errors.unit.test.ts +++ b/apps/cli/src/command-internal/connect-errors.unit.test.ts @@ -10,6 +10,7 @@ import { connectSuggestion, ipv6Suggestion, isCertificateVerificationFailure, + isNetworkRestrictionFailure, isDialFailure, isIPv6ConnectivityError, isIPv6ConnectivityErrorCause, @@ -442,6 +443,20 @@ describe("isCertificateVerificationFailure", () => { }); }); +describe("isNetworkRestrictionFailure", () => { + it("matches the pooler's allow-list rejection but not other failures", () => { + expect( + isNetworkRestrictionFailure( + realSqlConnectError(new Error("Address not in tenant allow_list: 1.2.3.4")), + ), + ).toBe(true); + expect(isNetworkRestrictionFailure(authFailedError())).toBe(false); + expect(isNetworkRestrictionFailure(realSqlConnectError(new Error("some other failure")))).toBe( + false, + ); + }); +}); + describe("isDialFailure", () => { it("classifies dial errno failures", () => { expect(isDialFailure(realSqlConnectError(dialError("ECONNREFUSED", "127.0.0.1", 54322)))).toBe( diff --git a/apps/cli/src/command-internal/db-config.integration.test.ts b/apps/cli/src/command-internal/db-config.integration.test.ts index 101d200a5c..ddaaaf5a5a 100644 --- a/apps/cli/src/command-internal/db-config.integration.test.ts +++ b/apps/cli/src/command-internal/db-config.integration.test.ts @@ -85,7 +85,11 @@ import type { DbConnectionInput } from "./db-config.types.ts"; import { hostedMutationPreapproval } from "./hosted-consent/hosted-consent.ts"; import type { HostedMutationConsent } from "./hosted-consent/hosted-consent.token.ts"; import type { ResolvedTarget } from "./target/resolve-target.ts"; -import { SUGGEST_CA_VERIFICATION, SUGGEST_ENV_VAR } from "./connect-errors.ts"; +import { + SUGGEST_CA_VERIFICATION, + SUGGEST_ENV_VAR, + SUGGEST_NETWORK_RESTRICTIONS, +} from "./connect-errors.ts"; import { DbConnectError } from "./db-connection.errors.ts"; import { toConnectError } from "./db-connection.sql-pg.layer.ts"; import { DbConnection, type DbSession, type PgConnInput } from "./db-connection.service.ts"; @@ -2824,6 +2828,55 @@ describe("dbConfigResolver.resolveConnection", () => { }).pipe(Effect.scoped, Effect.provide([BunServices.layer, BunCrypto.layer])); }); + it.effect("fails at once on an IP rejected by network restrictions and lists no bans", () => { + const requests: Array<{ readonly method: string; readonly path: string }> = []; + const connection = failingConnect( + Number.POSITIVE_INFINITY, + () => + new DbConnectError({ + message: "failed to connect to postgres: Address not in tenant allow_list", + networkRestricted: true, + }), + ); + return Effect.gen(function* () { + yield* bannedRequests(requests, ["203.0.113.7"]); + const { fiber } = yield* forkResolve( + inputFor(linkedTarget(ref, { source: "flag", matchesLinkedRef: false })), + connection.layer, + ); + const exit = yield* Fiber.await(fiber); + const error = Exit.isFailure(exit) ? Cause.findErrorOption(exit.cause) : Option.none(); + expect(error).toEqual( + Option.some( + expect.objectContaining({ + _tag: "DbConfigConnectTempRoleError", + message: expect.stringContaining("Address not in tenant allow_list"), + suggestion: SUGGEST_NETWORK_RESTRICTIONS, + }), + ), + ); + expect(connection.attempts.count).toBe(1); + expect(requests.some((request) => request.path.includes("network-bans"))).toBe(false); + }).pipe(Effect.scoped, Effect.provide([BunServices.layer, BunCrypto.layer])); + }); + + it.effect("retries a plain authentication failure to the end and lists no ban", () => { + const requests: Array<{ readonly method: string; readonly path: string }> = []; + const connection = failingConnect(Number.POSITIVE_INFINITY); + return Effect.gen(function* () { + yield* bannedRequests(requests, []); + const { fiber, advance } = yield* forkResolve( + inputFor(linkedTarget(ref, { source: "flag", matchesLinkedRef: false })), + connection.layer, + ); + yield* advance(8); + const exit = yield* Fiber.await(fiber); + expect(failureText(exit)).toContain("DbConfigConnectTempRoleError"); + expect(connection.attempts.count).toBe(9); + expect(requests.some((request) => request.method === "DELETE")).toBe(false); + }).pipe(Effect.scoped, Effect.provide([BunServices.layer, BunCrypto.layer])); + }); + it.effect("connects without removing bans that are not blocking the caller", () => { const requests: Array<{ readonly method: string; readonly path: string }> = []; const connection = failingConnect(3); diff --git a/apps/cli/src/command-internal/db-config.layer.ts b/apps/cli/src/command-internal/db-config.layer.ts index 567d39893d..49026ced75 100644 --- a/apps/cli/src/command-internal/db-config.layer.ts +++ b/apps/cli/src/command-internal/db-config.layer.ts @@ -37,6 +37,7 @@ import { type ConnectSuggestionContext, SUGGEST_CA_VERIFICATION, SUGGEST_ENV_VAR, + SUGGEST_NETWORK_RESTRICTIONS, } from "./connect-errors.ts"; import { DbConnection, type PgConnInput } from "./db-connection.service.ts"; import { IdentityStitch } from "./identity-stitch.ts"; @@ -322,9 +323,10 @@ const NO_BANS: BanState = { listed: [], failure: Option.none() }; // Verify-connect with backoff while the pooler refreshes the temp password. From attempt 3 on, look // for network bans on the project: with approval they are removed and a failed removal is what the // run reports if the retries run out; without it the last listing is reported instead. A rejected -// server certificate ends the loop at once: it cannot change between attempts, and bans do not -// cause it. Every other failure keeps retrying, dial failures included, because a ban surfaces as -// a dial failure and only the retry path lists bans. +// server certificate or an IP rejected by the project's network restrictions ends the loop at +// once: neither changes between attempts, and bans do not cause them. Every other failure keeps +// retrying, dial failures included, because a ban surfaces as a dial failure and only the retry +// path lists bans. const waitForTempRole = Effect.fn("DbConfig.waitForTempRole")(function* ( ref: string, conn: PgConnInput, @@ -350,6 +352,14 @@ const waitForTempRole = Effect.fn("DbConfig.waitForTempRole")(function* ( }), ); } + if (cause.networkRestricted === true) { + return Effect.fail( + new Errors.DbConfigConnectTempRoleError({ + message: `failed to connect as temp role: ${cause.message}`, + suggestion: SUGGEST_NETWORK_RESTRICTIONS, + }), + ); + } // 8 retries after the initial attempt allows 9 total attempts. `n` is 1-based, so give // up only after attempt 9 (`n > MAX_RETRIES`), not at attempt 8. if (n > MAX_RETRIES) { diff --git a/apps/cli/src/command-internal/db-connection.errors.ts b/apps/cli/src/command-internal/db-connection.errors.ts index 4751e8af15..ca9b241fff 100644 --- a/apps/cli/src/command-internal/db-connection.errors.ts +++ b/apps/cli/src/command-internal/db-connection.errors.ts @@ -22,6 +22,11 @@ export class DbConnectError extends Data.TaggedError("DbConnectError")<{ * field to fail fast, since no retry can change the certificate. */ readonly certificateRejected?: boolean; + /** + * True when the pooler rejected the caller's IP for project network restrictions; the + * temp-role probe keys off this field to fail fast, since no retry can change the allow list. + */ + readonly networkRestricted?: boolean; /** * True when the failure is an IPv6 dial failure that an IPv4 pooler retry can recover; * `gen types`' pooler fallback keys off this field. diff --git a/apps/cli/src/command-internal/db-connection.sql-pg.layer.ts b/apps/cli/src/command-internal/db-connection.sql-pg.layer.ts index 924a88706e..8c503cb182 100644 --- a/apps/cli/src/command-internal/db-connection.sql-pg.layer.ts +++ b/apps/cli/src/command-internal/db-connection.sql-pg.layer.ts @@ -11,6 +11,7 @@ import { connectFailureMessage, connectSuggestion, isCertificateVerificationFailure, + isNetworkRestrictionFailure, isDialFailure, isIPv6ConnectivityErrorCause, isSqlState, @@ -652,6 +653,7 @@ export const toConnectError = ( ...(suggestion === undefined ? {} : { suggestion }), ...(isDialFailure(error) ? { retryable: true } : {}), ...(isCertificateVerificationFailure(error) ? { certificateRejected: true } : {}), + ...(isNetworkRestrictionFailure(error) ? { networkRestricted: true } : {}), ...(isIPv6ConnectivityErrorCause(error) ? { ipv6Unreachable: true } : {}), }); }; diff --git a/apps/cli/src/commands/db/push/SIDE_EFFECTS.md b/apps/cli/src/commands/db/push/SIDE_EFFECTS.md index 8a13bfa9aa..8861b408c3 100644 --- a/apps/cli/src/commands/db/push/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/push/SIDE_EFFECTS.md @@ -155,7 +155,9 @@ project ref and its environment. `sslmode=require` with the bundled Supabase root CA, which the driver verifies as `verify-ca` (chain checked, hostname not). This covers the temp-role probe through the pooler and the container pooler fallback. A server certificate that fails verification ends the temp-role probe - at once, without its retries, and suggests setting `PGSSLROOTCERT` or `PGSSLMODE=require`. Any + at once, without its retries, and suggests setting `PGSSLROOTCERT` or `PGSSLMODE=require`. So + does a pooler rejection of the caller's IP (`Address not in tenant allow_list`), which suggests + checking Network Restrictions and Network Bans. Any explicit setting, `disable` and `verify-full` with the user's own root cert included, is passed through unchanged. `supabase-local` and YAML profiles are never pinned. From b708f879ad8d3013a9ebfbf0d8465a1edb514cad Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Fri, 6 Nov 2026 09:37:18 +0100 Subject: [PATCH 4/4] fix(cli): verify a user root CA and pin the bootstrap fallback A root CA set without sslmode on a hosted host now keeps sslmode=require, so the driver verifies the chain against it instead of skipping verification. The bootstrap IPv6 fallback connection gets the pin too. The temp-role probe stops retrying on a hostname mismatch (ERR_TLS_CERT_ALTNAME_INVALID), no longer treats OUT_OF_MEM as a certificate rejection, and its network restrictions hint links the dashboard's database settings. --- .../src/command-internal/connect-errors.ts | 10 ++++++++-- .../connect-errors.unit.test.ts | 2 ++ .../db-config.integration.test.ts | 13 ++++++++----- .../src/command-internal/db-config.layer.ts | 14 +++++++++++--- apps/cli/src/command-internal/supabase-tls.ts | 19 ++++++++----------- .../supabase-tls.unit.test.ts | 11 ++++++++++- .../commands/bootstrap/bootstrap.handler.ts | 6 +++++- .../bootstrap/bootstrap.integration.test.ts | 5 +++++ apps/cli/src/commands/db/push/SIDE_EFFECTS.md | 12 ++++++------ .../src/commands/gen/types/SIDE_EFFECTS.md | 6 +++--- 10 files changed, 66 insertions(+), 32 deletions(-) diff --git a/apps/cli/src/command-internal/connect-errors.ts b/apps/cli/src/command-internal/connect-errors.ts index 8f3eb14c6d..69a6260429 100644 --- a/apps/cli/src/command-internal/connect-errors.ts +++ b/apps/cli/src/command-internal/connect-errors.ts @@ -85,7 +85,7 @@ export const SUGGEST_NETWORK_RESTRICTIONS = * allow-listing. Shared by both the always-remote `Address not in tenant allow_list` branch * and the non-local `ECONNREFUSED`/`connection refused` branch in `connectSuggestion`. */ -function suggestNetworkRestrictions(dashboardUrl: string): string { +export function suggestNetworkRestrictions(dashboardUrl: string): string { return `${SUGGEST_NETWORK_RESTRICTIONS}\n${dashboardUrl}/project/_/database/settings`; } @@ -232,6 +232,12 @@ const TLS_ERROR_CODES = new Set([ "HOSTNAME_MISMATCH", ]); +const CERT_VERIFICATION_CODES: ReadonlySet = new Set([ + ...[...TLS_ERROR_CODES].filter((code) => code !== "OUT_OF_MEM"), + // Node and Bun's `checkServerIdentity` failure, which `verify-full` raises on a hostname mismatch. + "ERR_TLS_CERT_ALTNAME_INVALID", +]); + /** * Whether the remote pooler rejected the caller's IP for project network restrictions. Only * the allow-list changing can fix it, so it sets `DbConnectError.networkRestricted`, which the @@ -248,7 +254,7 @@ export function isNetworkRestrictionFailure(error: unknown): boolean { */ export function isCertificateVerificationFailure(error: unknown): boolean { const cause = deepestConnectCause(error); - return hasStringCode(cause) && TLS_ERROR_CODES.has(cause.code); + return hasStringCode(cause) && CERT_VERIFICATION_CODES.has(cause.code); } // node-postgres' own message when the server answers `N` to SSLRequest // (`pg/lib/connection.js`); rendered as `tls error (server refused TLS connection)`. diff --git a/apps/cli/src/command-internal/connect-errors.unit.test.ts b/apps/cli/src/command-internal/connect-errors.unit.test.ts index accbd66ddf..33f6c0f953 100644 --- a/apps/cli/src/command-internal/connect-errors.unit.test.ts +++ b/apps/cli/src/command-internal/connect-errors.unit.test.ts @@ -438,6 +438,8 @@ describe("isCertificateVerificationFailure", () => { realSqlConnectError(Object.assign(new Error("failure"), { code })); expect(isCertificateVerificationFailure(withCode("SELF_SIGNED_CERT_IN_CHAIN"))).toBe(true); expect(isCertificateVerificationFailure(withCode("CERT_HAS_EXPIRED"))).toBe(true); + expect(isCertificateVerificationFailure(withCode("ERR_TLS_CERT_ALTNAME_INVALID"))).toBe(true); + expect(isCertificateVerificationFailure(withCode("OUT_OF_MEM"))).toBe(false); expect(isCertificateVerificationFailure(withCode("ECONNREFUSED"))).toBe(false); expect(isCertificateVerificationFailure(withCode("ECONNRESET"))).toBe(false); expect(isCertificateVerificationFailure(authFailedError())).toBe(false); diff --git a/apps/cli/src/command-internal/db-config.integration.test.ts b/apps/cli/src/command-internal/db-config.integration.test.ts index abaa7b6e99..a972f6e8f2 100644 --- a/apps/cli/src/command-internal/db-config.integration.test.ts +++ b/apps/cli/src/command-internal/db-config.integration.test.ts @@ -2112,7 +2112,11 @@ describe("resolveLinkedConn libpq TLS", () => { "supabase.com", "native", opts.password ?? Option.some("secret"), - { removeNetworkBans: false, poolerUrl: Option.fromNullishOr(opts.poolerUrl) }, + { + removeNetworkBans: false, + poolerUrl: Option.fromNullishOr(opts.poolerUrl), + dashboardUrl: "https://supabase.com/dashboard", + }, ); }), ).pipe(Effect.scoped); @@ -2249,14 +2253,13 @@ describe("resolveLinkedConn libpq TLS", () => { }).pipe(Effect.provide(linkedConnLayer)), ); - it.effect("keeps a PGSSLROOTCERT with no sslmode", () => + it.effect("verifies against a PGSSLROOTCERT with no sslmode instead of the bundled CA", () => Effect.gen(function* () { const conn = yield* linkedConn( { PGSSLROOTCERT: "/ca.pem" }, { projectHost: "supabase.co" }, ); - expect(conn).toMatchObject({ sslrootcert: "/ca.pem" }); - expect(conn.sslmode).toBeUndefined(); + expect(conn).toMatchObject({ sslmode: "require", sslrootcert: "/ca.pem" }); expect(conn.sslrootcertInline).toBeUndefined(); }).pipe(Effect.provide(linkedConnLayer)), ); @@ -2851,7 +2854,7 @@ describe("dbConfigResolver.resolveConnection", () => { expect.objectContaining({ _tag: "DbConfigConnectTempRoleError", message: expect.stringContaining("Address not in tenant allow_list"), - suggestion: SUGGEST_NETWORK_RESTRICTIONS, + suggestion: `${SUGGEST_NETWORK_RESTRICTIONS}\nhttps://supabase.com/dashboard/project/_/database/settings`, }), ), ); diff --git a/apps/cli/src/command-internal/db-config.layer.ts b/apps/cli/src/command-internal/db-config.layer.ts index 49026ced75..99f4ee6c1c 100644 --- a/apps/cli/src/command-internal/db-config.layer.ts +++ b/apps/cli/src/command-internal/db-config.layer.ts @@ -37,7 +37,7 @@ import { type ConnectSuggestionContext, SUGGEST_CA_VERIFICATION, SUGGEST_ENV_VAR, - SUGGEST_NETWORK_RESTRICTIONS, + suggestNetworkRestrictions, } from "./connect-errors.ts"; import { DbConnection, type PgConnInput } from "./db-connection.service.ts"; import { IdentityStitch } from "./identity-stitch.ts"; @@ -332,6 +332,7 @@ const waitForTempRole = Effect.fn("DbConfig.waitForTempRole")(function* ( conn: PgConnInput, dnsResolver: "native" | "https", removeNetworkBans: boolean, + dashboardUrl: string, ) { const dbConn = yield* DbConnection; const debug = yield* DebugLogger; @@ -356,7 +357,7 @@ const waitForTempRole = Effect.fn("DbConfig.waitForTempRole")(function* ( return Effect.fail( new Errors.DbConfigConnectTempRoleError({ message: `failed to connect as temp role: ${cause.message}`, - suggestion: SUGGEST_NETWORK_RESTRICTIONS, + suggestion: suggestNetworkRestrictions(dashboardUrl), }), ); } @@ -551,6 +552,7 @@ const resolvePoolerConn = Effect.fn("DbConfig.resolvePoolerConn")(function* ( ignoreSavedUrl = false, resolveVaultSecrets = true, removeNetworkBans: boolean, + dashboardUrl: string, ) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -585,7 +587,7 @@ const resolvePoolerConn = Effect.fn("DbConfig.resolvePoolerConn")(function* ( const withRole = yield* initLoginRole(ref, poolerConn); const finalUser = originalUser.endsWith(`.${ref}`) ? `${withRole.user}.${ref}` : withRole.user; const tempConn = { ...withRole, user: finalUser }; - yield* waitForTempRole(ref, tempConn, dnsResolver, removeNetworkBans); + yield* waitForTempRole(ref, tempConn, dnsResolver, removeNetworkBans, dashboardUrl); return Option.some(tempConn); }); @@ -613,6 +615,8 @@ export const resolveLinkedConn = Effect.fn("DbConfig.resolveLinkedConn")(functio options: { /** Whether the temp-role retry may clear listed bans; only a `--yes` approval sets it. */ readonly removeNetworkBans: boolean; + /** The active profile's dashboard, linked from the network restrictions hint. */ + readonly dashboardUrl: string; readonly adHocProjectRef?: boolean; readonly resolveVaultSecrets?: boolean; /** The pooler URL the link recorded for `ref`, used when the direct host is unreachable. */ @@ -633,6 +637,7 @@ export const resolveLinkedConn = Effect.fn("DbConfig.resolveLinkedConn")(functio fetchPoolerFromApi = false, hasPasswordFlag = false, removeNetworkBans, + dashboardUrl, poolerUrl = Option.none(), } = options; const dbPassword = yield* resolveLinkedPassword(ref, workdir, explicitPassword, hasPasswordFlag); @@ -690,6 +695,7 @@ export const resolveLinkedConn = Effect.fn("DbConfig.resolveLinkedConn")(functio adHocProjectRef, resolveVaultSecrets, removeNetworkBans, + dashboardUrl, ); if (Option.isNone(poolerConn)) { return yield* new Errors.DbConfigIpv6Error({ @@ -922,6 +928,7 @@ export const dbConfigResolverLayer = Layer.effect( hasPasswordFlag: options.hasPasswordFlag, removeNetworkBans: options.removeNetworkBans, poolerUrl: options.poolerUrl, + dashboardUrl: cliSettings.dashboardUrl, }, ); }); @@ -1020,6 +1027,7 @@ export const dbConfigResolverLayer = Layer.effect( !target.matchesLinkedRef, input.resolveVaultSecrets ?? true, canRemoveNetworkBans(input), + cliSettings.dashboardUrl, ); }).pipe( Effect.provide( diff --git a/apps/cli/src/command-internal/supabase-tls.ts b/apps/cli/src/command-internal/supabase-tls.ts index 424d09444f..138bdd995d 100644 --- a/apps/cli/src/command-internal/supabase-tls.ts +++ b/apps/cli/src/command-internal/supabase-tls.ts @@ -17,23 +17,20 @@ const PINNED_PROFILES: ReadonlySet = new Set(["supabase", "supabase-stag /** * Pins the bundled Supabase CA (`require` plus an inline root CA, which the driver promotes to * `verify-ca`) on a connection to a hosted Supabase database of the active built-in profile. A - * connection that sets `sslmode` or `sslrootcert` from any source, a host outside the profile's - * project and pooler domains, and every other profile (`supabase-local`, YAML) are + * root CA set without `sslmode` replaces the bundle but keeps `require`, since the unset default + * (`prefer`) would ignore it. A connection that sets `sslmode` from any source, a host outside + * the profile's project and pooler domains, and every other profile (`supabase-local`, YAML) are * returned unchanged. */ export function pinSupabaseTls(conn: PgConnInput, profile: string): PgConnInput { - if ( - !PINNED_PROFILES.has(profile) || - conn.sslmode !== undefined || - conn.sslrootcert !== undefined || - conn.sslrootcertInline !== undefined - ) { - return conn; - } + if (!PINNED_PROFILES.has(profile) || conn.sslmode !== undefined) return conn; const host = conn.host.toLowerCase().replace(/\.$/, ""); const domain = getDomain(host); const hosted = isDirectDbHost(host, projectHost(profile)) || (domain !== null && domain.toLowerCase() === poolerHost(profile)); - return hosted ? { ...conn, sslmode: "require", sslrootcertInline: rootCaBundle() } : conn; + if (!hosted) return conn; + return conn.sslrootcert !== undefined || conn.sslrootcertInline !== undefined + ? { ...conn, sslmode: "require" } + : { ...conn, sslmode: "require", sslrootcertInline: rootCaBundle() }; } diff --git a/apps/cli/src/command-internal/supabase-tls.unit.test.ts b/apps/cli/src/command-internal/supabase-tls.unit.test.ts index 2112b348c6..c3bec69a6e 100644 --- a/apps/cli/src/command-internal/supabase-tls.unit.test.ts +++ b/apps/cli/src/command-internal/supabase-tls.unit.test.ts @@ -39,8 +39,17 @@ describe("pinSupabaseTls host matching", () => { expect(pinSupabaseTls(input, "supabase")).toBe(input); }); - it("leaves a connection that chose its own TLS settings unchanged", () => { + it("leaves a connection that chose its own sslmode unchanged", () => { const input = conn("db.ref.supabase.co", { sslmode: "disable" }); expect(pinSupabaseTls(input, "supabase")).toBe(input); }); + + it("requires TLS verified against a root CA set without sslmode", () => { + const pinned = pinSupabaseTls( + conn("db.ref.supabase.co", { sslrootcert: "/ca.pem" }), + "supabase", + ); + expect(pinned).toMatchObject({ sslmode: "require", sslrootcert: "/ca.pem" }); + expect(pinned.sslrootcertInline).toBeUndefined(); + }); }); diff --git a/apps/cli/src/commands/bootstrap/bootstrap.handler.ts b/apps/cli/src/commands/bootstrap/bootstrap.handler.ts index b773aa07f9..32b2f96d0e 100644 --- a/apps/cli/src/commands/bootstrap/bootstrap.handler.ts +++ b/apps/cli/src/commands/bootstrap/bootstrap.handler.ts @@ -25,6 +25,7 @@ import { getProjectApiKeys } from "../../command-internal/get-api-keys.ts"; import { sanitizeErrorBody } from "../../command-internal/http-errors.ts"; import type { ConnectSuggestionContext } from "../../command-internal/connect-errors.ts"; import { resolveLinkedConn } from "../../command-internal/db-config.layer.ts"; +import { pinSupabaseTls } from "../../command-internal/supabase-tls.ts"; import { checkDbConfig } from "../../command-internal/db-config.read.ts"; import { dbPushCore } from "../../command-internal/db-push-core.ts"; import { @@ -380,10 +381,13 @@ export const bootstrap = Effect.fn("bootstrap")(function* ( { removeNetworkBans: Option.isSome(yield* hostedMutationPreapproval), poolerUrl: linkSnapshot.poolerUrl, + dashboardUrl: cliSettings.dashboardUrl, }, ).pipe( Effect.catchTag("DbConfigIpv6Error", (error) => - output.raw(`${error.message}\n`, "stderr").pipe(Effect.as(dbConfig)), + output + .raw(`${error.message}\n`, "stderr") + .pipe(Effect.as(pinSupabaseTls(dbConfig, cliSettings.profile))), ), ); const conn = { ...resolvedConn, suggestionContext }; diff --git a/apps/cli/src/commands/bootstrap/bootstrap.integration.test.ts b/apps/cli/src/commands/bootstrap/bootstrap.integration.test.ts index 31c193bd24..c48366681d 100644 --- a/apps/cli/src/commands/bootstrap/bootstrap.integration.test.ts +++ b/apps/cli/src/commands/bootstrap/bootstrap.integration.test.ts @@ -58,6 +58,7 @@ import { import { LinkedProjectCache } from "../../telemetry/linked-project-cache.service.ts"; import { DbConnectError } from "../../command-internal/db-connection.errors.ts"; import { DbConnection, type PgConnInput } from "../../command-internal/db-connection.service.ts"; +import { rootCaBundle } from "../../command-internal/supabase-tls.ts"; import { debugLoggerLayer } from "../../command-internal/debug-logger.layer.ts"; import { TargetPolicy } from "../../command-internal/target/target-policy.ts"; import { TemplateService, type StarterTemplate } from "./bootstrap.templates.ts"; @@ -768,6 +769,10 @@ describe("bootstrap integration", () => { expect(s.pushConnectCalls[0]?.user).toBe("postgres"); expect(s.pushConnectCalls[0]?.database).toBe("postgres"); expect(s.pushConnectCalls[0]?.password).toBe("s3cret"); + expect(s.pushConnectCalls[0]).toMatchObject({ + sslmode: "require", + sslrootcertInline: rootCaBundle(), + }); expect(s.out.stdoutText).toContain("Remote database is up to date."); }).pipe(Effect.provide(BunServices.layer)), ); diff --git a/apps/cli/src/commands/db/push/SIDE_EFFECTS.md b/apps/cli/src/commands/db/push/SIDE_EFFECTS.md index 8861b408c3..01b28f9cfd 100644 --- a/apps/cli/src/commands/db/push/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/push/SIDE_EFFECTS.md @@ -149,17 +149,17 @@ project ref and its environment. An invalid mode or `PGCONNECT_TIMEOUT`, an unknown `PGSERVICE`, or a lone `PGSSLCERT`/`PGSSLKEY` outside `sslmode=disable` fails with `invalid libpq environment` on either route. **Supabase CA pin (every remote-project connection, shared by all commands that resolve a linked - database):** when neither `sslmode` nor `sslrootcert` is set (query, `pg_service.conf`, - `PGSSLMODE` or `PGSSLROOTCERT`) and the host is a `db..` direct host or a + database):** when `sslmode` is not set (query, `pg_service.conf` or `PGSSLMODE`) and the host + is a `db..` direct host or a pooler host of the `supabase`, `supabase-staging` or `snap` built-in profile, the connection uses `sslmode=require` with the bundled Supabase root CA, which the driver verifies as `verify-ca` - (chain checked, hostname not). This covers the temp-role probe through the pooler and the + (chain checked, hostname not). A root cert set without `sslmode` (`PGSSLROOTCERT`, query or + service) replaces the bundled CA but keeps `sslmode=require`, so it is verified the same way. This covers the temp-role probe through the pooler and the container pooler fallback. A server certificate that fails verification ends the temp-role probe at once, without its retries, and suggests setting `PGSSLROOTCERT` or `PGSSLMODE=require`. So does a pooler rejection of the caller's IP (`Address not in tenant allow_list`), which suggests - checking Network Restrictions and Network Bans. Any - explicit setting, `disable` and `verify-full` with the user's own root cert included, is passed - through unchanged. `supabase-local` and YAML profiles are + checking Network Restrictions and Network Bans and links the dashboard's database settings. Any + explicit `sslmode`, `disable` and `verify-full` included, is passed through unchanged. `supabase-local` and YAML profiles are never pinned. `sslmode=disable` dials plaintext and reads no CA or client-cert file; otherwise those PEM files are read when set and a missing one fails the connection, and a `pg_service.conf` is read diff --git a/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md b/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md index 15d7a27d7a..fc1a9ab960 100644 --- a/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md @@ -28,7 +28,7 @@ instead of inspecting `supabase_db_*`. | `~/.pg_service.conf` (or a `PGSERVICEFILE`/DSN `servicefile=` override) | libpq service file | `--remote=`, only when the DSN or `PGSERVICE` names a `service` — its settings fill in for connection fields the DSN itself omits | | `Package.resolved` (nearest) | JSON | `--lang swift` without `SUPABASE_TYPEGEN`, on macOS or Linux x86_64: the first one found walking up from the working directory, including the copies inside an `.xcodeproj` or `.xcworkspace`; the supabase-swift version it pins picks the `supabase-typegen` release | | `/supabase/.temp/rest-version` | plain text | the local target only, when `db.major_version > 14` — forces v9 compat if the tag contains `v9` | -| `$PGSSLROOTCERT`, `$PGSSLCERT` / `$PGSSLKEY`, `pg_service.conf` | PEM, libpq | non-TypeScript `--lang` on a remote project, when `PGSSLCERT`/`PGSSLKEY` or `PGSERVICE` are set; `$PGSSLROOTCERT` also replaces the Supabase CA pin; the PEM files are skipped under `PGSSLMODE=disable` | +| `$PGSSLROOTCERT`, `$PGSSLCERT` / `$PGSSLKEY`, `pg_service.conf` | PEM, libpq | non-TypeScript `--lang` on a remote project, when `PGSSLCERT`/`PGSSLKEY` or `PGSERVICE` are set; `$PGSSLROOTCERT` also replaces the pinned Supabase CA; the PEM files are skipped under `PGSSLMODE=disable` | ## Files Written @@ -197,8 +197,8 @@ Not applicable. - **TLS.** There is no SSL probe on any path. - A remote project (including a resolved preview branch) on a `supabase`, `supabase-staging` or `snap` profile host connects with `sslmode=require` and the bundled Supabase CA pinned (promoted to - `verify-ca`) unless `sslmode` or `sslrootcert` is set from any source, which is then used as - is. The connection resolver applies the pin for a project ref, its direct host and pooler + `verify-ca`) unless `sslmode` is set from any source, which is then used as is. A root cert + set without `sslmode` replaces the bundled CA and keeps `require`. The connection resolver applies the pin for a project ref, its direct host and pooler alike, including the pooler temp-role probe (see `db push`); this command applies it itself only to a preview branch. On a project ref the `PG*` env (see `db push`) is validated and supplies the client-cert pair, and the 15s query timeout sets the connect timeout. A preview