mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
fix: plugins losing the saved ssh login when copying a host (#1391)
This commit is contained in:
1 parent
d433718c35
commit
6f442c5863
2 files changed
+119
-9
No files matched your search
@@ -96,6 +96,34 @@ function describeHost(host: number | PluginSshHost): string {
|
||||
return typeof host === "number" ? `host ${host}` : `host ${host.id}`;
|
||||
}
|
||||
|
||||
const OVERRIDE_FIELDS = [
|
||||
"password",
|
||||
"key",
|
||||
"privateKey",
|
||||
"keyPassword",
|
||||
"passphrase",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* A secret the plugin set on its copy (a password the user just typed) wins
|
||||
* over the stored one; everything else comes from what core resolved.
|
||||
*/
|
||||
function withOverrides(
|
||||
resolved: SshConnectHost,
|
||||
given: PluginSshHost,
|
||||
): SshConnectHost {
|
||||
if (given === (resolved as unknown)) return resolved;
|
||||
const source = given as unknown as Record<string, unknown>;
|
||||
const set = OVERRIDE_FIELDS.filter(
|
||||
(field) => typeof source[field] === "string" && source[field] !== "",
|
||||
);
|
||||
if (set.length === 0) return resolved;
|
||||
const merged = { ...resolved } as unknown as Record<string, unknown>;
|
||||
for (const field of set) merged[field] = source[field];
|
||||
if (typeof source.authType === "string") merged.authType = source.authType;
|
||||
return merged as unknown as SshConnectHost;
|
||||
}
|
||||
|
||||
const LOGIN_FAILED =
|
||||
/all configured authentication methods failed|permission denied|authentication failed/i;
|
||||
|
||||
@@ -134,8 +162,37 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
|
||||
{ host: SshConnectHost; userId: string }
|
||||
>();
|
||||
|
||||
const remember = (key: object, host: SshConnectHost, userId: string) =>
|
||||
resolvedHosts.set(key, { host, userId });
|
||||
// Plugins often copy a host before connecting ({ ...host, port }). Spread
|
||||
// copies enumerable symbol keys, so the copy keeps this tag. Its value is an
|
||||
// opaque token, not the host, so the secrets stay out of the plugin's reach.
|
||||
const tag = Symbol("resolvedHost");
|
||||
const tokens = new WeakMap<
|
||||
object,
|
||||
{ host: SshConnectHost; userId: string }
|
||||
>();
|
||||
|
||||
const remember = (key: object, host: SshConnectHost, userId: string) => {
|
||||
const entry = { host, userId };
|
||||
resolvedHosts.set(key, entry);
|
||||
const token = Object.freeze({});
|
||||
tokens.set(token, entry);
|
||||
if (Object.isExtensible(key)) {
|
||||
Object.defineProperty(key, tag, {
|
||||
value: token,
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const lookup = (host: object) => {
|
||||
const known = resolvedHosts.get(host);
|
||||
if (known) return known;
|
||||
const token = (host as Record<symbol, unknown>)[tag];
|
||||
return token && typeof token === "object"
|
||||
? tokens.get(token as object)
|
||||
: undefined;
|
||||
};
|
||||
|
||||
const handOut = (host: SshConnectHost, userId: string): PluginSshHost => {
|
||||
const redacted = redactHostSecrets(
|
||||
@@ -148,9 +205,7 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
|
||||
/** The acting user for a call about this host. */
|
||||
const userFor = (host: number | PluginSshHost | undefined): string =>
|
||||
actingUser(
|
||||
host && typeof host === "object"
|
||||
? resolvedHosts.get(host)?.userId
|
||||
: undefined,
|
||||
host && typeof host === "object" ? lookup(host)?.userId : undefined,
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -163,8 +218,8 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
|
||||
userId: string,
|
||||
): number | SshConnectHost => {
|
||||
if (typeof host === "number") return host;
|
||||
const known = resolvedHosts.get(host);
|
||||
if (known) return known.host;
|
||||
const known = lookup(host);
|
||||
if (known) return withOverrides(known.host, host);
|
||||
return { ...(host as unknown as SshConnectHost), userId };
|
||||
};
|
||||
|
||||
@@ -173,8 +228,7 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
|
||||
* auth or host key failure counts: a timeout says nothing about the login.
|
||||
*/
|
||||
const reportLogin = (host: number | PluginSshHost, error?: unknown) => {
|
||||
const hostId =
|
||||
typeof host === "number" ? host : resolvedHosts.get(host)?.host.id;
|
||||
const hostId = typeof host === "number" ? host : lookup(host)?.host.id;
|
||||
if (!Number.isInteger(hostId) || hostId <= 0) return;
|
||||
let outcome: { ok: boolean; hostKeyChanged?: boolean } = { ok: true };
|
||||
if (error !== undefined) {
|
||||
|
||||
@@ -91,6 +91,18 @@ vi.mock("../../hosts/connect/connect-host.js", () => ({
|
||||
return { client, jumpClient: null, dispose: vi.fn(() => client.end()) };
|
||||
},
|
||||
}));
|
||||
vi.mock("../../hosts/host-resolver.js", () => ({
|
||||
resolveHostById: async (hostId: number, userId: string) => ({
|
||||
id: hostId,
|
||||
userId,
|
||||
ip: "10.0.0.5",
|
||||
port: 22,
|
||||
username: "root",
|
||||
authType: "password",
|
||||
password: "stored-secret",
|
||||
}),
|
||||
resolveHostBySyncId: async () => null,
|
||||
}));
|
||||
vi.mock("../../hosts/status/host-status-service.js", () => ({
|
||||
hostStatusService: {
|
||||
reportLogin: (hostId: number, outcome: unknown) =>
|
||||
@@ -253,6 +265,50 @@ describe("ctx.ssh", () => {
|
||||
expect(h.connects[0].target).toMatchObject({ userId: "caller" });
|
||||
});
|
||||
|
||||
it("connects a copied redacted host with its stored login", async () => {
|
||||
h.granted = new Set(["ssh:connect", "credentials:use"]);
|
||||
const ssh = createPluginSsh({
|
||||
manifest: manifest(["ssh:connect", "credentials:use"]),
|
||||
bag: new DisposableBag("fixture"),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
const host = (await ssh.resolveHost(5))!;
|
||||
expect((host as Record<string, unknown>).password).toBeUndefined();
|
||||
expect(JSON.stringify(host)).not.toContain("stored-secret");
|
||||
for (const symbol of Object.getOwnPropertySymbols(host)) {
|
||||
expect(JSON.stringify((host as never)[symbol])).not.toContain(
|
||||
"stored-secret",
|
||||
);
|
||||
}
|
||||
|
||||
await ssh.connect({ ...host, port: host.port || 22 });
|
||||
expect(h.connects[0].target).toMatchObject({ password: "stored-secret" });
|
||||
|
||||
await ssh.connect({ ...host, password: "typed", authType: "password" });
|
||||
expect(h.connects[1].target).toMatchObject({ password: "typed" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(h.logins.map((login) => login.hostId)).toEqual([5, 5]);
|
||||
});
|
||||
|
||||
it("does not honor another plugin's resolved host", async () => {
|
||||
h.granted = new Set(["ssh:connect", "credentials:use"]);
|
||||
const deps = {
|
||||
manifest: manifest(["ssh:connect", "credentials:use"]),
|
||||
audit: vi.fn(async () => {}),
|
||||
};
|
||||
const first = createPluginSsh({
|
||||
...deps,
|
||||
bag: new DisposableBag("fixture"),
|
||||
});
|
||||
const second = createPluginSsh({
|
||||
...deps,
|
||||
bag: new DisposableBag("fixture"),
|
||||
});
|
||||
const host = (await first.resolveHost(5))!;
|
||||
await second.connect({ ...host });
|
||||
expect(h.connects[0].target).not.toHaveProperty("password");
|
||||
});
|
||||
|
||||
it("passes a given stream through to the pipeline, gated like any connect", async () => {
|
||||
const stream = { throughSource: true };
|
||||
const ssh = createPluginSsh({
|
||||
|
||||
Reference in new issue
Block a user