fix(connection): strip invisible characters from saved credentials

This commit is contained in:
U1
2026-09-14 22:49:15 +08:00
committed by GitHub
parent e84e49fa28
commit a5bb8cf1c1
4 changed files with 145 additions and 1 deletions
@@ -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;
}
@@ -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);
@@ -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);
}
+92
View File
@@ -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);
});
});