Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion apps/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
"test:e2e:run": "bun --bun vitest run --project e2e",
"test:live": "bun --bun vitest run --project live",
"test:smoke": "bun run tests/smoke-test.ts",
"types:check": "tsc --noEmit"
"types:check": "tsc --noEmit -p tsconfig.types.json"
},
"dependencies": {
"eciesjs": "^0.5.0",
Expand All @@ -55,10 +55,19 @@
"@effect/vitest": "catalog:",
"@modelcontextprotocol/sdk": "^1.30.0",
"@napi-rs/keyring": "^1.3.0",
"@oxfmt/binding-darwin-arm64": "0.66.0",
"@oxfmt/binding-darwin-x64": "0.66.0",
"@oxfmt/binding-linux-arm64-gnu": "0.66.0",
"@oxfmt/binding-linux-arm64-musl": "0.66.0",
"@oxfmt/binding-linux-x64-gnu": "0.66.0",
"@oxfmt/binding-linux-x64-musl": "0.66.0",
"@oxfmt/binding-win32-arm64-msvc": "0.66.0",
"@oxfmt/binding-win32-x64-msvc": "0.66.0",
"@supabase/api": "workspace:*",
"@supabase/config": "workspace:*",
"@supabase/pg-delta": "1.0.0-alpha.52",
"@supabase/pg-topo": "1.0.0-alpha.6",
"@supabase/postgrest-typegen": "0.2.2",
"@supabase/stack": "workspace:*",
"@supabase/supabase-js": "catalog:",
"@tsconfig/bun": "catalog:",
Expand Down
2 changes: 2 additions & 0 deletions apps/cli/scripts/build-binary.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { bundleServeMainTemplate } from "../src/shared/functions/serve-main-bundler.ts";
import { OXFMT_OPTIONAL_PLUGIN_EXTERNALS } from "./bundle-externals.ts";

/**
* Compiles the CLI to a standalone binary, run via `pnpm build:binary`. Embeds the pre-bundled
Expand All @@ -18,6 +19,7 @@ if (packageJson.version === undefined || packageJson.version.length === 0) {
const result = await Bun.build({
entrypoints: [entrypoint],
compile: { outfile },
external: [...OXFMT_OPTIONAL_PLUGIN_EXTERNALS],
define: {
SUPABASE_CLI_VERSION: JSON.stringify(packageJson.version),
SUPABASE_FUNCTIONS_SERVE_MAIN_TEMPLATE: JSON.stringify(await bundleServeMainTemplate()),
Expand Down
6 changes: 5 additions & 1 deletion apps/cli/scripts/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import path from "node:path";
import process from "node:process";
import { parseArgs } from "node:util";
import { bundleServeMainTemplate } from "../src/shared/functions/serve-main-bundler.ts";
import { OXFMT_OPTIONAL_PLUGIN_EXTERNALS } from "./bundle-externals.ts";
import { darwinBinaries, MACOS_IDENTIFIERS } from "./macos-signing.ts";

const MUSL_TARGETS = [
Expand Down Expand Up @@ -113,7 +114,10 @@ function libcForBunTarget(target: string): "glibc" | "musl" | "" {
}

async function runBunBuild(config: Bun.BuildConfig) {
const result = await Bun.build(config);
const result = await Bun.build({
...config,
external: [...(config.external ?? []), ...OXFMT_OPTIONAL_PLUGIN_EXTERNALS],
});
for (const log of result.logs) {
console.warn(log);
}
Expand Down
24 changes: 24 additions & 0 deletions apps/cli/scripts/bundle-externals.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/**
* Optional prettier plugins that `oxfmt`'s dist lazily `import()`s for non-TypeScript file
* types. They are never installed — `gen types` only formats generated TypeScript, through the
* statically embedded binding in `src/commands/gen/types/types.oxfmt.ts` — but `bun build`
* still resolves every analyzable dynamic import, so each must be marked external.
*/
export const OXFMT_OPTIONAL_PLUGIN_EXTERNALS = [
Comment thread
Coly010 marked this conversation as resolved.
"@prettier/plugin-hermes",
"@prettier/plugin-oxc",
"@prettier/plugin-pug",
"@shopify/prettier-plugin-liquid",
"@zackad/prettier-plugin-twig",
"prettier-plugin-astro",
"prettier-plugin-marko",
] as const;

/**
* {@link OXFMT_OPTIONAL_PLUGIN_EXTERNALS} as `--external=<name>` CLI arguments, for `bun build`
* invocations that shell out (e.g. `tools/release/local-release.ts`) rather than calling the
* `Bun.build()` object API.
*/
export const oxfmtExternalArgs = OXFMT_OPTIONAL_PLUGIN_EXTERNALS.map(
(name) => `--external=${name}`,
);
5 changes: 5 additions & 0 deletions apps/cli/src/command-internal/connect-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,11 @@ export function connectSuggestion(
) {
return SUGGEST_ENV_VAR;
}
// An unset `sslmode` negotiates TLS and fails rather than downgrading, so a server without
// TLS needs the caller to opt into plaintext explicitly.
if (text.includes(SERVER_REFUSED_SSL) || text.includes("server refused TLS connection")) {
return "This server does not accept TLS. Set `sslmode=disable` on the connection string to connect in plaintext.";
}
// Node system errors carry the dialed address as a structured field instead of libpq's
// parenthesized literal, so also consult the errno + `address` classifier.
if (isIPv6ConnectivityError(text) || hasIPv6DialCause(error)) {
Expand Down
5 changes: 5 additions & 0 deletions apps/cli/src/command-internal/db-connection.errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 failure is an IPv6 dial failure that an IPv4 pooler retry can recover;
* `gen types`' pooler fallback keys off this field.
*/
readonly ipv6Unreachable?: boolean;
}> {
get [ErrorActionabilityId](): CliErrorActionabilityDeclaration {
return actionability.dbConnection;
Expand Down
2 changes: 2 additions & 0 deletions apps/cli/src/command-internal/db-connection.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ export interface PgConnInput {
* `verify-ca`. Absent → system roots / no CA pinning.
*/
readonly sslrootcert?: string;
/** Inline PEM CA bundle; takes precedence over {@link sslrootcert} when both are set. */
readonly sslrootcertInline?: string;
/**
* libpq client-certificate auth, from the DSN or `PGSSLCERT`/`PGSSLKEY`/`PGSSLPASSWORD`.
* `sslcert`/`sslkey` are file paths loaded into the client cert; `sslpassword` decrypts an
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,61 @@ const BIND_COMPLETE = wireMessage("2", Buffer.alloc(0));
const NO_DATA = wireMessage("n", Buffer.alloc(0));
const EMPTY_QUERY = wireMessage("I", Buffer.alloc(0));

/**
* A fake Postgres server that completes an auth-less startup handshake, answers every
* simple-protocol query with `SELECT 1`'s result (satisfying `acquireProbedPool`'s own probe),
* and records whether the client sent an SSLRequest first — so a test can prove whether TLS was
* attempted independent of how the attempt is resolved.
*/
const fakeStartupServer = (): Promise<{
readonly port: number;
readonly close: () => void;
readonly sawSslRequest: () => boolean;
}> =>
new Promise((resolve) => {
let sawSslRequest = false;
const server = net.createServer((socket) => {
let sawStartup = false;
let pending = Buffer.alloc(0);
socket.on("data", (data: Buffer) => {
pending = Buffer.concat([pending, data]);
for (;;) {
if (!sawStartup) {
if (pending.length < 8) return;
const length = pending.readInt32BE(0);
if (pending.length < length) return;
if (pending.readInt32BE(4) === 80877103) {
sawSslRequest = true;
socket.write("N");
} else {
sawStartup = true;
socket.write(Buffer.concat([AUTHENTICATION_OK, READY_FOR_QUERY]));
}
pending = pending.subarray(length);
continue;
}
if (pending.length < 5) return;
const length = pending.readInt32BE(1);
if (pending.length < length + 1) return;
const type = String.fromCharCode(pending[0] ?? 0);
pending = pending.subarray(length + 1);
if (type === "Q") {
socket.write(Buffer.concat([commandComplete("SELECT 1"), READY_FOR_QUERY]));
}
}
});
socket.on("error", () => {});
});
server.listen(0, "127.0.0.1", () => {
const address = server.address() as net.AddressInfo;
resolve({
port: address.port,
close: () => server.close(),
sawSslRequest: () => sawSslRequest,
});
});
});

const readCString = (body: Buffer, offset: number): readonly [string, number] => {
const end = body.indexOf(0, offset);
return [body.toString("utf8", offset, end), end + 1];
Expand Down Expand Up @@ -793,3 +848,84 @@ describe("acquirePgPool", () => {
}),
);
});

describe("a local target's explicit TLS request (CLI-2366: honor --db-url's own sslmode/sslrootcert)", () => {
it.live("attempts TLS instead of forcing plaintext when a local target's DSN sets sslmode", () =>
Effect.gen(function* () {
const server = yield* Effect.promise(fakeStartupServer);
const error = yield* connectFailure({ port: server.port, sslmode: "require" }).pipe(
Effect.ensuring(Effect.sync(server.close)),
);
expect(server.sawSslRequest()).toBe(true);
expect(error.message).toContain("tls error (The server does not support SSL connections)");
expect(error.suggestion).toBe(
"This server does not accept TLS. Set `sslmode=disable` on the connection string to connect in plaintext.",
);
}),
);

it.live("stays plaintext for a local target when sslmode=disable is set explicitly", () =>
Effect.gen(function* () {
const server = yield* Effect.promise(fakeStartupServer);
yield* Effect.gen(function* () {
const pool = yield* acquirePgPool(
{
host: "127.0.0.1",
port: server.port,
user: "postgres",
password: "postgres",
database: "postgres",
sslmode: "disable",
},
{ isLocal: true, dnsResolver: "native" },
);
yield* Effect.tryPromise(() => pool.query("select 1"));
}).pipe(Effect.scoped, Effect.ensuring(Effect.sync(server.close)));
expect(server.sawSslRequest()).toBe(false);
}),
);

it.live(
"stays plaintext for a local target with no sslmode/sslrootcert set (the default must not regress)",
() =>
Effect.gen(function* () {
const server = yield* Effect.promise(fakeStartupServer);
yield* Effect.gen(function* () {
const pool = yield* acquirePgPool(
{
host: "127.0.0.1",
port: server.port,
user: "postgres",
password: "postgres",
database: "postgres",
},
{ isLocal: true, dnsResolver: "native" },
);
yield* Effect.tryPromise(() => pool.query("select 1"));
}).pipe(Effect.scoped, Effect.ensuring(Effect.sync(server.close)));
expect(server.sawSslRequest()).toBe(false);
}),
);

it.live(
"loads sslrootcert for a local target when the DSN explicitly set it, instead of silently ignoring it",
() =>
Effect.gen(function* () {
const missingPath = "/tmp/cli-2366-missing-sslrootcert.pem";
const error = yield* connectFailure({
port: 54322,
sslrootcert: missingPath,
sslmode: "verify-full",
});
expect(error.message).toContain(`failed to read sslrootcert ${missingPath}`);
}),
);

it.live("keeps a remote target's sslrootcert loading unchanged", () =>
Effect.gen(function* () {
const missingPath = "/tmp/cli-2366-missing-sslrootcert-remote.pem";
const error = yield* connectFailure({ port: 5432, sslrootcert: missingPath }, false);
expect(error.message).toContain(`failed to read sslrootcert ${missingPath}`);
}),
);
});
67 changes: 53 additions & 14 deletions apps/cli/src/command-internal/db-connection.sql-pg.layer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
connectFailureMessage,
connectSuggestion,
isDialFailure,
isIPv6ConnectivityErrorCause,
isSqlState,
} from "./connect-errors.ts";
import { DbConnectError, DbCopyError, DbExecError } from "./db-connection.errors.ts";
Expand Down Expand Up @@ -395,6 +396,19 @@ export interface ClientCert {
readonly passphrase?: string;
}

/**
* Whether the DSN itself asked for TLS behavior: `--db-url`'s `sslmode`/`sslrootcert` are honored
* even against a target classified local (e.g. a TLS tunnel on the loopback stack), so `isLocal`
* alone must not force plaintext when one of these is set.
*/
export function tlsExplicitlyRequested(cfg: PgConnInput): boolean {
return (
cfg.sslmode !== undefined ||
(cfg.sslrootcert?.length ?? 0) > 0 ||
(cfg.sslrootcertInline?.length ?? 0) > 0
);
Comment on lines +404 to +409

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (blocking): this is a regression of local --db-url (and every other DbConnection command), not just a too-eager helper.

967878c85 was meant to honor an explicit TLS demand on a target classified local — a TLS tunnel on 127.0.0.1 with sslmode=require / verify-full / sslrootcert. That part is right: before this commit, sslConfigsFor returned [false] whenever isLocal was true, so the DSN’s TLS settings never reached the wire.

The predicate as written treats any defined sslmode as that demand:

cfg.sslmode !== undefined || sslrootcert || sslrootcertInline

That is not the same as “the DSN asked for TLS”. Two realistic inputs now lift the exemption and then hit TLS-only dialing (sslConfigsFor maps prefer / unset to TLS with no plaintext fallback, isLocal && !explicitTls at the acquisition site):

  1. ?sslmode=prefer on a loopback DSN. libpq prefer means try TLS, then plaintext. This CLI has never implemented that fallback — prefer is TLS-only — but local classification previously forced plaintext before that mapping ran. After this commit, prefer is “explicit TLS” and the local Postgres that does not speak SSL fails with a handshake error instead of connecting.
  2. PGSSLMODE (or a service-file sslmode) with no sslmode in the DSN. parseConnectionString copies those into cfg.sslmode when the URL omits it (db-config.parse.ts, URL and keyword parsers). A developer with PGSSLMODE=prefer (or require) in the environment, then gen types --db-url postgresql://postgres:postgres@127.0.0.1:54322/postgres, now fails against the local stack. The same path is used by db query --db-url and anything else that goes through acquirePgPoolConnection.

That is a behavior change from 5b15cdd91 / pre-exemption-fix: a classified-local DSN with no TLS keys in the URL connected in plaintext. It is also wider than the bug that was filed — a missing sslmode=require on a TLS loopback tunnel. sslmode=disable happens to still work only because sslConfigsFor special-cases it after the exemption is already gone.

Dogfood on this SHA confirmed the intended tunnel case: compose and native loopback with a bare DSN and with sslmode=disable still connect; sslmode=require against plaintext Postgres now fail-closes (SSLRequest on the wire). The hole is the prefer / env-filled sslmode case, which this helper cannot distinguish from require.

Smallest fix: treat TLS as explicitly requested only for require / verify-ca / verify-full, or when sslrootcert / an inline CA is set. Leave prefer / allow / unset / env-filled prefer on a local target as plaintext. That keeps the TLS-tunnel fix and restores the local --db-url that used to work.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are right, and this landed before I saw the review — follow-up in #6664.

Your diagnosis is exact, and the PGSSLMODE half is the part I got wrong twice: I noted the ambient-env risk while designing the predicate, convinced myself it was acceptable because libpq would also honour it, and missed that prefer is the common ambient value and that this CLI maps prefer to TLS-only with no fallback. Treating "has an sslmode" as "demands TLS" conflated a fallback description with a demand.

#6664 narrows it to require / verify-ca / verify-full, or a supplied sslrootcert / inline CA — your smallest fix.

Reproduced the regression against a plaintext Postgres on the local-classified port, before and after, same database:

Connection merged develop #6664
bare loopback DSN connects connects
PGSSLMODE=prefer fails connects
PGSSLMODE=allow fails connects
?sslmode=prefer fails connects
?sslmode=disable connects connects
?sslmode=require fails closed fails closed
PGSSLMODE=verify-full fails closed fails closed

The tunnel case from the previous round is unchanged — an explicit demand still puts an SSLRequest on the wire against a loopback target, and a root cert still opens the CA gate.

On test coverage: the unit tests I shipped last round actively asserted the broad behaviour (including sslmode=disable counting as a demand), so they encoded the bug rather than catching it. Those assertions are corrected, and there is now a test that runs the real DSN parser with a PGSSLMODE env fill-in into the predicate, so the env-sourced case is covered end to end instead of reasoned about.

}

export function sslOptionFor(
sslmode: string | undefined,
isLocal: boolean,
Expand Down Expand Up @@ -442,6 +456,9 @@ export function sslOptionFor(
* so a failed handshake on the default `prefer` mode fails loudly rather than silently
* downgrading to plaintext. `servername` targets the original hostname per dial host when a
* DoH-resolved IP was substituted; `caCert` promotes `require` to `verify-ca` when set.
* `isLocal` is the caller's TLS-exemption decision, not the raw target classification: a local
* target that explicitly set `sslmode`/`sslrootcert` (see {@link tlsExplicitlyRequested}) is not
* exempt, so the caller passes `false` for it in that case.
*/
export function sslConfigsFor(
sslmode: string | undefined,
Expand Down Expand Up @@ -610,7 +627,11 @@ export const acquireProbedPool = <P extends ProbePool>(
});

/** Maps a driver connect failure to a credential-free `DbConnectError`. */
const toConnectError = (cfg: PgConnInput, isLocal: boolean, error: unknown): DbConnectError => {
export const toConnectError = (
cfg: PgConnInput,
isLocal: boolean,
error: unknown,
): DbConnectError => {
const suggestion =
cfg.suggestionContext === undefined
? undefined
Expand All @@ -619,6 +640,7 @@ const toConnectError = (cfg: PgConnInput, isLocal: boolean, error: unknown): DbC
message: `failed to connect to postgres: ${connectFailureMessage(cfg, error)}`,
...(suggestion === undefined ? {} : { suggestion }),
...(isDialFailure(error) ? { retryable: true } : {}),
...(isIPv6ConnectivityErrorCause(error) ? { ipv6Unreachable: true } : {}),
});
};

Expand All @@ -630,6 +652,9 @@ const toConnectError = (cfg: PgConnInput, isLocal: boolean, error: unknown): DbC
*/
const acquirePgPoolConnection = (cfg: PgConnInput, { isLocal, dnsResolver }: DbConnectOptions) =>
Effect.gen(function* () {
// A local target that explicitly set `sslmode`/`sslrootcert` (e.g. a TLS tunnel on the
// loopback stack) is not exempt from TLS; only the default loopback case stays plaintext.
const explicitTls = tlsExplicitlyRequested(cfg);
// Dials the primary host then each HA fallback from `cfg.fallbacks`, in order. When
// `--dns-resolver https` is set, each host resolves to all its Cloudflare DoH IPs up front
// and each is retried in turn; the original hostname is kept as the TLS `servername` so
Expand Down Expand Up @@ -682,21 +707,28 @@ const acquirePgPoolConnection = (cfg: PgConnInput, { isLocal, dnsResolver }: DbC
// `failed to connect to postgres:` prefix plus the connection identity and underlying driver
// cause, not the bare `SqlError` toString, which drops that detail.
// Loads the `sslrootcert` CA bundle; a missing/unreadable file aborts. Skipped for local
// connections. Loaded whenever any dial target is non-socket, since a socket primary can
// still have a TCP fallback that needs it ({@link sslConfigsFor} already plaintexts socket
// targets).
// connections, unless the local target explicitly requested TLS. Loaded whenever any dial
// target is non-socket, since a socket primary can still have a TCP fallback that needs it
// ({@link sslConfigsFor} already plaintexts socket targets).
const rootcertPath = cfg.sslrootcert;
const anyTcpTarget = dialTargets.some(({ dialHost }) => !isUnixSocketHost(dialHost));
const caCert =
rootcertPath !== undefined && rootcertPath.length > 0 && !isLocal && anyTcpTarget
? yield* Effect.try({
try: () => readFileSync(rootcertPath, "utf8"),
catch: (error) =>
new DbConnectError({
message: `failed to read sslrootcert ${rootcertPath}: ${error}`,
}),
})
: undefined;
cfg.sslrootcertInline !== undefined &&
cfg.sslrootcertInline.length > 0 &&
(!isLocal || explicitTls)
? cfg.sslrootcertInline
: rootcertPath !== undefined &&
rootcertPath.length > 0 &&
(!isLocal || explicitTls) &&
anyTcpTarget
? yield* Effect.try({
try: () => readFileSync(rootcertPath, "utf8"),
catch: (error) =>
new DbConnectError({
message: `failed to read sslrootcert ${rootcertPath}: ${error}`,
}),
})
: undefined;

// Loads the client `sslcert`/`sslkey` for cert auth, using the same non-local/TCP gate as
// the CA bundle; `sslpassword` decrypts an encrypted key. Bound to locals so the narrowing
Expand Down Expand Up @@ -728,7 +760,14 @@ const acquirePgPoolConnection = (cfg: PgConnInput, { isLocal, dnsResolver }: DbC
// each dial target (host × resolved IPs), with `servername` per target set to the original
// hostname when dialing a DoH-resolved IP.
const attempts = dialTargets.flatMap(({ dialHost, port, servername }) =>
sslConfigsFor(cfg.sslmode, isLocal, servername, caCert, dialHost, clientCert).map((ssl) => ({
sslConfigsFor(
cfg.sslmode,
isLocal && !explicitTls,
servername,
caCert,
dialHost,
clientCert,
).map((ssl) => ({
pool: makePool(dialHost, port, ssl),
// The fallback chain only short-circuits on an auth error when the failed attempt used
// TLS; a TLS config is any non-plaintext `ssl` value.
Expand Down
Loading