From a5bb8cf1c1acbc4c8e07931a44c760394b93f072 Mon Sep 17 00:00:00 2001 From: U1 <56990620+CHNHJF@users.noreply.github.com> Date: Mon, 14 Sep 2026 22:49:15 +0800 Subject: [PATCH] fix(connection): strip invisible characters from saved credentials --- .../connection/ConnectionDialog.vue | 3 + .../connection/TunnelProfileManager.vue | 6 +- .../src/lib/connection/credentialSanitizer.ts | 45 +++++++++ .../app-tests/credentialSanitizer.test.ts | 92 +++++++++++++++++++ 4 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/src/lib/connection/credentialSanitizer.ts create mode 100644 packages/app-tests/credentialSanitizer.test.ts diff --git a/apps/desktop/src/components/connection/ConnectionDialog.vue b/apps/desktop/src/components/connection/ConnectionDialog.vue index bfa5682f98..bf1b0c4d2b 100644 --- a/apps/desktop/src/components/connection/ConnectionDialog.vue +++ b/apps/desktop/src/components/connection/ConnectionDialog.vue @@ -42,6 +42,7 @@ import type { NacosAdminConfig, NacosApiPlane, NacosAuthConfig, NacosImplementat import { CONNECTION_ATTEMPT_CANCELLED_MESSAGE, useConnectionStore } from "@/stores/connectionStore"; import { useTunnelProfileStore } from "@/stores/tunnelProfileStore"; import { detachTunnelProfileLayer, tunnelProfileReferenceLayer, tunnelProfileSummary } from "@/lib/connection/tunnelProfiles"; +import { sanitizeConnectionCredentials } from "@/lib/connection/credentialSanitizer"; import { applySshAuthMethod, inferSshAuthMethod } from "@/lib/connection/sshAuthMethod"; import { applySshConfigHostAliasPrefill as prefillSshConfigHostAlias } from "@/lib/connection/sshConfigHosts"; import { canPersistConnectionTestResult, connectionEditDraftSyncAction } from "./connectionEditDraftSync"; @@ -4573,6 +4574,8 @@ function connectionConfigForSubmit(id: string, generatedName = "", validatePlugi if (!config.show_system_schemas) config.show_system_schemas = undefined; if (config.visible_schemas && Object.keys(config.visible_schemas).length === 0) config.visible_schemas = undefined; if (config.agent_java_options && config.agent_java_options.length === 0) config.agent_java_options = undefined; + // Pasted credentials may carry invisible characters that trim() keeps (#9043). + sanitizeConnectionCredentials(config); return config as ConnectionConfig; } diff --git a/apps/desktop/src/components/connection/TunnelProfileManager.vue b/apps/desktop/src/components/connection/TunnelProfileManager.vue index dbd0a7d014..5174ba9074 100644 --- a/apps/desktop/src/components/connection/TunnelProfileManager.vue +++ b/apps/desktop/src/components/connection/TunnelProfileManager.vue @@ -14,6 +14,7 @@ import { useTunnelProfileStore } from "@/stores/tunnelProfileStore"; import { createTunnelProfile, createTunnelProfileTestGuard, tunnelProfileSummary, type TunnelProfileType } from "@/lib/connection/tunnelProfiles"; import { applySshConfigHostAliasPrefill as prefillSshConfigHostAlias } from "@/lib/connection/sshConfigHosts"; import { applySshAuthMethod } from "@/lib/connection/sshAuthMethod"; +import { stripInvisibleCharactersFromLayer } from "@/lib/connection/credentialSanitizer"; import * as api from "@/lib/backend/api"; import type { SshConfigHostEntry, TunnelProfile } from "@/types/database"; import { translateBackendError } from "@/i18n/backend-errors"; @@ -151,7 +152,10 @@ async function save() { invalidateProfileTest(); isSaving.value = true; try { - await store.saveProfiles(cloneProfiles(draft.value)); + // Pasted credentials may carry invisible characters that trim() keeps (#9043). + const sanitized = cloneProfiles(draft.value); + sanitized.forEach(stripInvisibleCharactersFromLayer); + await store.saveProfiles(sanitized); toast(t("settings.tunnelsSaved")); } catch (error) { toast(t("settings.tunnelsSaveFailed", { message: translateBackendError(t, error) }), 5000); diff --git a/apps/desktop/src/lib/connection/credentialSanitizer.ts b/apps/desktop/src/lib/connection/credentialSanitizer.ts new file mode 100644 index 0000000000..ca4ec83d62 --- /dev/null +++ b/apps/desktop/src/lib/connection/credentialSanitizer.ts @@ -0,0 +1,45 @@ +import type { TransportLayerConfig } from "@/types/database"; + +// Zero-width and other invisible characters (U+200B ZERO WIDTH SPACE, +// U+200C/ZWNJ, U+200D/ZWJ, U+2060 WORD JOINER, U+FEFF BOM) can ride along when +// a credential is pasted from web pages or rich-text sources. They survive +// String.trim() — not ASCII whitespace — and turn a visually identical +// password into a different secret, so the connection fails with "incorrect +// username/password" (#9043). Strip them from credential fields before save. +// The set also covers direction marks (U+200E/U+200F, U+202A–U+202E, +// U+2066–U+2069), the soft hyphen U+00AD, and the invisible operators +// U+2061–U+2064. NBSP (U+00A0) and ASCII whitespace are deliberately kept: +// they are legitimate password characters. +const INVISIBLE_CHARACTERS = /[\u00AD\u200B-\u200F\u202A-\u202E\u2060-\u2064\u2066-\u2069\uFEFF]/g; + +export function stripInvisibleCharacters(value: string | undefined): string | undefined { + if (value === undefined) return undefined; + return value.replace(INVISIBLE_CHARACTERS, ""); +} + +/** Strips invisible characters from the credential fields of one transport layer, in place. */ +export function stripInvisibleCharactersFromLayer(layer: TransportLayerConfig): void { + if (layer.type === "ssh") { + layer.user = stripInvisibleCharacters(layer.user) ?? layer.user; + layer.password = stripInvisibleCharacters(layer.password); + layer.key_passphrase = stripInvisibleCharacters(layer.key_passphrase); + } else if (layer.type === "proxy") { + layer.username = stripInvisibleCharacters(layer.username) ?? layer.username; + layer.password = stripInvisibleCharacters(layer.password); + } else { + layer.token = stripInvisibleCharacters(layer.token); + } +} + +export interface CredentialBearingConfig { + username?: string; + password?: string; + transport_layers?: TransportLayerConfig[]; +} + +/** Strips invisible characters from the credential fields of a connection config, in place. */ +export function sanitizeConnectionCredentials(config: CredentialBearingConfig): void { + config.username = stripInvisibleCharacters(config.username) ?? config.username; + config.password = stripInvisibleCharacters(config.password) ?? config.password; + config.transport_layers?.forEach(stripInvisibleCharactersFromLayer); +} diff --git a/packages/app-tests/credentialSanitizer.test.ts b/packages/app-tests/credentialSanitizer.test.ts new file mode 100644 index 0000000000..a6a4bdcc39 --- /dev/null +++ b/packages/app-tests/credentialSanitizer.test.ts @@ -0,0 +1,92 @@ +import { strict as assert } from "node:assert"; +import { describe, it } from "vitest"; +import { sanitizeConnectionCredentials, stripInvisibleCharacters, stripInvisibleCharactersFromLayer } from "../../apps/desktop/src/lib/connection/credentialSanitizer.ts"; +import type { TransportLayerConfig } from "../../apps/desktop/src/types/database.ts"; + +describe("stripInvisibleCharacters", () => { + it("strips zero-width characters that survive trim()", () => { + // U+200B ZERO WIDTH SPACE, U+FEFF BOM: invisible but not whitespace, so + // trim() keeps them and the stored secret silently differs from the typed + // one (#9043). + assert.equal(stripInvisibleCharacters("secret\u200B"), "secret"); + assert.equal(stripInvisibleCharacters("\uFEFFsecret"), "secret"); + assert.equal(stripInvisibleCharacters("se\u200Ccre\u200Dt"), "secret"); + assert.equal(stripInvisibleCharacters("se\u2060cret"), "secret"); + }); + + it("strips direction marks, soft hyphen, and invisible operators", () => { + assert.equal(stripInvisibleCharacters("secret\u200E"), "secret"); + assert.equal(stripInvisibleCharacters("se\u200Fcret"), "secret"); + assert.equal(stripInvisibleCharacters("\u202Asecret\u202C"), "secret"); + assert.equal(stripInvisibleCharacters("se\u202Ecret"), "secret"); + assert.equal(stripInvisibleCharacters("se\u2066cre\u2069t"), "secret"); + assert.equal(stripInvisibleCharacters("se\u00ADcret"), "secret"); + assert.equal(stripInvisibleCharacters("se\u2061cre\u2064t"), "secret"); + }); + + it("keeps meaningful characters, including whitespace and special symbols", () => { + assert.equal(stripInvisibleCharacters("p@ss & !word *^#"), "p@ss & !word *^#"); + assert.equal(stripInvisibleCharacters(" leading and trailing "), " leading and trailing "); + assert.equal(stripInvisibleCharacters("tab\tinside"), "tab\tinside"); + // NBSP is a legitimate password character, not a format character. + assert.equal(stripInvisibleCharacters("nb\u00A0sp"), "nb\u00A0sp"); + }); + + it("passes through undefined and empty strings", () => { + assert.equal(stripInvisibleCharacters(undefined), undefined); + assert.equal(stripInvisibleCharacters(""), ""); + assert.equal(stripInvisibleCharacters("\u200B"), ""); + }); +}); + +describe("stripInvisibleCharactersFromLayer", () => { + it("strips ssh user, password, and key passphrase", () => { + const layer = { type: "ssh", id: "1", host: "h", port: 22, user: "ro\u200Bot", password: "p\u2060w", key_passphrase: "k\uFEFFey" } as TransportLayerConfig; + stripInvisibleCharactersFromLayer(layer); + assert.equal(layer.type === "ssh" && layer.user, "root"); + assert.equal(layer.type === "ssh" && layer.password, "pw"); + assert.equal(layer.type === "ssh" && layer.key_passphrase, "key"); + }); + + it("strips proxy username and password", () => { + const layer = { type: "proxy", id: "2", host: "h", port: 1080, username: "u\u200Eser", password: "p\u202Aw" } as TransportLayerConfig; + stripInvisibleCharactersFromLayer(layer); + assert.equal(layer.type === "proxy" && layer.username, "user"); + assert.equal(layer.type === "proxy" && layer.password, "pw"); + }); + + it("strips http tunnel token", () => { + const layer = { type: "http_tunnel", id: "3", url: "http://h", token: "to\u2069ken" } as TransportLayerConfig; + stripInvisibleCharactersFromLayer(layer); + assert.equal(layer.type === "http_tunnel" && layer.token, "token"); + }); +}); + +describe("sanitizeConnectionCredentials", () => { + it("strips top-level and transport-layer credentials in place", () => { + const config = { + username: "ad\u200Bmin", + password: "se\u00ADcret", + transport_layers: [ + { type: "ssh", id: "1", host: "h", port: 22, user: "ro\u200Eot", password: "ssh\u2060pw", key_passphrase: undefined }, + { type: "proxy", id: "2", host: "h", port: 1080, username: "pr\u202Eoxy", password: "ppw" }, + { type: "http_tunnel", id: "3", url: "http://h", token: "tk\uFEFFn" }, + ] as TransportLayerConfig[], + }; + sanitizeConnectionCredentials(config); + assert.equal(config.username, "admin"); + assert.equal(config.password, "secret"); + assert.equal(config.transport_layers?.[0].type === "ssh" && config.transport_layers[0].user, "root"); + assert.equal(config.transport_layers?.[0].type === "ssh" && config.transport_layers[0].password, "sshpw"); + assert.equal(config.transport_layers?.[1].type === "proxy" && config.transport_layers[1].username, "proxy"); + assert.equal(config.transport_layers?.[2].type === "http_tunnel" && config.transport_layers[2].token, "tkn"); + }); + + it("leaves configs without credentials or layers untouched", () => { + const config: { username?: string; password?: string; transport_layers?: TransportLayerConfig[] } = {}; + sanitizeConnectionCredentials(config); + assert.equal(config.username, undefined); + assert.equal(config.password, undefined); + assert.equal(config.transport_layers, undefined); + }); +});