fix(db): trust verified Supabase root CA for deployed TLS
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
60f15a3576
commit
af559ca455
3 files changed
+104
-7
No files matched your search
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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-----
|
||||
`;
|
||||
@@ -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");
|
||||
}
|
||||
expect(buildPostgresOptions("postgres://adaptador:local@127.0.0.1:5432/adaptador", "local", { ADAPTER_ENV: "development" })).not.toHaveProperty(
|
||||
"connection",
|
||||
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<void>((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<void>((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<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("recusa a credencial administrativa na web e BYPASSRLS no worker", () => {
|
||||
|
||||
Reference in new issue
Block a user