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:
2026-09-24 03:25:13 +08:00
committed by GitHub
parent f807c22a36
commit 3454ce67fe
16 changed files with 549 additions and 65 deletions
+2 -2
View File
@@ -1005,7 +1005,7 @@ pub(crate) async fn run_mysql_connect(
// TLS: through a tunnel the peer is loopback, so verify-ca/verify-full
// degrade to encrypt-only `require`. Direct connections honor the mode.
let decision = crate::commands::ssh::effective_tls_decision(
crate::db::tls::tls_decision(config.ssl_mode.as_deref()),
crate::db::tls::tls_decision_for(&config.host, config.ssl_mode.as_deref()),
via_tunnel,
);
match decision {
@@ -1084,7 +1084,7 @@ pub async fn db_connect(
// connections honor the user's mode. Building this before opening the
// tunnel means a config error can't leak the tunnel.
let decision = crate::commands::ssh::effective_tls_decision(
crate::db::tls::tls_decision(config.ssl_mode.as_deref()),
crate::db::tls::tls_decision_for(&config.host, config.ssl_mode.as_deref()),
will_tunnel,
);
let tls = crate::db::tls::build_tls_config(
@@ -265,7 +265,7 @@ async fn test_pg_connection(config: &DbConfig, ssh: &SshManager) -> TestConnecti
// TLS: through a tunnel the peer is loopback, so verify-ca/verify-full
// degrade to encrypt-only `require`. Direct connections honor the mode.
let decision = crate::commands::ssh::effective_tls_decision(
crate::db::tls::tls_decision(config.ssl_mode.as_deref()),
crate::db::tls::tls_decision_for(&config.host, config.ssl_mode.as_deref()),
target.via_tunnel,
);
let tls = match crate::db::tls::build_tls_config(
@@ -368,7 +368,7 @@ async fn test_mysql_connection(config: &DbConfig, ssh: &SshManager) -> TestConne
// TLS: through a tunnel the peer is loopback, so verify-ca/verify-full
// degrade to encrypt-only `require`. Direct connections honor the mode.
let decision = crate::commands::ssh::effective_tls_decision(
crate::db::tls::tls_decision(config.ssl_mode.as_deref()),
crate::db::tls::tls_decision_for(&config.host, config.ssl_mode.as_deref()),
target.via_tunnel,
);
match decision {
+75
View File
@@ -29,6 +29,37 @@ pub fn tls_decision(ssl_mode: Option<&str>) -> TlsDecision {
}
}
/// Host suffixes of managed providers that reject plaintext connections with
/// `connection is insecure (try using 'sslmode=require')`.
const MANAGED_SSL_HOST_SUFFIXES: [&str; 3] = [".neon.tech", ".supabase.co", ".psdb.cloud"];
/// Resolve the SSL mode to use for a connection, defaulting these managed
/// providers to `require` when the connection carries no explicit mode.
///
/// Connections saved before the UI persisted `ssl_mode` (or imported without
/// it) would otherwise connect with TLS disabled and be refused by the server.
/// An explicit mode always wins, so a deliberate `disable` is never overridden,
/// and the inferred mode only encrypts — it never verifies certificates.
pub fn resolve_ssl_mode(host: &str, ssl_mode: Option<&str>) -> Option<String> {
if let Some(mode) = ssl_mode {
return Some(mode.to_string());
}
let host = host.to_ascii_lowercase();
if MANAGED_SSL_HOST_SUFFIXES
.iter()
.any(|suffix| host.ends_with(suffix))
{
return Some("require".to_string());
}
None
}
/// TLS decision for a connection, applying the managed-provider default from
/// [`resolve_ssl_mode`] before mapping to a [`TlsDecision`].
pub fn tls_decision_for(host: &str, ssl_mode: Option<&str>) -> TlsDecision {
tls_decision(resolve_ssl_mode(host, ssl_mode).as_deref())
}
/// Build a rustls `ClientConfig` for tokio-postgres, or `None` for disable.
/// `ca_path` is required for Verify; `cert_path`/`key_path` are optional client auth.
pub fn build_tls_config(
@@ -197,6 +228,50 @@ mod tests {
assert!(matches!(tls_decision(Some("bogus")), TlsDecision::Disable));
}
#[test]
fn resolve_ssl_mode_defaults_managed_hosts_to_require() {
// Saved connections that predate the form storing `ssl_mode` still work.
for host in [
"ep-hidden-star-b3hqtp4x-pooler.c-4.ap-southeast-1.aws.neon.tech",
"db.abcdefghijklmnopqrst.supabase.co",
"xxxx.us-east-2.psdb.cloud",
"EP-X-POOLER.EU.AWS.NEON.TECH",
] {
assert_eq!(resolve_ssl_mode(host, None).as_deref(), Some("require"));
}
}
#[test]
fn resolve_ssl_mode_leaves_other_hosts_and_explicit_modes_alone() {
assert_eq!(resolve_ssl_mode("localhost", None), None);
assert_eq!(resolve_ssl_mode("prod.example.com", None), None);
// An explicit mode always wins — a deliberate `disable` is never overridden.
assert_eq!(
resolve_ssl_mode("ep-x.eu.aws.neon.tech", Some("disable")).as_deref(),
Some("disable")
);
assert_eq!(
resolve_ssl_mode("ep-x.eu.aws.neon.tech", Some("verify-full")).as_deref(),
Some("verify-full")
);
}
#[test]
fn tls_decision_for_honors_managed_default_and_explicit_modes() {
assert!(matches!(
tls_decision_for("ep-x.eu.aws.neon.tech", None),
TlsDecision::Require
));
assert!(matches!(
tls_decision_for("ep-x.eu.aws.neon.tech", Some("disable")),
TlsDecision::Disable
));
assert!(matches!(
tls_decision_for("db.example.com", None),
TlsDecision::Disable
));
}
#[test]
fn build_tls_disable_returns_none() {
assert!(build_tls_config(TlsDecision::Disable, None, None, None)
@@ -4,16 +4,19 @@ import type { ConnectionFormData } from "./connectionFormData";
interface ConnectionMetadataRowProps {
form: ConnectionFormData;
onChange: (updates: Partial<ConnectionFormData>) => void;
/** Shown when the label is empty — usually the name derived from a pasted
* connection string, which Test/Save uses as a fallback. */
namePlaceholder?: string;
}
export function ConnectionMetadataRow({ form, onChange }: ConnectionMetadataRowProps) {
export function ConnectionMetadataRow({ form, onChange, namePlaceholder }: ConnectionMetadataRowProps) {
return (
<div className="space-y-3">
<label className="block text-sm text-text mb-1.5">Connection Label</label>
<Input
value={form.name}
onChange={(value) => onChange({ name: value })}
placeholder="My Production Database"
placeholder={namePlaceholder || "My Production Database"}
aria-label="Connection Label"
/>
</div>
@@ -9,14 +9,17 @@ export interface DetailedConnectionFormProps {
form: ConnectionFormData;
onChange: (updates: Partial<ConnectionFormData>) => void;
managedPreset?: "supabase" | "neon" | "planetscale" | null;
/** Placeholder for the Connection Label when the field is empty, e.g. the
* name derived from a pasted connection string. */
namePlaceholder?: string;
}
export function DetailedConnectionForm({ form, onChange, managedPreset }: DetailedConnectionFormProps) {
export function DetailedConnectionForm({ form, onChange, managedPreset, namePlaceholder }: DetailedConnectionFormProps) {
const [activeTab, setActiveTab] = useState<"general" | "tagsEnv" | "ssh">("general");
return (
<div className="space-y-4">
<ConnectionMetadataRow form={form} onChange={onChange} />
<ConnectionMetadataRow form={form} onChange={onChange} namePlaceholder={namePlaceholder} />
<div>
<div className="flex gap-6 border-b border-border mb-4">
<button type="button" onClick={() => setActiveTab("general")} className={`pb-2 text-sm cursor-pointer transition-colors ${activeTab === "general" ? "text-text border-b-2 border-text" : "text-text-muted hover:text-text"}`}>General</button>
@@ -52,6 +52,60 @@ describe("NewConnectionScreen", () => {
expect(screen.getByLabelText("User")).toHaveValue("u");
});
it("paste a Neon URL pre-fills every required setting, including SSL mode", async () => {
const user = userEvent.setup();
render(<NewConnectionScreen />);
await user.type(
screen.getByLabelText(/connection uri/i),
"postgresql://neondb_owner:npg_secret@ep-hidden-star-b3hqtp4x-pooler.c-4.ap-southeast-1.aws.neon.tech/neondb?sslmode=require&channel_binding=require",
);
// Every field the URL can supply is filled, so no manual cleanup is needed
expect(screen.getByLabelText("Host")).toHaveValue(
"ep-hidden-star-b3hqtp4x-pooler.c-4.ap-southeast-1.aws.neon.tech",
);
expect(screen.getByLabelText("Port")).toHaveValue(5432);
expect(screen.getByLabelText("User")).toHaveValue("neondb_owner");
expect(screen.getByLabelText("Password")).toHaveValue("npg_secret");
expect(screen.getByLabelText("Database")).toHaveValue("neondb");
// The label stays untouched but is suggested from the database name.
expect(screen.getByLabelText(/connection label/i)).toHaveValue("");
expect(screen.getByLabelText(/connection label/i)).toHaveAttribute(
"placeholder",
"neondb",
);
// SSL mode is visible on the SSH / SSL tab...
await user.click(screen.getByRole("button", { name: /ssh \/ ssl/i }));
await user.click(screen.getByRole("button", { name: "SSL" }));
expect(screen.getByRole("button", { name: "SSL Mode" })).toHaveTextContent("Require");
// ...and reaches the save payload with no extra clicks.
await user.click(screen.getByText("Save Connection"));
await waitFor(() => expect(createConnection).toHaveBeenCalledTimes(1));
expect(createConnection).toHaveBeenCalledWith(
expect.objectContaining({
name: "neondb",
db_type: "postgresql",
ssl_mode: "require",
username: "neondb_owner",
password: "npg_secret",
database: "neondb",
}),
);
});
it("a typed label wins over the URL-derived suggestion", async () => {
const user = userEvent.setup();
render(<NewConnectionScreen />);
await user.type(screen.getByLabelText(/connection uri/i), "postgresql://u:p@localhost:5432/db");
await user.type(screen.getByLabelText(/connection label/i), "My DB");
await user.click(screen.getByText("Save Connection"));
await waitFor(() => expect(createConnection).toHaveBeenCalledTimes(1));
expect(createConnection).toHaveBeenCalledWith(
expect.objectContaining({ name: "My DB" }),
);
});
it("clicking a provider tab reveals the form with that db_type", async () => {
const user = userEvent.setup();
render(<NewConnectionScreen />);
@@ -60,6 +114,19 @@ describe("NewConnectionScreen", () => {
expect(screen.getByLabelText("Port")).toHaveValue(3306);
});
it("NeonDB card preselects Require SSL in the save payload", async () => {
const user = userEvent.setup();
render(<NewConnectionScreen />);
await user.click(screen.getByRole("button", { name: /NeonDB/i }));
await user.type(screen.getByLabelText(/connection label/i), "Neon prod");
await user.type(screen.getByLabelText("Host"), "ep-x-pooler.eu.aws.neon.tech");
await user.click(screen.getByText("Save Connection"));
await waitFor(() => expect(createConnection).toHaveBeenCalledTimes(1));
expect(createConnection).toHaveBeenCalledWith(
expect.objectContaining({ name: "Neon prod", ssl_mode: "require" }),
);
});
it("SQLite tab swaps the URI input for a File Path input", async () => {
const user = userEvent.setup();
render(<NewConnectionScreen />);
@@ -103,6 +170,20 @@ describe("NewConnectionScreen", () => {
expect(notify).toHaveBeenCalledWith("Connection successful", "success");
});
it("tests a pasted URL with its SSL mode", async () => {
const user = userEvent.setup();
render(<NewConnectionScreen />);
await user.type(
screen.getByLabelText(/connection uri/i),
"postgresql://u:p@ep-x-pooler.eu.aws.neon.tech/db?sslmode=verify-full",
);
await user.click(screen.getByText("Test Connection"));
await waitFor(() => expect(testConnection).toHaveBeenCalledTimes(1));
expect(testConnection).toHaveBeenCalledWith(
expect.objectContaining({ ssl_mode: "verify-full" }),
);
});
it("invokes onCancel when Back is clicked", async () => {
const user = userEvent.setup();
const onCancel = vi.fn();
@@ -9,6 +9,8 @@ import { ProviderSetupGuide } from "./ProviderSetupGuide";
import {
parseConnectionString,
detectProviderFromHost,
suggestConnectionLabel,
type ParsedConnectionString,
} from "../../lib/connectionString";
import { getProviderById, type ProviderId } from "../../lib/providers";
import { validateConnectionInput } from "../../lib/utils";
@@ -53,6 +55,10 @@ function createEmptyForm(
database: null,
use_keychain: true,
ssh_password: null,
ssl_mode: null,
ssl_ca_path: null,
ssl_cert_path: null,
ssl_key_path: null,
};
}
@@ -64,6 +70,40 @@ function getDefaultPort(dbType: string): number {
);
}
type ManagedPreset = "supabase" | "neon" | "planetscale" | null;
/** Managed-provider highlight for a parsed URL (Postgres/MySQL hosts only). */
function providerForParsed(parsed: ParsedConnectionString): ManagedPreset {
return parsed.db_type === "postgresql" || parsed.db_type === "mysql"
? detectProviderFromHost(parsed.host)
: null;
}
/**
* Everything a pasted connection string can populate, so the connection can be
* tested and saved with no manual cleanup: connection type, host, port,
* user/password/database, and the `?sslmode=` TLS mode.
*
* The URL's SSL mode wins when present; otherwise the mode the user already
* picked under SSH / SSL is kept. The label is left blank when the user has not
* typed one (the derived name is offered as a placeholder and used on save by
* `buildPayload`), so a suggestion never clobbers or gets appended to.
*/
function parsedFormPatch(
parsed: ParsedConnectionString,
prev: ConnectionFormData,
): Partial<ConnectionFormData> {
return {
db_type: parsed.db_type,
host: parsed.host,
port: parsed.port ?? getDefaultPort(parsed.db_type),
username: parsed.username,
password: parsed.password,
database: parsed.database,
ssl_mode: parsed.ssl_mode ?? prev.ssl_mode ?? null,
};
}
export function NewConnectionScreen({
defaultFolderId = null,
prefilledConnectionString = "",
@@ -73,9 +113,7 @@ export function NewConnectionScreen({
onCancel,
}: NewConnectionScreenProps) {
const [stage, setStage] = useState<Stage>("entry");
const [managedPreset, setManagedPreset] = useState<
"supabase" | "neon" | "planetscale" | null
>(null);
const [managedPreset, setManagedPreset] = useState<ManagedPreset>(null);
const [form, setForm] = useState<ConnectionFormData>(() =>
createEmptyForm(
defaultFolderId,
@@ -86,6 +124,12 @@ export function NewConnectionScreen({
const [saveLoading, setSaveLoading] = useState(false);
const createConnection = useConnectionStore((s) => s.createConnection);
const notify = useNotificationStore((s) => s.notify);
// Name to use when the label is left blank: derived from the pasted URL
// (database name, else host/file stem). Shown as the Connection Label's
// placeholder and applied at save/test time, so a suggestion never
// clobbers or gets appended to typed text.
const parsedUri = parseConnectionString(form.connection_string);
const suggestedName = parsedUri ? suggestConnectionLabel(parsedUri) : "";
const revealConfigured = useCallback(
(updates: Partial<ConnectionFormData>) => {
@@ -97,26 +141,17 @@ export function NewConnectionScreen({
const handleConnectionStringChange = useCallback((value: string) => {
const parsed = parseConnectionString(value);
if (parsed) {
const provider =
parsed.db_type === "postgresql" || parsed.db_type === "mysql"
? detectProviderFromHost(parsed.host)
: null;
setManagedPreset(provider);
setForm((prev) => ({
...prev,
connection_string: value,
db_type: parsed.db_type,
host: parsed.host,
port: parsed.port ?? getDefaultPort(parsed.db_type),
username: parsed.username,
password: parsed.password,
database: parsed.database,
}));
} else {
if (!parsed) {
setManagedPreset(null);
setForm((prev) => ({ ...prev, connection_string: value }));
return;
}
setManagedPreset(providerForParsed(parsed));
setForm((prev) => ({
...prev,
connection_string: value,
...parsedFormPatch(parsed, prev),
}));
}, []);
useEffect(() => {
@@ -150,7 +185,15 @@ export function NewConnectionScreen({
? null
: getDefaultPort(provider.dbType),
...(provider.dbType === "sqlite" ? { host: "" } : {}),
...(id === "planetscale" ? { ssl_mode: "verify-full" } : {}),
// Managed providers refuse plaintext connections. Preselect
// their documented mode so Test/Save works on the first try.
// (PlanetScale/MySQL requires identity verification.)
...(id === "planetscale"
? { ssl_mode: "verify-full" as const }
: {}),
...(id === "supabase" || id === "neon"
? { ssl_mode: "require" as const }
: {}),
});
},
[revealConfigured],
@@ -166,22 +209,11 @@ export function NewConnectionScreen({
const value = updates.connection_string;
const parsed = parseConnectionString(value);
if (parsed) {
const provider =
parsed.db_type === "postgresql" || parsed.db_type === "mysql"
? detectProviderFromHost(parsed.host)
: null;
setManagedPreset(provider);
setManagedPreset(providerForParsed(parsed));
return {
...prev,
...updates,
db_type: parsed.db_type,
host: parsed.host,
port:
parsed.port ??
getDefaultPort(parsed.db_type),
username: parsed.username,
password: parsed.password,
database: parsed.database,
...parsedFormPatch(parsed, prev),
};
}
return { ...prev, ...updates };
@@ -194,7 +226,7 @@ export function NewConnectionScreen({
const buildPayload = useCallback((): ConnectionInput => {
return {
name: form.name,
name: form.name.trim() || suggestedName,
db_type: form.db_type,
host: form.host,
port: form.port,
@@ -213,8 +245,12 @@ export function NewConnectionScreen({
ssh_private_key_path: form.ssh_private_key ?? null,
ssh_password: form.ssh_password ?? null,
ssh_passphrase: form.ssh_passphrase ?? null,
ssl_mode: form.ssl_mode ?? null,
ssl_ca_path: form.ssl_ca_path ?? null,
ssl_cert_path: form.ssl_cert_path ?? null,
ssl_key_path: form.ssl_key_path ?? null,
};
}, [form]);
}, [form, suggestedName]);
const validate = useCallback((): string | null => {
const result = validateConnectionInput(buildPayload());
@@ -336,6 +372,7 @@ export function NewConnectionScreen({
<DetailedConnectionForm
form={form}
onChange={updateForm}
namePlaceholder={suggestedName || undefined}
/>
</>
)}
@@ -1,5 +1,5 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { render, screen, fireEvent } from "@testing-library/react";
import { SslFields } from "./SslFields";
const notify = vi.fn();
@@ -35,4 +35,33 @@ describe("SslFields", () => {
expect(screen.queryByLabelText("Client Certificate")).not.toBeInTheDocument();
expect(screen.queryByLabelText("Client Key")).not.toBeInTheDocument();
});
it("writes certificate paths under the names the backend persists", () => {
// Regression guard: these used to be ssl_ca_cert / ssl_client_cert /
// ssl_client_key, which the Rust ConnectionInput silently ignores — the
// connection was then saved with TLS disabled.
const onChange = vi.fn();
render(
<SslFields
values={{ ssl_mode: "verify-full", ssl_ca_path: "/tmp/ca.pem" }}
onChange={onChange}
/>,
);
expect(screen.getByLabelText("CA Certificate")).toHaveValue("/tmp/ca.pem");
fireEvent.change(screen.getByLabelText("CA Certificate"), {
target: { value: "/tmp/root.pem" },
});
expect(onChange).toHaveBeenLastCalledWith({ ssl_ca_path: "/tmp/root.pem" });
fireEvent.change(screen.getByLabelText("Client Certificate"), {
target: { value: "/tmp/client.pem" },
});
expect(onChange).toHaveBeenLastCalledWith({ ssl_cert_path: "/tmp/client.pem" });
fireEvent.change(screen.getByLabelText("Client Key"), {
target: { value: "/tmp/client.key" },
});
expect(onChange).toHaveBeenLastCalledWith({ ssl_key_path: "/tmp/client.key" });
});
});
@@ -55,14 +55,14 @@ export function SslFields({ values, onChange }: SslFieldsProps) {
<label className="block text-sm text-text mb-1.5">CA Certificate</label>
<div className="flex gap-2">
<Input
value={(values.ssl_ca_cert as string) ?? ""}
onChange={(value) => onChange({ ssl_ca_cert: value })}
value={(values.ssl_ca_path as string) ?? ""}
onChange={(value) => onChange({ ssl_ca_path: value })}
placeholder="/path/to/ca-cert.pem"
aria-label="CA Certificate"
/>
<button
type="button"
onClick={() => handlePickFile("ssl_ca_cert")}
onClick={() => handlePickFile("ssl_ca_path")}
className="px-4 py-2 rounded-full bg-surface border border-border text-sm text-text hover:bg-surface-raised transition-colors cursor-pointer"
>
Browse
@@ -74,14 +74,14 @@ export function SslFields({ values, onChange }: SslFieldsProps) {
<label className="block text-sm text-text mb-1.5">Client Certificate</label>
<div className="flex gap-2">
<Input
value={(values.ssl_client_cert as string) ?? ""}
onChange={(value) => onChange({ ssl_client_cert: value })}
value={(values.ssl_cert_path as string) ?? ""}
onChange={(value) => onChange({ ssl_cert_path: value })}
placeholder="/path/to/client-cert.pem"
aria-label="Client Certificate"
/>
<button
type="button"
onClick={() => handlePickFile("ssl_client_cert")}
onClick={() => handlePickFile("ssl_cert_path")}
className="px-4 py-2 rounded-full bg-surface border border-border text-sm text-text hover:bg-surface-raised transition-colors cursor-pointer"
>
Browse
@@ -93,14 +93,14 @@ export function SslFields({ values, onChange }: SslFieldsProps) {
<label className="block text-sm text-text mb-1.5">Client Key</label>
<div className="flex gap-2">
<Input
value={(values.ssl_client_key as string) ?? ""}
onChange={(value) => onChange({ ssl_client_key: value })}
value={(values.ssl_key_path as string) ?? ""}
onChange={(value) => onChange({ ssl_key_path: value })}
placeholder="/path/to/client-key.pem"
aria-label="Client Key"
/>
<button
type="button"
onClick={() => handlePickFile("ssl_client_key")}
onClick={() => handlePickFile("ssl_key_path")}
className="px-4 py-2 rounded-full bg-surface border border-border text-sm text-text hover:bg-surface-raised transition-colors cursor-pointer"
>
Browse
@@ -1,5 +1,6 @@
import type { DbType } from "../../lib/types";
import type { Environment } from "./EnvironmentSelect";
import type { SslMode } from "../../lib/connectionString";
export interface ConnectionFormData {
name: string;
@@ -24,4 +25,13 @@ export interface ConnectionFormData {
ssh_private_key?: string | null;
ssh_password: string | null;
ssh_passphrase?: string | null;
// SSL/TLS fields. These must live on the form data (and survive into the
// submit payload) or the SSH/SSL tab's selections are silently dropped — the
// backend then connects with TLS disabled, which managed providers (Neon,
// Supabase, PlanetScale) reject with "connection is insecure".
// `null` = unset, which the backend treats as TLS disabled.
ssl_mode?: SslMode | null;
ssl_ca_path?: string | null;
ssl_cert_path?: string | null;
ssl_key_path?: string | null;
}
@@ -10,18 +10,23 @@ const { updateConnection, loadAll } = vi.hoisted(() => ({
}));
vi.mock("../../stores/connectionStore", () => ({
useConnectionStore: (sel: (s: any) => any) =>
sel({ updateConnection, loadAll, folders: [], tags: [] }),
useConnectionStore: Object.assign(
(sel: (s: any) => any) =>
sel({ updateConnection, loadAll, folders: [], tags: [] }),
{ getState: () => ({ getConnectionPassword: async () => "pw" }) },
),
}));
vi.mock("../../lib/commands", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../lib/commands")>();
return {
...actual,
updateConnection: vi.fn(),
testConnection: vi.fn(),
testConnection: vi.fn().mockResolvedValue({ ok: true }),
saveConnectionPassword: vi.fn(),
saveConnectionSshPassword: vi.fn(),
saveConnectionSshPassphrase: vi.fn(),
getConnectionSshPassword: vi.fn().mockResolvedValue(null),
getConnectionSshPassphrase: vi.fn().mockResolvedValue(null),
deleteConnectionPassword: vi.fn(),
};
});
@@ -121,4 +126,62 @@ describe("EditConnectionModal", () => {
);
expect(vi.mocked(commands.saveConnectionPassword)).not.toHaveBeenCalled();
});
it("persists the existing SSL settings on save", async () => {
render(
<EditConnectionModal
connection={{
...baseConn,
ssl_mode: "verify-full",
ssl_ca_path: "/tmp/ca.pem",
ssl_cert_path: "/tmp/client.pem",
ssl_key_path: "/tmp/client.key",
}}
open={true}
onClose={() => {}}
onSaved={() => {}}
/>
);
fireEvent.click(screen.getByRole("button", { name: /^save$/i }));
await waitFor(() => expect(vi.mocked(commands.updateConnection)).toHaveBeenCalled());
expect(vi.mocked(commands.updateConnection)).toHaveBeenCalledWith(
"c1",
expect.objectContaining({
ssl_mode: "verify-full",
ssl_ca_path: "/tmp/ca.pem",
ssl_cert_path: "/tmp/client.pem",
ssl_key_path: "/tmp/client.key",
}),
);
});
it("tests with the same SSL + SSH config it would save", async () => {
render(
<EditConnectionModal
connection={{
...baseConn,
ssl_mode: "require",
ssh_host: "jump.example.com",
ssh_port: 22,
ssh_user: "tunnel",
ssh_auth_method: "key",
ssh_private_key_path: "/tmp/id_ed25519",
}}
open={true}
onClose={() => {}}
onSaved={() => {}}
/>
);
fireEvent.click(screen.getByRole("button", { name: /^test$/i }));
await waitFor(() => expect(vi.mocked(commands.testConnection)).toHaveBeenCalled());
expect(vi.mocked(commands.testConnection)).toHaveBeenCalledWith(
expect.objectContaining({
ssl_mode: "require",
ssh_host: "jump.example.com",
ssh_user: "tunnel",
ssh_auth_method: "key",
ssh_private_key_path: "/tmp/id_ed25519",
}),
);
});
});
@@ -4,9 +4,9 @@ import { Button } from "../ui/Button";
import { DetailedConnectionForm } from "../connections/DetailedConnectionForm";
import { useConnectionStore } from "../../stores/connectionStore";
import { useNotificationStore } from "../../stores/notificationStore";
import { updateConnection, testConnection, saveConnectionSshPassword, saveConnectionSshPassphrase } from "../../lib/commands";
import { updateConnection, testConnection, saveConnectionSshPassword, saveConnectionSshPassphrase, getConnectionSshPassword, getConnectionSshPassphrase } from "../../lib/commands";
import { persistDbPassword } from "../../lib/keychain";
import { detectProviderFromHost } from "../../lib/connectionString";
import { detectProviderFromHost, type SslMode } from "../../lib/connectionString";
import type { Connection, ConnectionInput } from "../../lib/types";
import type { ConnectionFormData } from "../connections/connectionFormData";
@@ -47,6 +47,10 @@ export function EditConnectionModal({
ssh_private_key: connection.ssh_private_key_path ?? null,
ssh_password: null,
ssh_passphrase: null,
ssl_mode: (connection.ssl_mode as SslMode | null | undefined) ?? null,
ssl_ca_path: connection.ssl_ca_path ?? null,
ssl_cert_path: connection.ssl_cert_path ?? null,
ssl_key_path: connection.ssl_key_path ?? null,
}));
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
@@ -75,6 +79,10 @@ export function EditConnectionModal({
ssh_private_key_path: form.ssh_private_key ?? null,
ssh_password: form.ssh_password ?? null,
ssh_passphrase: form.ssh_passphrase ?? null,
ssl_mode: form.ssl_mode ?? null,
ssl_ca_path: form.ssl_ca_path ?? null,
ssl_cert_path: form.ssl_cert_path ?? null,
ssl_key_path: form.ssl_key_path ?? null,
};
const updated = await updateConnection(connection.id, input);
await persistDbPassword(connection.id, form.use_keychain, form.password).catch(() => {});
@@ -104,6 +112,18 @@ export function EditConnectionModal({
if (!password) {
password = await useConnectionStore.getState().getConnectionPassword(connection.id).catch(() => null);
}
// SSH secrets live in the keychain only, so a tunneled connection can be
// tested without re-entering them.
const sshPassword =
form.ssh_password ??
(form.ssh_host
? await getConnectionSshPassword(connection.id).catch(() => null)
: null);
const sshPassphrase =
form.ssh_passphrase ??
(form.ssh_host
? await getConnectionSshPassphrase(connection.id).catch(() => null)
: null);
const result = await testConnection({
name: form.name,
@@ -116,7 +136,19 @@ export function EditConnectionModal({
folder_id: form.folder_id,
environment: form.environment,
tag_ids: form.tag_ids,
ssh_password: form.ssh_password ?? null,
// Test the same config Save would persist — a tunneled/SSL connection
// must be probed through its tunnel and TLS mode.
ssh_host: form.ssh_host ?? null,
ssh_port: form.ssh_port ?? null,
ssh_user: form.ssh_user ?? null,
ssh_auth_method: form.ssh_auth_method ?? null,
ssh_private_key_path: form.ssh_private_key ?? null,
ssh_password: sshPassword,
ssh_passphrase: sshPassphrase,
ssl_mode: form.ssl_mode ?? null,
ssl_ca_path: form.ssl_ca_path ?? null,
ssl_cert_path: form.ssl_cert_path ?? null,
ssl_key_path: form.ssl_key_path ?? null,
});
if (result.ok) {
notify("Connection successful", "success");
+78 -1
View File
@@ -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,
});
});
});
+69
View File
@@ -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