fix(connections): persist SSL settings and honor a URL's sslmode (#36)
Connecting to a managed provider (Neon in the report) failed with "connection is insecure (try using `sslmode=require`)" because the TLS mode never reached the backend: - ConnectionFormData carried no ssl_* fields and both submit handlers (NewConnectionScreen.buildPayload, EditConnectionModal save + test) omitted them, so the SSH/SSL tab's mode was silently dropped before IPC. - SslFields wrote ssl_ca_cert / ssl_client_cert / ssl_client_key — names the Rust ConnectionInput ignores (it expects ssl_ca_path / ssl_cert_path / ssl_key_path). - parseConnectionString ignored query params, so a pasted Neon/Supabase URL's ?sslmode=require was discarded. Pasting a URI now fills every setting it can (sslmode / ssl-mode, including MySQL's REQUIRED and VERIFY_IDENTITY spellings) and offers a derived label (database name, else host/file stem) as a placeholder that Test/Save uses when the field is left blank. Managed presets pre-select their TLS mode (Supabase / Neon → Require, PlanetScale → Verify Full). Backend safety net: db::tls::resolve_ssl_mode defaults *.neon.tech / *.supabase.co / *.psdb.cloud to `require` when a connection has no ssl_mode, so connections saved before this fix keep working; an explicit mode (including disable) always wins. Tests: +15 frontend (parser, form payload, SSL field names, edit modal), +4 Rust (resolve_ssl_mode / tls_decision_for).
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { parseConnectionString, looksLikeConnectionString, detectProviderFromHost, buildConnectionUrl } from "./connectionString";
|
||||
import { parseConnectionString, looksLikeConnectionString, detectProviderFromHost, buildConnectionUrl, suggestConnectionLabel } from "./connectionString";
|
||||
import type { Connection } from "./types";
|
||||
|
||||
function makeConn(overrides: Partial<Connection> = {}): Connection {
|
||||
@@ -30,6 +30,7 @@ describe("parseConnectionString", () => {
|
||||
username: "user",
|
||||
password: "pass",
|
||||
database: "mydb",
|
||||
ssl_mode: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,6 +43,7 @@ describe("parseConnectionString", () => {
|
||||
username: "root",
|
||||
password: null,
|
||||
database: "app",
|
||||
ssl_mode: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -54,6 +56,7 @@ describe("parseConnectionString", () => {
|
||||
username: "user",
|
||||
password: "pass",
|
||||
database: "0",
|
||||
ssl_mode: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -66,6 +69,7 @@ describe("parseConnectionString", () => {
|
||||
username: null,
|
||||
password: null,
|
||||
database: null,
|
||||
ssl_mode: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -78,6 +82,7 @@ describe("parseConnectionString", () => {
|
||||
username: null,
|
||||
password: null,
|
||||
database: null,
|
||||
ssl_mode: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -90,6 +95,7 @@ describe("parseConnectionString", () => {
|
||||
username: "user",
|
||||
password: null,
|
||||
database: "db",
|
||||
ssl_mode: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -102,12 +108,51 @@ describe("parseConnectionString", () => {
|
||||
username: "user",
|
||||
password: null,
|
||||
database: "db",
|
||||
ssl_mode: "require",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null for an empty string", () => {
|
||||
expect(parseConnectionString("")).toBeNull();
|
||||
});
|
||||
it("lifts sslmode from a Neon-style URL (extra params ignored)", () => {
|
||||
const result = parseConnectionString(
|
||||
"postgresql://neondb_owner:pw@ep-hidden-star-b3hqtp4x-pooler.c-4.ap-southeast-1.aws.neon.tech/neondb?sslmode=require&channel_binding=require",
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
db_type: "postgresql",
|
||||
host: "ep-hidden-star-b3hqtp4x-pooler.c-4.ap-southeast-1.aws.neon.tech",
|
||||
username: "neondb_owner",
|
||||
password: "pw",
|
||||
database: "neondb",
|
||||
ssl_mode: "require",
|
||||
});
|
||||
});
|
||||
|
||||
it("lifts every supported sslmode value", () => {
|
||||
const mode = (q: string) => parseConnectionString(`postgresql://u@h/db?sslmode=${q}`)?.ssl_mode;
|
||||
expect(mode("disable")).toBe("disable");
|
||||
expect(mode("require")).toBe("require");
|
||||
expect(mode("verify-ca")).toBe("verify-ca");
|
||||
expect(mode("verify-full")).toBe("verify-full");
|
||||
expect(mode("VERIFY-FULL")).toBe("verify-full");
|
||||
expect(mode("verify_full")).toBe("verify-full");
|
||||
});
|
||||
|
||||
it("ignores unsupported sslmode values instead of downgrading to plaintext", () => {
|
||||
expect(parseConnectionString("postgresql://u@h/db?sslmode=allow")?.ssl_mode).toBeNull();
|
||||
expect(parseConnectionString("postgresql://u@h/db?sslmode=prefer")?.ssl_mode).toBeNull();
|
||||
expect(parseConnectionString("postgresql://u@h/db?sslmode=bogus")?.ssl_mode).toBeNull();
|
||||
});
|
||||
|
||||
it("lifts MySQL's ssl-mode vocabulary and skips TLS for non-SQL db types", () => {
|
||||
expect(parseConnectionString("mysql://u@h/db?ssl-mode=REQUIRED")?.ssl_mode).toBe("require");
|
||||
expect(parseConnectionString("mysql://u@h/db?ssl-mode=verify-full")?.ssl_mode).toBe("verify-full");
|
||||
expect(parseConnectionString("mysql://u@h/db?ssl-mode=VERIFY_IDENTITY")?.ssl_mode).toBe("verify-full");
|
||||
expect(parseConnectionString("mysql://u@h/db?ssl-mode=PREFERRED")?.ssl_mode).toBeNull();
|
||||
expect(parseConnectionString("redis://u@h?sslmode=require")?.ssl_mode).toBeNull();
|
||||
expect(parseConnectionString("sqlite:///tmp/a.db?sslmode=require")?.ssl_mode).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a non-URL string", () => {
|
||||
expect(parseConnectionString("hello world")).toBeNull();
|
||||
@@ -122,6 +167,7 @@ describe("parseConnectionString", () => {
|
||||
username: "user",
|
||||
password: null,
|
||||
database: "db",
|
||||
ssl_mode: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -134,6 +180,7 @@ describe("parseConnectionString", () => {
|
||||
username: "root",
|
||||
password: "pw",
|
||||
database: "app",
|
||||
ssl_mode: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -146,6 +193,7 @@ describe("parseConnectionString", () => {
|
||||
username: "root",
|
||||
password: "pw",
|
||||
database: "app",
|
||||
ssl_mode: null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -158,6 +206,7 @@ describe("parseConnectionString", () => {
|
||||
username: null,
|
||||
password: null,
|
||||
database: null,
|
||||
ssl_mode: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -180,6 +229,33 @@ describe("looksLikeConnectionString", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("suggestConnectionLabel", () => {
|
||||
const label = (url: string) => {
|
||||
const parsed = parseConnectionString(url)!;
|
||||
return suggestConnectionLabel(parsed);
|
||||
};
|
||||
|
||||
it("prefers the database name", () => {
|
||||
expect(
|
||||
label("postgresql://neondb_owner:pw@ep-x-pooler.c-4.ap-southeast-1.aws.neon.tech/neondb?sslmode=require"),
|
||||
).toBe("neondb");
|
||||
});
|
||||
|
||||
it("falls back to the host when there is no database", () => {
|
||||
expect(label("postgresql://u@db.example.com:5432")).toBe("db.example.com");
|
||||
});
|
||||
|
||||
it("uses the file stem for SQLite paths", () => {
|
||||
expect(label("sqlite:///Users/me/data/app-demo.sqlite")).toBe("app-demo");
|
||||
expect(label("file:///tmp/notes.db")).toBe("notes");
|
||||
});
|
||||
|
||||
it("caps the label at the 100-char validation limit", () => {
|
||||
const host = `${"a".repeat(120)}.example.com`;
|
||||
expect(label(`postgresql://u@${host}/`).length).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectProviderFromHost", () => {
|
||||
it("detects Supabase by host suffix", () => {
|
||||
expect(detectProviderFromHost("db.abcdefghijklmnopqrst.supabase.co")).toBe("supabase");
|
||||
@@ -291,6 +367,7 @@ describe("buildConnectionUrl", () => {
|
||||
username: "user",
|
||||
password: "pass",
|
||||
database: "mydb",
|
||||
ssl_mode: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,53 @@ export interface ParsedConnectionString {
|
||||
username: string | null;
|
||||
password: string | null;
|
||||
database: string | null;
|
||||
/** SSL mode lifted from the URL's `sslmode` / `ssl-mode` query param, or
|
||||
* `null` when absent/unsupported. Keeps managed-PostgreSQL URLs (Neon,
|
||||
* Supabase, …) working — they ship with `?sslmode=require` and the server
|
||||
* rejects a plaintext connection with "connection is insecure". */
|
||||
ssl_mode: SslMode | null;
|
||||
}
|
||||
|
||||
/** SSL modes the backend understands (mirrors `DbConfig.ssl_mode`). */
|
||||
export type SslMode = "disable" | "require" | "verify-ca" | "verify-full";
|
||||
|
||||
/** Alternate spellings used by other clients, mapped onto our four modes:
|
||||
* MySQL's `ssl-mode` vocabulary (`REQUIRED`, `VERIFY_IDENTITY`, …) and
|
||||
* plural forms. `allow` / `prefer` are intentionally absent — they permit a
|
||||
* plaintext fallback, which our mode set cannot express, so they return
|
||||
* `null` and the caller keeps its current/default mode. */
|
||||
const SSL_MODE_ALIASES: Record<string, SslMode> = {
|
||||
disable: "disable",
|
||||
disabled: "disable",
|
||||
require: "require",
|
||||
required: "require",
|
||||
"verify-ca": "verify-ca",
|
||||
"verify-full": "verify-full",
|
||||
// MySQL's strongest mode: chain + hostname verification.
|
||||
"verify-identity": "verify-full",
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract the TLS mode from a connection URL's query string.
|
||||
*
|
||||
* Accepts libpq's `sslmode` (PostgreSQL) and MySQL's `ssl-mode` spelling,
|
||||
* case-insensitively and hyphen/underscore-insensitively (`verify-full`,
|
||||
* `verify_full`, `VERIFY-FULL`, …). Unsupported values (`allow`, `prefer`)
|
||||
* return `null` so the caller keeps its current/default mode rather than
|
||||
* silently downgrading to plaintext.
|
||||
*/
|
||||
function parseSslMode(url: URL): SslMode | null {
|
||||
let raw: string | null = null;
|
||||
for (const [key, value] of url.searchParams) {
|
||||
const k = key.toLowerCase();
|
||||
if (k === "sslmode" || k === "ssl-mode") {
|
||||
raw = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!raw) return null;
|
||||
const normalized = raw.trim().toLowerCase().replace(/_/g, "-");
|
||||
return SSL_MODE_ALIASES[normalized] ?? null;
|
||||
}
|
||||
|
||||
const DB_PROTOCOLS: Record<string, DbType> = {
|
||||
@@ -68,6 +115,9 @@ export function parseConnectionString(input: string): ParsedConnectionString | n
|
||||
username,
|
||||
password,
|
||||
database,
|
||||
ssl_mode: db_type === "postgresql" || db_type === "mysql" || db_type === "mariadb"
|
||||
? parseSslMode(url)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -95,6 +145,25 @@ export function detectProviderFromHost(
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Suggest a connection label for a freshly pasted connection string, so the
|
||||
* connection can be saved without typing a name first.
|
||||
*
|
||||
* Preference order: the database name, then (for SQLite) the file stem, then
|
||||
* the host. Capped at 100 chars to satisfy `validateConnectionInput`, which
|
||||
* rejects longer names.
|
||||
*/
|
||||
export function suggestConnectionLabel(parsed: ParsedConnectionString): string {
|
||||
let label: string;
|
||||
if (parsed.db_type === "sqlite") {
|
||||
const file = parsed.host.split(/[\\/]/).pop() ?? "";
|
||||
label = file.replace(/\.(sqlite3?|db)$/i, "") || file;
|
||||
} else {
|
||||
label = parsed.database?.replace(/^\/+/, "") || parsed.host;
|
||||
}
|
||||
return label.trim().slice(0, 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a connection URL string from a saved connection, for copying into
|
||||
* env vars / other tools. `password` is the decrypted keychain value (or
|
||||
|
||||
Reference in New Issue
Block a user