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
36 changes: 36 additions & 0 deletions src/components/ConnectionModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()}
/>
Expand Down Expand Up @@ -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"
/>
Expand Down Expand Up @@ -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"
/>
Expand Down Expand Up @@ -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"
/>
Expand Down Expand Up @@ -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"
/>
Expand All @@ -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"
/>
Expand All @@ -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"
/>
Expand Down Expand Up @@ -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"
/>
</div>
Expand All @@ -1472,6 +1501,10 @@ export function ConnectionModal({
value={sshPrivateKey}
onChange={(e) => setSSHPrivateKey(e.target.value)}
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----&#10;Paste private key here...&#10;-----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"
/>
Expand All @@ -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"
/>
</div>
Expand Down
112 changes: 112 additions & 0 deletions tests/components/ConnectionModal-secret-fields.test.tsx
Original file line number Diff line number Diff line change
@@ -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<string, Field>) {
for (const field of document.querySelectorAll<Field>("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(<ConnectionModal isOpen onClose={() => {}} onConnect={() => {}} />);
const found = new Map<string, Field>();

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);

Check warning on line 96 in tests/components/ConnectionModal-secret-fields.test.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer "expect(keys.filter((k) => k.includes("-----BEGIN"))).toHaveLength(4)" over this generic assertion for better reporting; it works on any object with a numeric length property.

See more on https://sonarcloud.io/project/issues?id=libredb_libredb-studio&issues=AaEwj2hBm8RejBOuyKS-&open=AaEwj2hBm8RejBOuyKS-&pullRequest=1707
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([]);
});
});
Loading