From af559ca455f7dac1ce55342196f1b24c0b30c3f7 Mon Sep 17 00:00:00 2001 From: camilafagundes Date: Fri, 23 Oct 2026 19:42:46 -0300 Subject: [PATCH] fix(db): trust verified Supabase root CA for deployed TLS Co-authored-by: Cursor --- src/infra/db/connection-policy.ts | 14 ++++-- src/infra/db/supabase-root-ca.ts | 28 ++++++++++++ tests/unit/database-guard.test.ts | 71 +++++++++++++++++++++++++++++-- 3 files changed, 105 insertions(+), 8 deletions(-) create mode 100644 src/infra/db/supabase-root-ca.ts diff --git a/src/infra/db/connection-policy.ts b/src/infra/db/connection-policy.ts index b9267e7..4786229 100644 --- a/src/infra/db/connection-policy.ts +++ b/src/infra/db/connection-policy.ts @@ -1,5 +1,6 @@ import type { EnvMap } from "@/config/env"; import { isDeployedEnv } from "@/config/env"; +import { SUPABASE_ROOT_CA_2021 } from "./supabase-root-ca"; /** Projeto autorizado a receber migrations. Não é segredo. */ export const AUTHORIZED_HML_PROJECT = "adaptador-planilha-hml"; @@ -169,7 +170,7 @@ export type PostgresClientOptions = { connect_timeout: number; idle_timeout: number; max_lifetime: number; - ssl?: { rejectUnauthorized: true }; + ssl?: { rejectUnauthorized: true; ca: string }; }; /** @@ -177,8 +178,13 @@ export type PostgresClientOptions = { * * `connection` no postgres.js é um parâmetro de startup, não uma opção do * socket. Enviar `family=4` faz o PostgreSQL recusar a sessão com 42704 - * (`unrecognized configuration parameter "family"`) antes da primeira - * consulta. O IPv4 continua garantido pelo host do Session Pooler. + * antes da primeira consulta. O IPv4 continua no host do Session Pooler. + * + * O pooler apresenta uma cadeia emitida pela raiz pública Supabase Root 2021 + * CA. Essa raiz não está na confiança padrão do Node, e por isso a verificação + * falha com SELF_SIGNED_CERT_IN_CHAIN. `ca` fixa somente essa raiz neste + * socket. `rejectUnauthorized` permanece verdadeiro, e o driver continua + * conferindo o hostname. A confiança padrão dos outros processos não muda. */ export function buildPostgresOptions(connectionString: string, purpose: DbPurpose, env: EnvMap = process.env): PostgresClientOptions { const deployed = isDeployedEnv(env); @@ -191,7 +197,7 @@ export function buildPostgresOptions(connectionString: string, purpose: DbPurpos idle_timeout: 20, max_lifetime: 60 * 30, }; - if (deployed) options.ssl = { rejectUnauthorized: true }; + if (deployed) options.ssl = { rejectUnauthorized: true, ca: SUPABASE_ROOT_CA_2021 }; return options; } diff --git a/src/infra/db/supabase-root-ca.ts b/src/infra/db/supabase-root-ca.ts new file mode 100644 index 0000000..cd7380a --- /dev/null +++ b/src/infra/db/supabase-root-ca.ts @@ -0,0 +1,28 @@ +/** + * CA raiz pública do Supabase, publicada como prod-ca-2021. + * Não contém chave privada. A impressão SHA-256 é conferida nos testes. + */ +export const SUPABASE_ROOT_CA_2021 = `-----BEGIN CERTIFICATE----- +MIIDxDCCAqygAwIBAgIUbLxMod62P2ktCiAkxnKJwtE9VPYwDQYJKoZIhvcNAQEL +BQAwazELMAkGA1UEBhMCVVMxEDAOBgNVBAgMB0RlbHdhcmUxEzARBgNVBAcMCk5l +dyBDYXN0bGUxFTATBgNVBAoMDFN1cGFiYXNlIEluYzEeMBwGA1UEAwwVU3VwYWJh +c2UgUm9vdCAyMDIxIENBMB4XDTIxMDQyODEwNTY1M1oXDTMxMDQyNjEwNTY1M1ow +azELMAkGA1UEBhMCVVMxEDAOBgNVBAgMB0RlbHdhcmUxEzARBgNVBAcMCk5ldyBD +YXN0bGUxFTATBgNVBAoMDFN1cGFiYXNlIEluYzEeMBwGA1UEAwwVU3VwYWJhc2Ug +Um9vdCAyMDIxIENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqQXW +QyHOB+qR2GJobCq/CBmQ40G0oDmCC3mzVnn8sv4XNeWtE5XcEL0uVih7Jo4Dkx1Q +DmGHBH1zDfgs2qXiLb6xpw/CKQPypZW1JssOTMIfQppNQ87K75Ya0p25Y3ePS2t2 +GtvHxNjUV6kjOZjEn2yWEcBdpOVCUYBVFBNMB4YBHkNRDa/+S4uywAoaTWnCJLUi +cvTlHmMw6xSQQn1UfRQHk50DMCEJ7Cy1RxrZJrkXXRP3LqQL2ijJ6F4yMfh+Gyb4 +O4XajoVj/+R4GwywKYrrS8PrSNtwxr5StlQO8zIQUSMiq26wM8mgELFlS/32Uclt +NaQ1xBRizkzpZct9DwIDAQABo2AwXjALBgNVHQ8EBAMCAQYwHQYDVR0OBBYEFKjX +uXY32CztkhImng4yJNUtaUYsMB8GA1UdIwQYMBaAFKjXuXY32CztkhImng4yJNUt +aUYsMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAB8spzNn+4VU +tVxbdMaX+39Z50sc7uATmus16jmmHjhIHz+l/9GlJ5KqAMOx26mPZgfzG7oneL2b +VW+WgYUkTT3XEPFWnTp2RJwQao8/tYPXWEJDc0WVQHrpmnWOFKU/d3MqBgBm5y+6 +jB81TU/RG2rVerPDWP+1MMcNNy0491CTL5XQZ7JfDJJ9CCmXSdtTl4uUQnSuv/Qx +Cea13BX2ZgJc7Au30vihLhub52De4P/4gonKsNHYdbWjg7OWKwNv/zitGDVDB9Y2 +CMTyZKG3XEu5Ghl1LEnI3QmEKsqaCLv12BnVjbkSeZsMnevJPs1Ye6TjjJwdik5P +o/bKiIz+Fq8= +-----END CERTIFICATE----- +`; diff --git a/tests/unit/database-guard.test.ts b/tests/unit/database-guard.test.ts index 710fe8d..ecd5ae8 100644 --- a/tests/unit/database-guard.test.ts +++ b/tests/unit/database-guard.test.ts @@ -1,3 +1,8 @@ +import { X509Certificate } from "node:crypto"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import tls from "node:tls"; import { describe, expect, it } from "vitest"; import { AUTHORIZED_HML_PROJECT, @@ -13,6 +18,7 @@ import { import { readFileSync, readdirSync } from "node:fs"; import path from "node:path"; import { assertInitAuthorized, projectIdentityDefinitionSql, projectIdentityRevertSql } from "@/infra/db/project-identity"; +import { SUPABASE_ROOT_CA_2021 } from "@/infra/db/supabase-root-ca"; import { assertAdminTarget, evaluateAdminIdentity, evaluateProjectIdentity, evaluateRuntimeRole, type AdminAttestation } from "@/infra/db/target-guard"; import type { Db } from "@/infra/db/types"; @@ -167,16 +173,73 @@ describe("barreira do banco de homologação", () => { expect(resolvePoolMax("web", env)).toBe(2); expect(resolvePoolMax("worker", env)).toBe(2); expect(resolvePoolMax("admin", env)).toBe(1); + const root = new X509Certificate(SUPABASE_ROOT_CA_2021); + expect(root.ca).toBe(true); + expect(root.fingerprint256).toBe("80:70:25:AD:50:D4:ED:21:9D:2C:9C:7D:29:9C:00:4F:82:4E:B0:0C:F7:F6:5A:FE:F6:07:D0:7B:72:E6:CA:FA"); + expect(root.subject).toContain("CN=Supabase Root 2021 CA"); + expect(SUPABASE_ROOT_CA_2021).not.toContain("PRIVATE KEY"); const deployed = buildPostgresOptions(adminUrl(), "admin", env); const production = buildPostgresOptions(adminUrl(), "admin", { ...env, ADAPTER_ENV: "production" }); for (const options of [deployed, production]) { - expect(options.ssl).toEqual({ rejectUnauthorized: true }); + expect(options.ssl).toEqual({ rejectUnauthorized: true, ca: SUPABASE_ROOT_CA_2021 }); expect(options).not.toHaveProperty("connection"); expect(JSON.stringify(options)).not.toContain("family"); + expect(JSON.stringify(options)).not.toContain("rejectUnauthorized\":false"); + } + const local = buildPostgresOptions("postgres://adaptador:local@127.0.0.1:5432/adaptador", "local", { + ADAPTER_ENV: "development", + }); + expect(local).not.toHaveProperty("connection"); + expect(local.ssl).toBeUndefined(); + }); + + it("a CA pública não libera outro destino nem aceita certificado divergente", async () => { + expect(() => + buildPostgresOptions(adminUrl(`postgres.${REF}`, "verify-full", "aws-0-us-west-2.pooler.supabase.com"), "admin", env), + ).toThrow(/destino_incompativel/); + expect(() => assertInitAuthorized({ ADAPTER_ENV: "staging", ADAPTER_DB_PROJECT_REF: REF })).toThrow(/init_nao_autorizada/); + const options = buildPostgresOptions(adminUrl(), "admin", env); + expect(options.ssl).not.toHaveProperty("checkServerIdentity"); + expect(options.ssl).not.toHaveProperty("servername"); + expect(options.ssl?.rejectUnauthorized).toBe(true); + + const dir = mkdtempSync(path.join(tmpdir(), "ca-divergente-")); + const keyPath = path.join(dir, "key.pem"); + const certPath = path.join(dir, "cert.pem"); + try { + execFileSync( + "openssl", + ["req", "-x509", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:prime256v1", "-keyout", keyPath, "-out", certPath, "-days", "1", "-nodes", "-subj", "/CN=divergente.example"], + { stdio: "ignore" }, + ); + const server = tls.createServer({ key: readFileSync(keyPath), cert: readFileSync(certPath) }, (socket) => socket.end()); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("porta_local_ausente"); + try { + await new Promise((resolve, reject) => { + const socket = tls.connect({ + host: "127.0.0.1", + port: address.port, + servername: "divergente.example", + rejectUnauthorized: true, + ca: SUPABASE_ROOT_CA_2021, + }); + socket.once("secureConnect", () => { + socket.end(); + reject(new Error("certificado_divergente_aceito")); + }); + socket.once("error", (error: NodeJS.ErrnoException) => { + expect(error.code).toMatch(/SELF_SIGNED_CERT_IN_CHAIN|DEPTH_ZERO_SELF_SIGNED_CERT|UNABLE_TO_VERIFY_LEAF_SIGNATURE/); + resolve(); + }); + }); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + } finally { + rmSync(dir, { recursive: true, force: true }); } - expect(buildPostgresOptions("postgres://adaptador:local@127.0.0.1:5432/adaptador", "local", { ADAPTER_ENV: "development" })).not.toHaveProperty( - "connection", - ); }); it("recusa a credencial administrativa na web e BYPASSRLS no worker", () => {