diff --git a/src/components/ConnectionModal.tsx b/src/components/ConnectionModal.tsx index efb7eab75..e553bfafb 100644 --- a/src/components/ConnectionModal.tsx +++ b/src/components/ConnectionModal.tsx @@ -388,6 +388,10 @@ export function ConnectionModal({ value={pasteInput} onChange={(e) => setPasteInput(e.target.value)} placeholder="postgres://user:pass@host:5432/db or mongodb://... or databend://..." + autoComplete="off" + spellCheck={false} + autoCorrect="off" + autoCapitalize="off" className="h-9 bg-panel border-hairline focus:border-brand-tint/50 text-xs font-mono flex-1" onKeyDown={(e) => e.key === "Enter" && handlePasteConnectionString()} /> @@ -606,6 +610,10 @@ export function ConnectionModal({ value={connectionString} onChange={(e) => setConnectionString(e.target.value)} placeholder={connectionUriPlaceholder} + autoComplete="off" + spellCheck={false} + autoCorrect="off" + autoCapitalize="off" aria-describedby={describedByHint(uiConfig, "connectionString")} className="h-10 bg-panel border-hairline focus:border-brand-tint/50 transition-all text-xs font-mono" /> @@ -756,6 +764,9 @@ export function ConnectionModal({ // Server credential, not the user's own login: "new-password" is the only // value Chrome honours to keep saved site passwords out of the field. autoComplete="new-password" + spellCheck={false} + autoCorrect="off" + autoCapitalize="off" aria-describedby={describedByHint(uiConfig, "password")} className="h-10 bg-panel border-hairline focus:border-brand-tint/50 transition-all text-xs" /> @@ -1104,6 +1115,9 @@ export function ConnectionModal({ onChange={(e) => setApiKeySecret(e.target.value)} placeholder="***" autoComplete="new-password" + spellCheck={false} + autoCorrect="off" + autoCapitalize="off" aria-describedby={describedByHint(uiConfig, "apiKeySecret")} className="h-10 bg-panel border-hairline focus:border-brand-tint/50 transition-all text-xs font-mono" /> @@ -1282,6 +1296,10 @@ export function ConnectionModal({ value={caCert} onChange={(e) => setCaCert(e.target.value)} placeholder="-----BEGIN CERTIFICATE----- Paste CA cert content here... -----END CERTIFICATE-----" + autoComplete="off" + spellCheck={false} + autoCorrect="off" + autoCapitalize="off" rows={3} className="w-full rounded-md bg-panel border border-hairline focus:border-hue-emerald-tint/50 text-xs font-mono text-fg-secondary p-2 resize-none placeholder:text-fg-subtle" /> @@ -1296,6 +1314,10 @@ export function ConnectionModal({ value={clientCert} onChange={(e) => setClientCert(e.target.value)} placeholder="-----BEGIN CERTIFICATE----- Optional client cert... -----END CERTIFICATE-----" + autoComplete="off" + spellCheck={false} + autoCorrect="off" + autoCapitalize="off" rows={3} className="w-full rounded-md bg-panel border border-hairline focus:border-hue-emerald-tint/50 text-xs font-mono text-fg-secondary p-2 resize-none placeholder:text-fg-subtle" /> @@ -1308,6 +1330,10 @@ export function ConnectionModal({ value={clientKey} onChange={(e) => setClientKey(e.target.value)} placeholder="-----BEGIN PRIVATE KEY----- Optional client key... -----END PRIVATE KEY-----" + autoComplete="off" + spellCheck={false} + autoCorrect="off" + autoCapitalize="off" rows={3} className="w-full rounded-md bg-panel border border-hairline focus:border-hue-emerald-tint/50 text-xs font-mono text-fg-secondary p-2 resize-none placeholder:text-fg-subtle" /> @@ -1461,6 +1487,9 @@ export function ConnectionModal({ onChange={(e) => setSSHPassword(e.target.value)} placeholder="••••••••" autoComplete="new-password" + spellCheck={false} + autoCorrect="off" + autoCapitalize="off" className="h-9 bg-panel border-hairline focus:border-hue-purple-tint/50 text-xs" /> @@ -1472,6 +1501,10 @@ export function ConnectionModal({ value={sshPrivateKey} onChange={(e) => setSSHPrivateKey(e.target.value)} placeholder="-----BEGIN OPENSSH PRIVATE KEY----- Paste private key here... -----END OPENSSH PRIVATE KEY-----" + autoComplete="off" + spellCheck={false} + autoCorrect="off" + autoCapitalize="off" rows={4} className="w-full rounded-md bg-panel border border-hairline focus:border-hue-purple-tint/50 text-xs font-mono text-fg-secondary p-2 resize-none placeholder:text-fg-subtle" /> @@ -1484,6 +1517,9 @@ export function ConnectionModal({ onChange={(e) => setSSHPassphrase(e.target.value)} placeholder="Key passphrase (if encrypted)" autoComplete="new-password" + spellCheck={false} + autoCorrect="off" + autoCapitalize="off" className="h-9 bg-panel border-hairline focus:border-hue-purple-tint/50 text-xs" /> diff --git a/tests/components/ConnectionModal-secret-fields.test.tsx b/tests/components/ConnectionModal-secret-fields.test.tsx new file mode 100644 index 000000000..3d13ccf74 --- /dev/null +++ b/tests/components/ConnectionModal-secret-fields.test.tsx @@ -0,0 +1,112 @@ +import "../setup-dom"; + +import { afterEach, describe, expect, test } from "bun:test"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { MotionGlobalConfig } from "framer-motion"; + +import { ConnectionModal } from "@/components/ConnectionModal"; +import { getDBConfig, isFileBased, offersSshTunnel, offersSslPanel } from "@/lib/db-ui-config"; +import { EXTERNAL_DATABASE_TYPES } from "@/lib/db/compatibility"; + +/** + * Every field of the connection dialog that holds a secret or key material keeps the browser's spell checker and + * text assistance off: a spell checker can send the text to an outside service and keep its words in the user's + * dictionary. The real dialog and the real form hook are walked through every engine, every SSL mode that shows the + * PEM fields, both SSH auth methods, the connection-string mode and the Paste URL box, so a secret field added later + * without the attributes fails here. + */ + +MotionGlobalConfig.skipAnimations = true; + +afterEach(cleanup); + +type Field = HTMLInputElement | HTMLTextAreaElement; + +/** A field is secret when it is masked, holds PEM text, or holds a URI that can carry a password. */ +function isSecret(field: Field): boolean { + if (field instanceof HTMLInputElement && field.type === "password") return true; + const placeholder = field.getAttribute("placeholder") ?? ""; + if (placeholder.includes("-----BEGIN")) return true; + if (field.id === "connectionString") return true; + return placeholder.includes("://"); +} + +function keyOf(field: Field): string { + return field.id || field.getAttribute("placeholder") || field.outerHTML; +} + +function collect(found: Map) { + for (const field of document.querySelectorAll("input, textarea")) { + if (isSecret(field)) found.set(keyOf(field), field.cloneNode() as Field); + } +} + +function clickIfPresent(name: string) { + const button = screen.queryByRole("button", { name }); + if (button) fireEvent.click(button); +} + +describe("the connection dialog's secret fields", () => { + test("turn spell checking, autocorrect, autocapitalize and autocomplete off", () => { + render( {}} onConnect={() => {}} />); + const found = new Map(); + + fireEvent.click(screen.getByRole("button", { name: "Paste URL" })); + collect(found); + + for (const type of EXTERNAL_DATABASE_TYPES) { + const label = getDBConfig(type).label; + const button = screen.queryByRole("button", { name: label }); + if (!button) continue; + fireEvent.click(button); + collect(found); + if (getDBConfig(type).showConnectionStringToggle) { + fireEvent.click(screen.getByRole("button", { name: "Connection String" })); + collect(found); + fireEvent.click(screen.getByRole("button", { name: "Host / Port" })); + } + if (isFileBased(type)) continue; + if (offersSslPanel(type)) { + if (!screen.queryByRole("button", { name: "verify-full" })) clickIfPresent("SSL / TLS"); + fireEvent.click(screen.getByRole("button", { name: "verify-full" })); + collect(found); + } + if (offersSshTunnel(type)) { + if (!screen.queryByText("Enable SSH Tunnel")) clickIfPresent("SSH Tunnel"); + const enable = screen.getByRole("checkbox", { name: "Enable SSH Tunnel" }) as HTMLInputElement; + if (!enable.checked) fireEvent.click(enable); + fireEvent.click(screen.getByRole("button", { name: "Password" })); + collect(found); + fireEvent.click(screen.getByRole("button", { name: "Private Key" })); + collect(found); + } + } + + // The walk reached every kind of secret field the dialog has today. + const keys = [...found.keys()]; + for (const expected of [ + "password", + "apiKeySecret", + "connectionString", + "••••••••", + "Key passphrase (if encrypted)", + ]) { + expect(keys).toContain(expected); + } + expect(keys.filter((k) => k.includes("-----BEGIN")).length).toBe(4); + expect(keys.some((k) => k.startsWith("postgres://"))).toBe(true); + + const offending = [...found.values()] + .filter((field) => { + const masked = field instanceof HTMLInputElement && field.type === "password"; + return ( + field.getAttribute("spellcheck") !== "false" || + field.getAttribute("autocorrect") !== "off" || + field.getAttribute("autocapitalize") !== "off" || + field.getAttribute("autocomplete") !== (masked ? "new-password" : "off") + ); + }) + .map(keyOf); + expect(offending).toEqual([]); + }); +});